Compare commits

...

82 Commits

Author SHA1 Message Date
sagit 02ff215f99 Merge pull request #28 from Sagit-chu/opencode/lucky-eagle
fix(gost): process WebSocket commands concurrently to prevent diagnos…
2026-02-05 14:25:24 +08:00
root 2a4e7777ab fix(gost): run TcpPing commands concurrently without config save race
When multiple TcpPing requests are sent in parallel for diagnosing
multiple remote addresses, the Go agent was processing them serially.
This caused later requests to timeout (10s) while waiting for earlier
requests to complete.

Changes:
- Only TcpPing commands run in goroutines for parallel execution
- TcpPing (read-only diagnostic) no longer triggers saveConfig()
- Other state-mutating commands remain synchronous with config save
- Add mutex to saveConfig() to protect concurrent file writes
2026-02-05 06:18:56 +00:00
sagit eac94a5719 Merge pull request #26 from Sagit-chu/opencode/hidden-pixel
fix(diagnose): parallelize TCP ping diagnostics to prevent timeout ca…
2026-02-05 12:57:10 +08:00
root 96fcd0fc57 fix(diagnose): parallelize TCP ping diagnostics to prevent timeout cascade
Previously, forward/tunnel diagnosis executed TCP pings sequentially,
causing total time to accumulate. If the first remote address timed out
(5s), subsequent checks could push total time beyond the frontend's 30s
timeout, resulting in diagnosis failure even for healthy endpoints.

Now all diagnostic tasks run in parallel using CompletableFuture, so
total time equals max(individual ping time) instead of sum.
2026-02-05 04:52:42 +00:00
sagit 06869aedfd Merge pull request #25 from Sagit-chu/opencode/kind-sailor
fix(gost): mark node failed when transport detects relay error
2026-02-05 12:21:32 +08:00
sagit 6a201131a3 Merge branch 'main' into opencode/kind-sailor 2026-02-05 12:17:27 +08:00
root 1130a55ef5 fix(gost): mark node failed when transport detects relay error
When using relay connector with noDelay=false (default), connection
errors to the final target are deferred until first read/write during
Transport(). Previously the Transport() return value was ignored,
causing the marker to never be called for unreachable targets.

Now we capture the Transport() error and mark the node as failed,
enabling failover for subsequent connections.
2026-02-05 04:09:57 +00:00
root 265cd0a50e Revert "fix(backend): enable noDelay for relay connector to fix chain failover"
This reverts commit 51cbd4b9de.
2026-02-05 04:06:46 +00:00
sagit e7ffa77b15 Merge pull request #24 from Sagit-chu/opencode/kind-sailor
fix(backend): enable noDelay for relay connector to fix chain failover
2026-02-05 11:14:46 +08:00
root 51cbd4b9de fix(backend): enable noDelay for relay connector to fix chain failover
When using relay connector with noDelay=false (default), connection
errors are deferred until first read/write. This prevents the forwarder
marker from being called, causing failover to never trigger.

Setting nodelay=true ensures connection errors propagate immediately,
allowing proper failover behavior when chain targets are unreachable.
2026-02-05 03:11:52 +00:00
sagit e122e7460d Merge pull request #23 from Sagit-chu/opencode/crisp-cabin
fix(gost): remove single-node optimization to enable forwarder failover
2026-02-05 10:18:00 +08:00
root 7c898154b3 fix(gost): remove single-node optimization to enable forwarder failover
The single-node bypass in hop.Select() was preventing FailFilter from
being applied when retry excludes reduced available nodes to one.
This caused failed forwarder nodes to keep being selected instead of
failing over to healthy alternatives.

FailFilter's built-in safety guard (len <= 1 returns as-is) ensures
the last remaining node is never permanently blocked.
2026-02-05 02:14:37 +00:00
sagit 1d19d68019 Merge pull request #22 from Sagit-chu/feat/failover-debug-logging
feat(gost): add debug logging for failover mechanism analysis
2026-02-05 09:09:09 +08:00
root 09c58e2298 feat(gost): add debug logging for failover mechanism analysis
Add debug logs to trace failover behavior:
- FailFilter.Filter(): log node name, fail count, maxFails, timeSince, failTimeout
- hop.Select(): log excludeNodes list, node selection results
- handler retry loop: log maxRetries, selected nodes, dial failures

This helps diagnose issues where failover between multiple target nodes
is not working as expected.
2026-02-05 01:06:56 +00:00
sagit 583905b7ed Merge pull request #21 from Sagit-chu/opencode/neon-nebula
fix(gost): use chain.NewNode() to properly initialize marker for fail…
2026-02-05 07:29:15 +08:00
sagit 6e3f045b9b Merge branch 'main' into opencode/neon-nebula 2026-02-05 07:26:49 +08:00
root ec41202b3c fix(gost): use chain.NewNode() to properly initialize marker for failover
When creating temporary Node instances with struct literals like
&chain.Node{Addr: host}, the marker field was not initialized.
Only chain.NewNode() properly initializes marker = selector.NewFailMarker().

Without a valid marker:
- Failed nodes cannot be marked (marker.Mark() is no-op on nil)
- Subsequent selections cannot filter out failed nodes
- Failover mechanism completely fails

Fixed locations:
- sniffer.go dial(): &chain.Node{Addr: host} -> chain.NewNode("", host)
- sniffer.go dialTLS(): &chain.Node{Addr: host} -> chain.NewNode("", host)
- local/handler.go: target := &chain.Node{} -> var target *chain.Node
- remote/handler.go: &chain.Node{Addr: host} -> chain.NewNode("", host)
2026-02-04 23:10:56 +00:00
sagit 1ee7dea8b4 Merge pull request #20 from Sagit-chu/Sagit-chu-patch-1
change beta to main
2026-02-04 16:55:44 +08:00
sagit 2d69350bab change beta to main 2026-02-04 16:54:15 +08:00
sagit c984e5b62a docs: remove stable installation instructions
docs: remove stable installation instructions
2026-02-04 16:53:06 +08:00
sagit 5b79b11101 Merge branch 'beta' into opencode/calm-sailor 2026-02-04 16:50:28 +08:00
root 2c22e600f7 docs: remove stable installation instructions 2026-02-04 08:46:41 +00:00
sagit aef284c474 Merge pull request #18 from Sagit-chu/opencode/sunny-wizard
fix(gost): sync agent version with release tag
2026-02-04 16:29:24 +08:00
root 0443cd9ceb fix(gost): sync agent version with release tag
- Change version.go default to 'dev' for local development
- Use version variable in WebSocket reporter instead of hardcoded '2.0.2'
- Inject version via -ldflags in CI build from tag name
2026-02-04 08:22:37 +00:00
sagit 3337422775 Merge pull request #17 from Sagit-chu/opencode/curious-nebula
fix(gost): add fallback when FailFilter excludes all nodes
2026-02-04 15:52:19 +08:00
root 3e046fc80e fix(gost): restore single-node bypass and preserve FailFilter backoff
Address reviewer feedback from PR #14 fix:

1. Single-node case: Bypass selector/FailFilter to ensure availability.
   This matches upstream go-gost/x behavior - single nodes should always
   be attempted regardless of recent failures.

2. Multi-node case: Preserve FailFilter's backoff contract. When all nodes
   are marked as failed, return nil to signal 'no healthy nodes' rather
   than falling back to a known-bad node. This prevents hammering unhealthy
   nodes and respects the failTimeout window.

The handler's retry loop with ExcludeNodes context handles the multi-node
failover properly - this change ensures hop.Select() provides correct
information about node health status.

Fixes intermittent forwarding failures introduced by #14.
2026-02-04 07:46:40 +00:00
root 0273bc6921 docs: add AGENTS.md for go-gost/x/registry 2026-02-04 06:36:03 +00:00
sagit be095057bd Merge pull request #14 from Sagit-chu/opencode/cosmic-pixel
fix(gost): implement failover for multi-node forwarding rules
2026-02-04 12:30:49 +08:00
root a98057d06a fix(gost): implement failover for multi-node forwarding rules (#12)
When a forwarding rule has multiple backend nodes configured, the first
node failure would cause the entire forward to fail instead of trying
the next available node.

Root causes fixed:
- FailFilter skipped filtering when only 1 node remained
- hop.Select() bypassed selector for single-node hops
- Handlers only attempted one node before giving up

Changes:
- selector/filter.go: Remove len<=1 early return, always filter failed nodes
- hop/hop.go: Remove single-node bypass, add ExcludeNodes context support
- ctx/value.go: Add ContextWithExcludeNodes/ExcludeNodesFromContext helpers
- handler/forward/local: Add maxRetries config, implement retry loop
- handler/forward/remote: Add maxRetries config, implement retry loop
- forwarder/sniffer.go: Add retry logic to dial() and dialTLS()

Closes #12
2026-02-04 04:12:38 +00:00
root 7d47903541 fix(ci): use legacy-peer-deps and add react-is dependency
- Use --legacy-peer-deps to resolve heroui peer dependency conflicts
- Add react-is required by recharts
2026-02-04 02:42:46 +00:00
root a7aabdd1dd fix(ci): remove npm cache to fix missing package-lock.json error 2026-02-04 02:34:08 +00:00
root 0357a92960 fix: 修复limit.tsx和forward.tsx的JSX语法错误
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 02:31:26 +00:00
root 8ff41c962e ci: add build check workflow and restrict release to tags only
- Add ci-build.yml for frontend/backend/agent compilation checks on push/PR
- Modify docker-build.yml to trigger only on version tags (not branches)
2026-02-04 02:27:29 +00:00
root 6f6fececa8 style: 美化用户隧道权限分配表单UI并统一代码格式
- 使用HeroUI Checkbox组件替换原生checkbox

- 重构隧道列表为card-based tile风格

- 添加选中/未选中/已分配状态视觉区分

- 应用eslint --fix代码格式统一

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 02:22:28 +00:00
root 72c2e28667 fix: 移除未使用的变量修复TypeScript编译错误 2026-02-03 15:55:30 +00:00
sagit 74c78851ca Merge pull request #11 from Sagit-chu/opencode/neon-rocket
feat: 简化用户隧道权限分配,支持多选隧道批量分配 (#8)
2026-02-03 23:28:55 +08:00
root 42732f844a fix: 为嵌套的TunnelAssignItem添加@Valid注解确保校验生效 2026-02-03 15:16:43 +00:00
root 99b8ac206a fix: 批量分配时对请求中的重复tunnelId进行去重
防止同一请求中包含重复tunnelId导致创建多条权限记录
2026-02-03 14:43:34 +00:00
root 0e5cd86ed1 feat: 简化用户隧道权限分配,支持多选隧道批量分配 (#8)
- 新增批量分配接口 POST /tunnel/user/batch-assign
- 支持一次选择多个隧道进行分配
- 每个隧道可单独设置限速规则
- flow/num/flowResetTime/expTime 自动从用户设置继承
- 前端表单简化为隧道多选列表+限速选择
- 已分配的隧道显示'已分配'标记且不可重复选择
2026-02-03 13:59:05 +00:00
root 60fc80b6ac fix: 节点更新时serverIpV4/V6/域名字段无法正确清空的问题
使用LambdaUpdateWrapper替代updateById以确保null值能正确更新到数据库

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-03 12:57:39 +00:00
root c8f0c55fda fix: 隧道新增入口节点后转发管理的入口IP不自动更新 2026-02-03 11:30:31 +00:00
root bfdaa47ea5 修复隧道编辑新增入口节点后转发规则不生效的问题 (#9)
当编辑隧道添加新入口节点时,现有转发规则的ForwardPort和GOST服务未同步更新,
导致新入口节点的端口实际不可用。

修复方案:在updateTunnel成功更新节点后,同步更新所有使用该隧道的转发规则:
- 对移除的入口节点:删除ForwardPort记录和GOST服务
- 对新增的入口节点:分配端口、创建ForwardPort记录和GOST服务
2026-02-03 09:28:38 +00:00
sagit d7b76b4590 Update README.md 2026-02-02 15:51:24 +08:00
root 2c2262b55d 完善分层 AGENTS.md,便于快速定位代码 2026-02-02 07:41:27 +00:00
root 7ca01aba5d 修复转发管理隧道下拉顺序不同步 2026-02-02 06:28:45 +00:00
sagit 0f57ec58b3 Merge pull request #10 from Sagit-chu/opencode/silent-falcon
优化拖拽排序顺滑度
2026-02-02 12:55:09 +08:00
root ac30f0172f 修复平铺模式用户切换与图表TS构建 2026-02-02 04:50:22 +00:00
root 531ba0bfed 新建隧道默认追加到末尾 2026-02-02 03:29:47 +00:00
root d787e4b07a 优化拖拽排序顺滑度 2026-02-02 03:14:18 +00:00
root 68e5d0ac0b 修复节点监控进度条频繁重置 2026-02-01 15:39:23 +00:00
root 38b70821c5 节点监控离线延迟去抖 2026-02-01 14:41:23 +00:00
root 936158dd32 节点地址输入支持域名 2026-02-01 12:34:31 +00:00
root e0d0553fd3 修复v6-only入口优先选择出口IPv6 2026-02-01 12:34:31 +00:00
root 78aa86b23c 前端版本号从tag注入并提交本地配置 2026-02-01 12:10:17 +00:00
root 1f850593dd 节点管理页支持填写IPv4/IPv6 2026-02-01 11:14:19 +00:00
root c4519c243a 组隧道链路按地址族自动匹配节点IP(v4优先) 2026-02-01 11:14:19 +00:00
root 31ccc48436 启动时迁移并回填节点双栈IP列 2026-02-01 11:14:19 +00:00
root 1c4914ec77 支持节点双栈IP字段 2026-02-01 11:14:19 +00:00
sagit 71c40127d5 Merge pull request #2 from Sagit-chu/opencode/sunny-island
fix: fix some bugs
2026-01-31 13:56:12 +08:00
root aac1d63ac0 feat: enable drag-sort in node monitoring 2026-01-31 05:52:52 +00:00
root 8222b31917 feat: enable drag-sort in tunnel management 2026-01-31 05:52:39 +00:00
root 81c9c117a2 feat: add tunnel/node update-order API clients 2026-01-31 05:52:26 +00:00
root de8e487dff feat: add update-order APIs for tunnel and node 2026-01-31 05:52:14 +00:00
root f4c59f64b0 feat: add inx ordering for tunnel and node 2026-01-31 05:51:20 +00:00
root 6adf90d45c fix: make node deletion detach tunnels safely
When deleting a node, detach it from affected tunnel chains and keep tunnel config consistent instead of deleting entire tunnels.
2026-01-31 05:00:18 +00:00
root 71eab8e07e chore: resolve latest release in install scripts
Install scripts now derive the release tag dynamically (with a CN mirror fallback) instead of being pinned to a single version.
2026-01-31 05:00:11 +00:00
sagit dd58ac67de feat: allow changing tunnel when editing forward rules
- Add tunnelId field to ForwardUpdateDto
- Refactor updateForward to support tunnel change:
  - Release old tunnel resources (GOST services + ForwardPort)
  - Allocate new tunnel resources with proper rollback
  - Preserve flow statistics (inFlow/outFlow)
- Remove tunnel selector disabled state in forward.tsx
- Bump version to 2.0.9
2026-01-24 05:27:51 +00:00
sagit e6226d8069 chore: update install scripts to use Sagit-chu repo and version 2.0.8 2026-01-24 04:59:39 +00:00
sagit 6e25e1838d ci: migrate Docker images to GHCR and add tag-triggered release
- Replace Docker Hub with GitHub Container Registry (ghcr.io)
- Add tag push trigger for automatic release creation
- Dynamic version from tag name (2.0.8 format)
- Frontend GitHub links now use env variable VITE_GITHUB_REPO
- Release artifacts auto-replace repo/version in scripts
2026-01-24 04:40:12 +00:00
sagit c0c88a8466 update readme 2026-01-24 04:28:02 +00:00
sagit 4edbb66224 feat: 支持编辑隧道入口/出口/转发链配置
- 后端: TunnelUpdateDto 添加 inNodeId, chainNodes, outNodeId 字段
- 后端: TunnelServiceImpl 重写 updateTunnel() 支持节点配置更新
- 后端: 添加自动回滚机制,更新失败时恢复原配置
- 前端: tunnel.tsx 解除编辑模式下的字段禁用限制
- 前端: 保留隧道类型不可修改(端口转发/隧道转发)
2026-01-24 04:22:55 +00:00
sagit 84e01c7851 docs: add AGENTS.md knowledge base files 2026-01-24 03:52:58 +00:00
qaq 761db1844d 修复流量暂停问题 2026-01-06 11:30:39 +08:00
qaq ca76cc29de 修复流量暂停问题 2026-01-06 11:26:43 +08:00
qaq c20a78a196 修复流量问题 2026-01-06 11:23:16 +08:00
qaq 2053cbec32 优化版本管理 2025-11-25 09:56:20 +08:00
bqlpfy 687e9c78ad Fix punctuation in README warning about version 2.0 2025-11-24 22:48:51 +08:00
bqlpfy 07105309fb Add warning for version 2.0 installation
Added warning about not installing version 2.0 due to issues.
2025-11-24 22:47:52 +08:00
qaq 31290137fe 优化gost执行诊断时计入DNS解析时间的问题 2025-11-21 16:27:58 +08:00
qaq 4a23f3b45a 优化gost执行诊断时计入DNS解析时间的问题 2025-11-21 16:24:45 +08:00
qaq 0b2974726c 修修复转发链v6格式化错误的问题 2025-11-21 10:17:53 +08:00
qaq 43aebdf8f4 修复agent时携带错误的屏蔽协议数据,编辑转发时无法使用原分配端口 2025-11-20 13:51:48 +08:00
96 changed files with 12396 additions and 6663 deletions
+68
View File
@@ -0,0 +1,68 @@
name: CI Build Check
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
frontend:
name: Build Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: vite-frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Build
run: npm run build
backend:
name: Build Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: springboot-backend
steps:
- uses: actions/checkout@v4
- name: Setup Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Build with Maven
run: mvn clean package -DskipTests
agent:
name: Build Agent
runs-on: ubuntu-latest
defaults:
run:
working-directory: go-gost
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build -v .
+219 -127
View File
@@ -1,93 +1,115 @@
name: Build and Push Images Based on Version name: Build and Push Images
# 在这里定义统一版本号
env: env:
VERSION: "2.0.4-beta" VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
REGISTRY: ghcr.io
on: on:
push: push:
branches: tags:
- main - '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
- beta
jobs: jobs:
check-version: check-version:
name: Check Version and Decide Build name: Check Version and Decide Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ env.VERSION }} version: ${{ steps.version.outputs.version }}
should_build: ${{ steps.check-tag.outputs.should_build }} should_build: ${{ steps.version.outputs.should_build }}
tag_exists: ${{ steps.check-tag.outputs.tag_exists }} should_build_gost: ${{ steps.version.outputs.should_build_gost }}
is_tag: ${{ steps.version.outputs.is_tag }}
image_owner: ${{ steps.version.outputs.image_owner }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Display version - name: Determine version and build strategy
id: version
run: | run: |
echo "Current version: ${{ env.VERSION }}" # 镜像 owner 需要小写
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
- name: Check if tag exists if [[ "${{ github.ref_type }}" == "tag" ]]; then
id: check-tag # Tag 触发:直接使用 tag 名作为版本,全量构建
run: | VERSION="${{ github.ref_name }}"
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then echo "🏷️ Tag trigger detected: $VERSION"
echo "Tag ${{ env.VERSION }} already exists, will only update files" echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "should_build=false" >> $GITHUB_OUTPUT echo "is_tag=true" >> $GITHUB_OUTPUT
echo "tag_exists=true" >> $GITHUB_OUTPUT
else
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
echo "should_build=true" >> $GITHUB_OUTPUT echo "should_build=true" >> $GITHUB_OUTPUT
echo "tag_exists=false" >> $GITHUB_OUTPUT echo "should_build_gost=true" >> $GITHUB_OUTPUT
else
# 分支触发:使用 env.VERSION,检查是否需要构建
VERSION="${{ env.VERSION }}"
echo "🌿 Branch trigger detected, using version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=false" >> $GITHUB_OUTPUT
# 检查 tag 是否已存在
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists"
echo "should_build=false" >> $GITHUB_OUTPUT
# 检查 go-gost 目录是否有变化
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
echo "✅ GOST files unchanged since tag"
echo "should_build_gost=false" >> $GITHUB_OUTPUT
else
echo "🔄 Detected changes in go-gost directory"
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
else
echo "Tag $VERSION does not exist, will build all components"
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
fi fi
build-gost: build-gost:
name: Build & Compress GOST Binary name: Build & Compress GOST Binary
needs: check-version needs: check-version
if: needs.check-version.outputs.should_build == 'true' if: needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version: '1.21' go-version: '1.23'
- name: Cache Go modules - name: Cache Go modules
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
~/.cache/go-build ~/.cache/go-build
~/go/pkg/mod ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
restore-keys: | restore-keys: |
${{ runner.os }}-go- ${{ runner.os }}-go-
- name: Install UPX - name: Install UPX
run: | run: |
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
tar -xf upx-4.2.1-amd64_linux.tar.xz tar -xf upx-4.2.1-amd64_linux.tar.xz
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/ sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
rm -rf upx-4.2.1-amd64_linux* rm -rf upx-4.2.1-amd64_linux*
- name: Build GOST binary (AMD64) - name: Build GOST binary (AMD64)
working-directory: ./go-gost working-directory: ./go-gost
run: | run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
- name: Build GOST binary (ARM64) - name: Build GOST binary (ARM64)
working-directory: ./go-gost working-directory: ./go-gost
run: | run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
- name: Compress with UPX (AMD64) - name: Compress with UPX
working-directory: ./go-gost working-directory: ./go-gost
run: | run: |
upx --best --lzma gost-amd64 upx --best --lzma gost-amd64
- name: Compress with UPX (ARM64)
working-directory: ./go-gost
run: |
upx --best --lzma gost-arm64 upx --best --lzma gost-arm64
- name: Upload GOST AMD64 artifact - name: Upload GOST AMD64 artifact
@@ -107,31 +129,40 @@ jobs:
needs: check-version needs: check-version
if: needs.check-version.outputs.should_build == 'true' if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub - name: Log in to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} registry: ${{ env.REGISTRY }}
password: ${{ secrets.DOCKER_HUB_TOKEN }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare build args
run: |
VERSION="${{ needs.check-version.outputs.version }}"
{
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
echo "VITE_APP_VERSION=$VERSION"
} > ./vite-frontend/.env.production
- name: Build and push Vite Docker images - name: Build and push Vite Docker images
run: | run: |
VERSION="${{ needs.check-version.outputs.version }}" VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
--push \ --push \
-t bqlpfy/vite-frontend:latest \ -t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
-t bqlpfy/vite-frontend:${VERSION} \ -t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
./vite-frontend ./vite-frontend
build-java: build-java:
@@ -139,17 +170,20 @@ jobs:
needs: check-version needs: check-version
if: needs.check-version.outputs.should_build == 'true' if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up JDK and Maven - name: Set up JDK and Maven
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
java-version: 21 java-version: 21
distribution: 'temurin' distribution: 'temurin'
- name: Cache Maven dependencies - name: Cache Maven dependencies
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.m2 path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
@@ -160,33 +194,149 @@ jobs:
run: mvn clean package -DskipTests run: mvn clean package -DskipTests
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub - name: Log in to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} registry: ${{ env.REGISTRY }}
password: ${{ secrets.DOCKER_HUB_TOKEN }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Java Docker images - name: Build and push Java Docker images
run: | run: |
VERSION="${{ needs.check-version.outputs.version }}" VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
--push \ --push \
-t bqlpfy/springboot-backend:latest \ -t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:latest \
-t bqlpfy/springboot-backend:${VERSION} \ -t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION} \
./springboot-backend ./springboot-backend
create-release: create-release:
name: Create Release and Tag name: Create Release (Tag Only)
needs: [check-version, build-gost, build-vite, build-java] needs: [check-version, build-gost, build-vite, build-java]
if: needs.check-version.outputs.should_build == 'true' if: needs.check-version.outputs.is_tag == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
with:
name: gost-binary-amd64
path: ./artifacts/amd64
- name: Download GOST ARM64 binary
uses: actions/download-artifact@v4
with:
name: gost-binary-arm64
path: ./artifacts/arm64
- name: Prepare release files
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
REPO="${{ github.repository }}"
# 移动二进制文件
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
# 复制并修改 docker-compose 文件
cp docker-compose-v4.yml ./artifacts/docker-compose-v4.yml
cp docker-compose-v6.yml ./artifacts/docker-compose-v6.yml
# 替换镜像地址为 GHCR
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
# 复制并修改安装脚本
cp install.sh ./artifacts/install.sh
cp panel_install.sh ./artifacts/panel_install.sh
# 替换仓库地址和版本号
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/install.sh
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/panel_install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
# 获取 commit 信息
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
COMMIT_DATE=$(git log -1 --pretty=format:"%ai")
# 创建 release
gh release create "${VERSION}" \
--title "Release ${VERSION}" \
--notes "## 📝 Release Information
- **Version**: ${VERSION}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
- **Author**: ${COMMIT_AUTHOR}
- **Date**: ${COMMIT_DATE}
- **Message**: ${COMMIT_MSG}
## 📦 Docker Images
\`\`\`bash
# Backend
docker pull ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}
# Frontend
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
\`\`\`
## 🚀 Quick Install
**Panel:**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
\`\`\`
**Node:**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`" \
--repo ${{ github.repository }}
# 上传所有文件到 release
echo "📤 上传 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "📤 上传安装脚本..."
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
echo "📤 上传 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./artifacts/docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./artifacts/docker-compose-v6.yml --clobber
echo "✅ Release ${VERSION} 创建完成"
update-release-gost:
name: Update GOST Binaries in Release
needs: [check-version, build-gost]
if: needs.check-version.outputs.is_tag == 'false' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download GOST AMD64 binary - name: Download GOST AMD64 binary
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@@ -205,73 +355,15 @@ jobs:
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64 mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64 mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
- name: Create Release - name: Update GOST binaries in Release
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
VERSION="${{ needs.check-version.outputs.version }}" VERSION="${{ needs.check-version.outputs.version }}"
# 创建并推送 tag echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
git tag "${VERSION}" ${{ github.sha }}
git push origin "${VERSION}"
# 获取 commit 信息
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
COMMIT_DATE=$(git log -1 --pretty=format:"%ai")
# 创建 release
gh release create "${VERSION}" \
--title "Release ${VERSION}" \
--notes "## 📝 Commit Information
- **Message**: ${COMMIT_MSG}
- **Author**: ${COMMIT_AUTHOR}
- **Date**: ${COMMIT_DATE}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})" \
--repo ${{ github.repository }}
# 上传所有文件到 release
echo "📤 上传 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "📤 上传安装脚本..."
gh release upload "${VERSION}" ./install.sh --clobber
gh release upload "${VERSION}" ./panel_install.sh --clobber
echo "📤 上传 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
echo "✅ 所有文件已上传到 Release ${VERSION}"
update-release-files: echo "✅ GOST 二进制文件更新完成"
name: Update Release Files Only
needs: check-version
if: needs.check-version.outputs.tag_exists == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Update Release Files
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
echo "📤 更新 Release ${VERSION} 中的脚本和配置文件..."
# 上传安装脚本(覆盖)
echo "📤 更新安装脚本..."
gh release upload "${VERSION}" ./install.sh --clobber
gh release upload "${VERSION}" ./panel_install.sh --clobber
# 上传 Docker Compose 配置文件(覆盖)
echo "📤 更新 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
echo "✅ 文件更新完成!Release ${VERSION} 中的脚本和配置已更新"
echo "ℹ️ Docker 镜像和 GOST 二进制未重新构建"
@@ -0,0 +1,71 @@
# Plan: 搭建开发环境
## 目标
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
## 当前状态
### ✅ 已安装
| 工具 | 版本 | 用途 |
|------|------|------|
| Node.js | v20.19.2 | vite-frontend |
| npm | 9.2.0 | vite-frontend |
| Go | 1.24.4 | go-gost |
| Docker | 29.1.4 | 容器化部署 |
### ❌ 缺失
| 工具 | 需求版本 | 用途 |
|------|----------|------|
| Java | 21 | springboot-backend |
| Maven | 3.x | 构建后端 |
| Docker Compose | v2 | 容器编排 |
---
## 执行任务
### Task 1: 安装 Java 21
```bash
apt-get update && apt-get install -y openjdk-21-jdk
```
**验证**: `java -version` 应显示 openjdk 21
### Task 2: 安装 Maven
```bash
apt-get install -y maven
```
**验证**: `mvn -v` 应显示 Maven 3.x
### Task 3: 安装 Docker Compose Plugin
```bash
apt-get install -y docker-compose-plugin
```
**验证**: `docker compose version` 应显示版本号
### Task 4: 安装前端依赖
```bash
cd /root/flux-panel/vite-frontend && npm install
```
**验证**: `node_modules/` 目录存在
### Task 5: 验证后端可构建
```bash
cd /root/flux-panel/springboot-backend && mvn clean compile -q
```
**验证**: 编译成功无错误
### Task 6: 验证 Go 模块
```bash
cd /root/flux-panel/go-gost && go mod download
```
**验证**: 依赖下载成功
---
## 完成标准
- [ ] `java -version` → openjdk 21
- [ ] `mvn -v` → Maven 3.x
- [ ] `docker compose version` → v2.x
- [ ] 前端: `npm run dev` 可启动
- [ ] 后端: `mvn compile` 成功
- [ ] Go: `go build .` 成功
+9
View File
@@ -0,0 +1,9 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-24T05:20:50.887Z"
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
---
Complete the task as instructed
+66
View File
@@ -0,0 +1,66 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── android-app/ # Android WebView wrapper (optional)
├── ios-app/ # iOS WebView wrapper (optional)
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CONVENTIONS
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
## COMMANDS
```bash
# Panel (Docker)
docker compose -f docker-compose-v4.yml up -d
docker compose -f docker-compose-v6.yml up -d
# Release-based install scripts
./panel_install.sh
./install.sh
# Local dev (per subproject)
(cd springboot-backend && mvn clean package)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
```
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
+9 -58
View File
@@ -1,40 +1,5 @@
# flux-panel转发面板 哆啦A梦转发面板 # flux-panel转发面板 哆啦A梦转发面板
# 赞助商
<p align="center">
<a href="https://vps.town" style="margin: 0 20px; text-align:center;">
<img src="./doc/vpstown.png" width="300">
</a>
<a href="https://whmcs.as211392.com" style="margin: 0 20px; text-align:center;">
<img src="./doc/as211392.png" width="300">
</a>
</p>
# 1.x无法无损升级到2.x请备份好数据
1.x迁移2.x流程如下
1.进入1.x的后台在转发页面导出所有转发保存
2.使用1.x节点脚本卸载所有节点
```
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
3.使用1.x面版脚本卸载面板
```
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
4.使用2.x脚本安装面板
```
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
使用1.x版本的请使用上面给出的脚本,不要使用最新的,最新的默认2.x
嫌弃麻烦或者想稳定的我不推荐你升级到2.x版本 目前还在beta阶段,很多问题。
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。 本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
--- ---
## 特性 ## 特性
@@ -51,24 +16,13 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/pane
--- ---
### Docker Compose部署 ### Docker Compose部署
#### 快速部署 #### 快速部署
面板端(1.x): 面板端:
```bash ```bash
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
``` ```
节点端(1.x): 节点端:
```bash ```bash
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/install.sh -o install.sh && chmod +x install.sh && ./install.sh curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
面板端(2.x):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(2.x):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
``` ```
#### 默认管理员账号 #### 默认管理员账号
@@ -101,17 +55,14 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/inst
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。 作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。 请务必在合法、合规、安全的前提下使用本项目。
--- ---
## ⭐ 喝杯咖啡!(USDT) ## ⭐ 喝杯咖啡!(USDT)
| 网络 | 地址 | | 网络 | 地址 |
|------------|----------------------------------------------------------------------| |------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` | | BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` | | TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` | | Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
[![Star History Chart](https://api.star-history.com/svg?repos=bqlpfy/flux-panel&type=Date)](https://www.star-history.com/#bqlpfy/flux-panel&Date)
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
backend: backend:
image: bqlpfy/springboot-backend:2.0.4-beta image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend container_name: springboot-backend
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -25,7 +25,7 @@ services:
start_period: 60s start_period: 60s
frontend: frontend:
image: bqlpfy/vite-frontend:2.0.4-beta image: bqlpfy/vite-frontend:2.0.7-beta
container_name: vite-frontend container_name: vite-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
backend: backend:
image: bqlpfy/springboot-backend:2.0.4-beta image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend container_name: springboot-backend
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -25,7 +25,7 @@ services:
start_period: 60s start_period: 60s
frontend: frontend:
image: bqlpfy/vite-frontend:2.0.4-beta image: bqlpfy/vite-frontend:2.0.7-beta
container_name: vite-frontend container_name: vite-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
+37
View File
@@ -0,0 +1,37 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
## STRUCTURE
```
go-gost/
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
├── config.go # Panel config.json loader (addr/secret + ports)
├── program.go # GOST runtime: parse config, run/reload services
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
└── go.mod # replace github.com/go-gost/x => ./x
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
## CONVENTIONS
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
## COMMANDS
```bash
cd go-gost
go run .
go test ./...
go build .
```
+1 -1
View File
@@ -119,7 +119,7 @@ func main() {
log := xlogger.NewLogger() log := xlogger.NewLogger()
logger.SetDefault(log) logger.SetDefault(log)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "2.0.0") wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
defer wsReporter.Stop() defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret) service.SetHTTPReportURL(config.Addr, config.Secret)
+1 -1
View File
@@ -1,5 +1,5 @@
package main package main
var ( var (
version = "3.1.0" version = "dev"
) )
+42
View File
@@ -0,0 +1,42 @@
# GO-GOST/X KNOWLEDGE BASE
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
## ANTI-PATTERNS
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X API KNOWLEDGE BASE
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
## CONVENTIONS
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
## CONVENTIONS
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+20
View File
@@ -109,3 +109,23 @@ func LoggerFromContext(ctx context.Context) logger.Logger {
v, _ := ctx.Value(keyLogger).(logger.Logger) v, _ := ctx.Value(keyLogger).(logger.Logger)
return v return v
} }
// excludeNodesKey saves the list of node addresses to exclude during selection.
// This is used for failover retry logic - when a node fails, it gets added to
// the exclude list so the next Select() call will skip it.
type excludeNodesKey struct{}
var (
keyExcludeNodes = &excludeNodesKey{}
)
// ContextWithExcludeNodes returns a context with the list of node addresses to exclude.
func ContextWithExcludeNodes(ctx context.Context, nodes []string) context.Context {
return context.WithValue(ctx, keyExcludeNodes, nodes)
}
// ExcludeNodesFromContext returns the list of node addresses to exclude from selection.
func ExcludeNodesFromContext(ctx context.Context) []string {
v, _ := ctx.Value(keyExcludeNodes).([]string)
return v
}
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
## STRUCTURE
```
go-gost/x/dialer/
├── direct/ # Baseline dialer
├── tcp/
├── udp/
├── tls/
├── ws/
├── quic/
├── http2/
├── http3/
├── ssh/
├── wg/ # WireGuard dialer
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
## CONVENTIONS
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+32
View File
@@ -0,0 +1,32 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/handler/
├── http/ # handler.go + metadata.go (+ udp.go)
├── socks/ # SOCKS variants
├── tunnel/ # Tunnel forwarding
├── relay/ # Relay forwarding
├── redirect/ # TCP/UDP redirect handlers
├── router/ # Routing/association entrypoints
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
## CONVENTIONS
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+83 -36
View File
@@ -176,51 +176,98 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
} }
} }
target := &chain.Node{} // Determine max retry attempts
if h.hop != nil { maxRetries := h.md.maxRetries
target = h.hop.Select(ctx, if maxRetries <= 0 {
hop.ProtocolSelectOption(proto), // Default: try all available nodes
) if nl, ok := h.hop.(hop.NodeList); ok {
} maxRetries = len(nl.Nodes())
if target == nil { }
err := errors.New("node not available") if maxRetries <= 0 {
return err maxRetries = 1
}
} }
addr := target.Addr var triedNodes []string
if opts := target.Options(); opts != nil { var lastErr error
switch opts.Network { var cc net.Conn
case "unix":
network = opts.Network h.options.Logger.Debugf("[handler.retry] starting retry loop: maxRetries=%d", maxRetries)
default:
if _, _, err := net.SplitHostPort(addr); err != nil { for attempt := 0; attempt < maxRetries; attempt++ {
addr += ":0" // Select a target node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
var target *chain.Node
if h.hop != nil {
target = h.hop.Select(selectCtx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
h.options.Logger.Debugf("[handler.retry] attempt=%d target=nil, triedNodes=%v", attempt, triedNodes)
if lastErr != nil {
return lastErr
}
return errors.New("node not available")
}
h.options.Logger.Debugf("[handler.retry] attempt=%d selected node=%s addr=%s", attempt, target.Name, target.Addr)
// Track this node as tried
triedNodes = append(triedNodes, target.Addr)
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
}
} }
} }
}
ro.Network = network ro.Network = network
ro.Host = addr ro.Host = addr
var buf bytes.Buffer var buf bytes.Buffer
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr) cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String() ro.Route = buf.String()
if err != nil { if err != nil {
// TODO: the router itself may be failed due to the failed node in the router, // Mark node as failed for future selections
// the dead marker may be a wrong operation. if marker := target.Marker(); marker != nil {
if marker := target.Marker(); marker != nil { marker.Mark()
marker.Mark() h.options.Logger.Debugf("[handler.retry] attempt=%d dial failed, marked node=%s count=%d err=%v",
attempt, target.Addr, marker.Count(), err)
}
lastErr = err
// Try next node
continue
} }
return err
}
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
xnet.Transport(conn, cc) // Success - reset marker and proceed
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
return nil if err := xnet.Transport(conn, cc); err != nil {
if marker := target.Marker(); marker != nil {
marker.Mark()
h.options.Logger.Debugf("[handler.transport] transport failed, marked node=%s count=%d err=%v",
target.Addr, marker.Count(), err)
}
return err
}
return nil
}
// All retries exhausted
if lastErr != nil {
return lastErr
}
return errors.New("all nodes failed")
} }
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool { func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
@@ -25,6 +25,12 @@ type metadata struct {
privateKey crypto.PrivateKey privateKey crypto.PrivateKey
alpn string alpn string
mitmBypass bypass.Bypass mitmBypass bypass.Bypass
// maxRetries specifies the maximum number of failover retry attempts.
// When a target node fails, the handler will try the next available node.
// 0 means use the total number of available nodes (try all nodes once).
// Default: 0 (try all available nodes)
maxRetries int
} }
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) { func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
@@ -56,5 +62,8 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.alpn = mdutil.GetString(md, "mitm.alpn") h.md.alpn = mdutil.GetString(md, "mitm.alpn")
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass")) h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
// maxRetries: 0 means try all available nodes (default behavior)
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
return return
} }
+84 -51
View File
@@ -204,68 +204,101 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
} }
} }
var target *chain.Node // Determine max retry attempts
if host != "" { maxRetries := h.md.maxRetries
target = &chain.Node{ if maxRetries <= 0 {
Addr: host, // Default: try all available nodes
if nl, ok := h.hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
} }
} if maxRetries <= 0 {
if h.hop != nil { maxRetries = 1
target = h.hop.Select(ctx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
err := errors.New("node not available")
log.Error(err)
return err
}
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
} }
} }
ro.Network = network var triedNodes []string
ro.Host = target.Addr var lastErr error
var cc net.Conn
log = log.WithFields(map[string]any{ for attempt := 0; attempt < maxRetries; attempt++ {
"node": target.Name, // Select a target node, excluding previously tried nodes
"dst": fmt.Sprintf("%s/%s", target.Addr, network), selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
}) var target *chain.Node
if host != "" {
target = chain.NewNode("", host)
}
if h.hop != nil {
target = h.hop.Select(selectCtx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
if lastErr != nil {
return lastErr
}
err := errors.New("node not available")
log.Error(err)
return err
}
log.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr) // Track this node as tried
triedNodes = append(triedNodes, target.Addr)
var buf bytes.Buffer if opts := target.Options(); opts != nil {
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr) switch opts.Network {
ro.Route = buf.String() case "unix":
if err != nil { network = opts.Network
log.Error(err) default:
// TODO: the router itself may be failed due to the failed node in the router, }
// the dead marker may be a wrong operation. }
ro.Network = network
ro.Host = target.Addr
targetLog := log.WithFields(map[string]any{
"node": target.Name,
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
})
targetLog.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
ro.Route = buf.String()
if err != nil {
targetLog.Error(err)
// Mark node as failed for future selections
if marker := target.Marker(); marker != nil {
marker.Mark()
}
lastErr = err
// Try next node
continue
}
// Success - reset marker and proceed
if marker := target.Marker(); marker != nil { if marker := target.Marker(); marker != nil {
marker.Mark() marker.Reset()
} }
return err defer cc.Close()
}
defer cc.Close() cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
if marker := target.Marker(); marker != nil {
marker.Reset() t := time.Now()
targetLog.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
xnet.Transport(conn, cc)
targetLog.WithFields(map[string]any{
"duration": time.Since(t),
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
return nil
} }
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc) // All retries exhausted
if lastErr != nil {
t := time.Now() return lastErr
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr) }
xnet.Transport(conn, cc) return errors.New("all nodes failed")
log.WithFields(map[string]any{
"duration": time.Since(t),
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
return nil
} }
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool { func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
@@ -26,6 +26,12 @@ type metadata struct {
privateKey crypto.PrivateKey privateKey crypto.PrivateKey
alpn string alpn string
mitmBypass bypass.Bypass mitmBypass bypass.Bypass
// maxRetries specifies the maximum number of failover retry attempts.
// When a target node fails, the handler will try the next available node.
// 0 means use the total number of available nodes (try all nodes once).
// Default: 0 (try all available nodes)
maxRetries int
} }
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) { func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
@@ -57,5 +63,9 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
} }
h.md.alpn = mdutil.GetString(md, "mitm.alpn") h.md.alpn = mdutil.GetString(md, "mitm.alpn")
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass")) h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
// maxRetries: 0 means try all available nodes (default behavior)
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
return return
} }
+35 -4
View File
@@ -18,6 +18,7 @@ import (
"github.com/go-gost/core/selector" "github.com/go-gost/core/selector"
"github.com/go-gost/x/config" "github.com/go-gost/x/config"
node_parser "github.com/go-gost/x/config/parsing/node" node_parser "github.com/go-gost/x/config/parsing/node"
ctxvalue "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/loader" "github.com/go-gost/x/internal/loader"
) )
@@ -141,11 +142,28 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nil return nil
} }
// Get list of nodes to exclude (for failover retry)
excludeNodes := ctxvalue.ExcludeNodesFromContext(ctx)
excludeSet := make(map[string]bool)
for _, addr := range excludeNodes {
excludeSet[addr] = true
}
// Debug logging for failover analysis
log.Debugf("[hop.Select] excludeNodes=%v, totalNodes=%d", excludeNodes, len(p.Nodes()))
var nodes []*chain.Node var nodes []*chain.Node
for _, node := range p.Nodes() { for _, node := range p.Nodes() {
if node == nil { if node == nil {
continue continue
} }
// Skip nodes in the exclude list (failover retry)
if excludeSet[node.Addr] || excludeSet[node.Name] {
log.Debugf("node %s(%s) excluded for failover retry", node.Name, node.Addr)
continue
}
// node level bypass // node level bypass
if node.Options().Bypass != nil && if node.Options().Bypass != nil &&
node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) { node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) {
@@ -177,9 +195,6 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
if len(nodes) == 0 { if len(nodes) == 0 {
return nil return nil
} }
if len(nodes) == 1 {
return nodes[0]
}
sort.Slice(nodes, func(i, j int) bool { sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Options().Priority > nodes[j].Options().Priority return nodes[i].Options().Priority > nodes[j].Options().Priority
@@ -189,9 +204,25 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nodes[0] return nodes[0]
} }
// Use selector with FailFilter for proper failover.
// FailFilter will exclude recently-failed nodes, allowing traffic to
// be routed to healthy alternatives.
// Note: FailFilter has a safety guard (len <= 1 returns as-is) to ensure
// the last remaining node is never permanently blocked.
if s := p.options.selector; s != nil { if s := p.options.selector; s != nil {
return s.Select(ctx, nodes...) log.Debugf("[hop.Select] calling selector.Select with %d nodes", len(nodes))
if node := s.Select(ctx, nodes...); node != nil {
log.Debugf("[hop.Select] selected node=%s addr=%s", node.Name, node.Addr)
return node
}
// All nodes filtered out by FailFilter - all are marked as failed.
// Return nil to signal "no healthy nodes available" to the caller.
// The handler's retry loop will handle this appropriately.
log.Debugf("all %d nodes filtered out by FailFilter, no healthy nodes available", len(nodes))
return nil
} }
// Fallback: return first node if no selector configured
return nodes[0] return nodes[0]
} }
+174 -109
View File
@@ -247,64 +247,98 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
} }
} }
node = &chain.Node{ // Determine max retry attempts
Addr: host, maxRetries := 1
if nl, ok := ho.Hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
} }
if ho.Hop != nil { if maxRetries <= 0 {
node = ho.Hop.Select(ctx, maxRetries = 1
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
hop.HostSelectOption(host),
hop.MethodSelectOption(req.Method),
hop.PathSelectOption(req.URL.Path),
hop.QuerySelectOption(req.URL.Query()),
hop.HeaderSelectOption(req.Header),
)
}
if node == nil {
ho.Log.Warnf("node for %s not found", host)
res.StatusCode = http.StatusBadGateway
ro.HTTP.StatusCode = res.StatusCode
res.Write(conn)
return nil, nil, errors.New("node not available")
} }
ro.Host = node.Addr var triedNodes []string
ho.Log = ho.Log.WithFields(map[string]any{ var lastErr error
"node": node.Name,
"dst": node.Addr,
})
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
cc, err = dial(ctx, "tcp", node.Addr) for attempt := 0; attempt < maxRetries; attempt++ {
if err != nil { // Select a node, excluding previously tried nodes
// TODO: the router itself may be failed due to the failed node in the router, selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
// the dead marker may be a wrong operation.
if marker := node.Marker(); marker != nil { node = chain.NewNode("", host)
marker.Mark() if ho.Hop != nil {
node = ho.Hop.Select(selectCtx,
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
hop.HostSelectOption(host),
hop.MethodSelectOption(req.Method),
hop.PathSelectOption(req.URL.Path),
hop.QuerySelectOption(req.URL.Query()),
hop.HeaderSelectOption(req.Header),
)
} }
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err) if node == nil {
res.Write(conn) if lastErr != nil {
return ho.Log.Warnf("node for %s not found after retries", host)
} res.StatusCode = http.StatusBadGateway
if marker := node.Marker(); marker != nil { ro.HTTP.StatusCode = res.StatusCode
marker.Reset() res.Write(conn)
} return nil, nil, lastErr
}
if tlsSettings := node.Options().TLS; tlsSettings != nil { ho.Log.Warnf("node for %s not found", host)
cfg := &tls.Config{ res.StatusCode = http.StatusBadGateway
ServerName: tlsSettings.ServerName, ro.HTTP.StatusCode = res.StatusCode
InsecureSkipVerify: !tlsSettings.Secure, res.Write(conn)
return nil, nil, errors.New("node not available")
} }
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion, // Track this node as tried
MaxVersion: tlsSettings.Options.MaxVersion, triedNodes = append(triedNodes, node.Addr)
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN, ro.Host = node.Addr
ho.Log = ho.Log.WithFields(map[string]any{
"node": node.Name,
"dst": node.Addr,
}) })
cc = tls.Client(cc, cfg) ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
cc, err = dial(ctx, "tcp", node.Addr)
if err != nil {
// Mark node as failed for future selections
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
lastErr = err
continue
}
// Success - reset marker
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
})
cc = tls.Client(cc, cfg)
}
return node, cc, nil
} }
return
// All retries exhausted
ho.Log.Warnf("all nodes failed for host %s", host)
res.Write(conn)
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, errors.New("all nodes failed")
} }
func (h *Sniffer) serveH2(ctx context.Context, conn net.Conn, ho *HandleOptions) error { func (h *Sniffer) serveH2(ctx context.Context, conn net.Conn, ho *HandleOptions) error {
@@ -847,74 +881,105 @@ func (h *Sniffer) dialTLS(ctx context.Context, host string, ho *HandleOptions) (
return return
} }
if host != "" {
node = &chain.Node{
Addr: host,
}
}
ro := ho.RecorderObject ro := ho.RecorderObject
if ho.Hop != nil {
node = ho.Hop.Select(ctx, // Determine max retry attempts
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)), maxRetries := 1
hop.HostSelectOption(host), if nl, ok := ho.Hop.(hop.NodeList); ok {
hop.ProtocolSelectOption(sniffing.ProtoTLS), maxRetries = len(nl.Nodes())
)
} }
if node == nil { if maxRetries <= 0 {
err = errors.New("node not available") maxRetries = 1
return
} }
addr := node.Addr var triedNodes []string
if opts := node.Options(); opts != nil { var lastErr error
switch opts.Network {
case "unix": for attempt := 0; attempt < maxRetries; attempt++ {
ro.Network = opts.Network // Select a node, excluding previously tried nodes
default: selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":443" node = nil
if host != "" {
node = chain.NewNode("", host)
}
if ho.Hop != nil {
node = ho.Hop.Select(selectCtx,
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.HostSelectOption(host),
hop.ProtocolSelectOption(sniffing.ProtoTLS),
)
}
if node == nil {
if lastErr != nil {
ho.Log.Warnf("node for %s not found after retries", host)
return nil, nil, lastErr
}
ho.Log.Warnf("node for %s not found", host)
return nil, nil, errors.New("node not available")
}
// Track this node as tried
triedNodes = append(triedNodes, node.Addr)
addr := node.Addr
if opts := node.Options(); opts != nil {
switch opts.Network {
case "unix":
ro.Network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":443"
}
} }
} }
} ro.Host = addr
ro.Host = addr
ho.Log = ho.Log.WithFields(map[string]any{ ho.Log = ho.Log.WithFields(map[string]any{
"host": host, "host": host,
"node": node.Name, "node": node.Name,
"dst": fmt.Sprintf("%s/%s", addr, ro.Network), "dst": fmt.Sprintf("%s/%s", addr, ro.Network),
})
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
cc, err = dial(ctx, ro.Network, addr)
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
return
}
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
}) })
cc = tls.Client(cc, cfg) ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
cc, err = dial(ctx, ro.Network, addr)
if err != nil {
// Mark node as failed for future selections
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
lastErr = err
continue
}
// Success - reset marker
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
})
cc = tls.Client(cc, cfg)
}
return node, cc, nil
} }
return
// All retries exhausted
ho.Log.Warnf("all nodes failed for host %s", host)
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, errors.New("all nodes failed")
} }
func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error { func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error {
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/listener/
├── tcp/ # listener.go + metadata.go
├── udp/
├── tls/
├── ws/
├── quic/
├── redirect/ # tcp/ + udp/
├── tun/ # TUN device listener
├── tap/ # TAP device listener
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
## CONVENTIONS
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+29
View File
@@ -0,0 +1,29 @@
# GO-GOST REGISTRY KNOWLEDGE BASE
**Generated:** Wed Feb 04 2026
## OVERVIEW
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
Allows the configuration system to resolve string types (e.g., "socks5") to actual Go implementations.
## STRUCTURE
One file per component type, exporting a standard Registry interface.
```
go-gost/x/registry/
├── handler.go # RegisterHandler(name, newFunc)
├── listener.go # RegisterListener(name, newFunc)
├── dialer.go # RegisterDialer(name, newFunc)
└── ... # Same pattern for auth, bypass, admission
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Register a new component | `go-gost/x/registry/{type}.go` | Use `Register{Type}(name, creator)` |
| Component lookup | `go-gost/x/registry/{type}.go` | `Get{Type}(name)` returns the creator function |
| Default registrations | `go-gost/x/` (init functions) | Most components register themselves in their package `init()` |
## CONVENTIONS
- Thread-safe maps used for storage.
- Names are case-sensitive (usually lowercase).
- Components must be registered *before* the configuration parser runs (usually done via `import _ "..."` in `main.go`).
+20 -3
View File
@@ -2,11 +2,13 @@ package selector
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/metadata" "github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/core/selector" "github.com/go-gost/core/selector"
mdutil "github.com/go-gost/x/metadata/util"
) )
type failFilter[T any] struct { type failFilter[T any] struct {
@@ -24,6 +26,8 @@ func FailFilter[T any](maxFails int, timeout time.Duration) selector.Filter[T] {
} }
// Filter filters dead objects. // Filter filters dead objects.
// For single-node case, skip filtering to ensure availability (matches upstream).
// For multi-node case, filter out failed nodes to enable failover.
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T { func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
if len(vs) <= 1 { if len(vs) <= 1 {
return vs return vs
@@ -51,8 +55,21 @@ func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
if mi, _ := any(v).(selector.Markable); mi != nil { if mi, _ := any(v).(selector.Markable); mi != nil {
if marker := mi.Marker(); marker != nil { if marker := mi.Marker(); marker != nil {
if marker.Count() < int64(maxFails) || count := marker.Count()
time.Since(marker.Time()) >= failTimeout { timeSince := time.Since(marker.Time())
passed := count < int64(maxFails) || timeSince >= failTimeout
// Debug logging for failover analysis
nodeName := "unknown"
nodeAddr := "unknown"
if node, ok := any(v).(*chain.Node); ok {
nodeName = node.Name
nodeAddr = node.Addr
}
fmt.Printf("[FailFilter] node=%s addr=%s count=%d maxFails=%d timeSince=%v failTimeout=%v passed=%v\n",
nodeName, nodeAddr, count, maxFails, timeSince, failTimeout, passed)
if passed {
l = append(l, v) l = append(l, v)
} }
continue continue
+6
View File
@@ -2,11 +2,17 @@ package socket
import ( import (
"os" "os"
"sync"
"github.com/go-gost/x/config" "github.com/go-gost/x/config"
) )
// configMutex 保护配置文件的并发写入
var configMutex sync.Mutex
func saveConfig() { func saveConfig() {
configMutex.Lock()
defer configMutex.Unlock()
file := "gost.json" file := "gost.json"
+151 -71
View File
@@ -14,8 +14,8 @@ import (
"time" "time"
"github.com/go-gost/x/config" "github.com/go-gost/x/config"
"github.com/go-gost/x/service"
"github.com/go-gost/x/internal/util/crypto" "github.com/go-gost/x/internal/util/crypto"
"github.com/go-gost/x/service"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/host"
@@ -87,6 +87,9 @@ type TcpPingResponse struct {
type WebSocketReporter struct { type WebSocketReporter struct {
url string url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
conn *websocket.Conn conn *websocket.Conn
reconnectTime time.Duration reconnectTime time.Duration
pingInterval time.Duration pingInterval time.Duration
@@ -195,7 +198,25 @@ func (w *WebSocketReporter) connect() error {
w.connecting = false w.connecting = false
}() }()
u, err := url.Parse(w.url) // 重新读取 config.json 获取最新的协议配置
type LocalConfig struct {
Addr string `json:"addr"`
Secret string `json:"secret"`
Http int `json:"http"`
Tls int `json:"tls"`
Socks int `json:"socks"`
}
var cfg LocalConfig
if b, err := os.ReadFile("config.json"); err == nil {
json.Unmarshal(b, &cfg)
}
// 使用最新的配置重新构建 URL
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
u, err := url.Parse(currentURL)
if err != nil { if err != nil {
return fmt.Errorf("解析URL失败: %v", err) return fmt.Errorf("解析URL失败: %v", err)
} }
@@ -225,7 +246,7 @@ func (w *WebSocketReporter) connect() error {
return nil return nil
}) })
fmt.Printf("✅ WebSocket连接建立成功\n") fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
return nil return nil
} }
@@ -445,7 +466,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
} }
if cmdMsg.Type != "call" { if cmdMsg.Type != "call" {
w.routeCommand(cmdMsg) // TcpPing 诊断命令异步执行,避免阻塞其他命令
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
} }
} else { } else {
// 处理普通消息 // 处理普通消息
@@ -456,7 +483,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return return
} }
if cmdMsg.Type != "call" { if cmdMsg.Type != "call" {
w.routeCommand(cmdMsg) // TcpPing 诊断命令异步执行,避免阻塞其他命令
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
} }
} }
@@ -476,6 +509,7 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
fmt.Println("🔔 收到命令: ", string(jsonBytes)) fmt.Println("🔔 收到命令: ", string(jsonBytes))
var err error var err error
var response CommandResponse var response CommandResponse
var needSaveConfig bool // 标记是否需要保存配置(只有状态变更命令才需要)
// 传递 requestId // 传递 requestId
response.RequestId = cmd.RequestId response.RequestId = cmd.RequestId
@@ -485,65 +519,81 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
case "AddService": case "AddService":
err = w.handleAddService(cmd.Data) err = w.handleAddService(cmd.Data)
response.Type = "AddServiceResponse" response.Type = "AddServiceResponse"
needSaveConfig = true
case "UpdateService": case "UpdateService":
err = w.handleUpdateService(cmd.Data) err = w.handleUpdateService(cmd.Data)
response.Type = "UpdateServiceResponse" response.Type = "UpdateServiceResponse"
needSaveConfig = true
case "DeleteService": case "DeleteService":
err = w.handleDeleteService(cmd.Data) err = w.handleDeleteService(cmd.Data)
response.Type = "DeleteServiceResponse" response.Type = "DeleteServiceResponse"
needSaveConfig = true
case "PauseService": case "PauseService":
err = w.handlePauseService(cmd.Data) err = w.handlePauseService(cmd.Data)
response.Type = "PauseServiceResponse" response.Type = "PauseServiceResponse"
needSaveConfig = true
case "ResumeService": case "ResumeService":
err = w.handleResumeService(cmd.Data) err = w.handleResumeService(cmd.Data)
response.Type = "ResumeServiceResponse" response.Type = "ResumeServiceResponse"
needSaveConfig = true
// Chain 相关命令 // Chain 相关命令
case "AddChains": case "AddChains":
err = w.handleAddChain(cmd.Data) err = w.handleAddChain(cmd.Data)
response.Type = "AddChainsResponse" response.Type = "AddChainsResponse"
needSaveConfig = true
case "UpdateChains": case "UpdateChains":
err = w.handleUpdateChain(cmd.Data) err = w.handleUpdateChain(cmd.Data)
response.Type = "UpdateChainsResponse" response.Type = "UpdateChainsResponse"
needSaveConfig = true
case "DeleteChains": case "DeleteChains":
err = w.handleDeleteChain(cmd.Data) err = w.handleDeleteChain(cmd.Data)
response.Type = "DeleteChainsResponse" response.Type = "DeleteChainsResponse"
needSaveConfig = true
// Limiter 相关命令 // Limiter 相关命令
case "AddLimiters": case "AddLimiters":
err = w.handleAddLimiter(cmd.Data) err = w.handleAddLimiter(cmd.Data)
response.Type = "AddLimitersResponse" response.Type = "AddLimitersResponse"
needSaveConfig = true
case "UpdateLimiters": case "UpdateLimiters":
err = w.handleUpdateLimiter(cmd.Data) err = w.handleUpdateLimiter(cmd.Data)
response.Type = "UpdateLimitersResponse" response.Type = "UpdateLimitersResponse"
needSaveConfig = true
case "DeleteLimiters": case "DeleteLimiters":
err = w.handleDeleteLimiter(cmd.Data) err = w.handleDeleteLimiter(cmd.Data)
response.Type = "DeleteLimitersResponse" response.Type = "DeleteLimitersResponse"
needSaveConfig = true
// TCP Ping 诊断命令 // TCP Ping 诊断命令(只读,不需要保存配置)
case "TcpPing": case "TcpPing":
var tcpPingResult TcpPingResponse var tcpPingResult TcpPingResponse
tcpPingResult, err = w.handleTcpPing(cmd.Data) tcpPingResult, err = w.handleTcpPing(cmd.Data)
response.Type = "TcpPingResponse" response.Type = "TcpPingResponse"
response.Data = tcpPingResult response.Data = tcpPingResult
// needSaveConfig = false (默认值)
// Protocol blocking switches // Protocol blocking switches
case "SetProtocol": case "SetProtocol":
err = w.handleSetProtocol(cmd.Data) err = w.handleSetProtocol(cmd.Data)
response.Type = "SetProtocolResponse" response.Type = "SetProtocolResponse"
needSaveConfig = true
default: default:
err = fmt.Errorf("未知命令类型: %s", cmd.Type) err = fmt.Errorf("未知命令类型: %s", cmd.Type)
response.Type = "UnknownCommandResponse" response.Type = "UnknownCommandResponse"
} }
// 只有状态变更命令才保存配置
if needSaveConfig {
saveConfig()
}
// 发送响应 // 发送响应
if err != nil { if err != nil {
saveConfig()
response.Success = false response.Success = false
response.Message = err.Error() response.Message = err.Error()
} else { } else {
saveConfig()
response.Success = true response.Success = true
response.Message = "OK" response.Message = "OK"
} }
@@ -775,81 +825,81 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
// handleSetProtocol 处理设置屏蔽协议的命令 // handleSetProtocol 处理设置屏蔽协议的命令
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error { func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
jsonData, err := json.Marshal(data) jsonData, err := json.Marshal(data)
if err != nil { if err != nil {
return fmt.Errorf("序列化协议设置失败: %v", err) return fmt.Errorf("序列化协议设置失败: %v", err)
} }
// 支持 {"http":0/1, "tls":0/1, "socks":0/1} // 支持 {"http":0/1, "tls":0/1, "socks":0/1}
var req struct { var req struct {
HTTP *int `json:"http"` HTTP *int `json:"http"`
TLS *int `json:"tls"` TLS *int `json:"tls"`
SOCKS *int `json:"socks"` SOCKS *int `json:"socks"`
} }
if err := json.Unmarshal(jsonData, &req); err != nil { if err := json.Unmarshal(jsonData, &req); err != nil {
return fmt.Errorf("解析协议设置失败: %v", err) return fmt.Errorf("解析协议设置失败: %v", err)
} }
// 读取当前值作为默认 // 读取当前值作为默认
httpVal, tlsVal, socksVal := 0, 0, 0 httpVal, tlsVal, socksVal := 0, 0, 0
if req.HTTP != nil { if req.HTTP != nil {
if *req.HTTP != 0 && *req.HTTP != 1 { if *req.HTTP != 0 && *req.HTTP != 1 {
return fmt.Errorf("http 取值必须为0或1") return fmt.Errorf("http 取值必须为0或1")
} }
httpVal = *req.HTTP httpVal = *req.HTTP
} }
if req.TLS != nil { if req.TLS != nil {
if *req.TLS != 0 && *req.TLS != 1 { if *req.TLS != 0 && *req.TLS != 1 {
return fmt.Errorf("tls 取值必须为0或1") return fmt.Errorf("tls 取值必须为0或1")
} }
tlsVal = *req.TLS tlsVal = *req.TLS
} }
if req.SOCKS != nil { if req.SOCKS != nil {
if *req.SOCKS != 0 && *req.SOCKS != 1 { if *req.SOCKS != 0 && *req.SOCKS != 1 {
return fmt.Errorf("socks 取值必须为0或1") return fmt.Errorf("socks 取值必须为0或1")
} }
socksVal = *req.SOCKS socksVal = *req.SOCKS
} }
// 设置至 service,全量传递(未提供的值沿用0) // 设置至 service,全量传递(未提供的值沿用0)
service.SetProtocolBlock(httpVal, tlsVal, socksVal) service.SetProtocolBlock(httpVal, tlsVal, socksVal)
// 同步写入本地 config.json // 同步写入本地 config.json
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil { if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
return fmt.Errorf("写入config.json失败: %v", err) return fmt.Errorf("写入config.json失败: %v", err)
} }
return nil return nil
} }
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json // updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error { func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
path := "config.json" path := "config.json"
// 读取现有配置 // 读取现有配置
type LocalConfig struct { type LocalConfig struct {
Addr string `json:"addr"` Addr string `json:"addr"`
Secret string `json:"secret"` Secret string `json:"secret"`
Http int `json:"http"` Http int `json:"http"`
Tls int `json:"tls"` Tls int `json:"tls"`
Socks int `json:"socks"` Socks int `json:"socks"`
} }
var cfg LocalConfig var cfg LocalConfig
if b, err := os.ReadFile(path); err == nil { if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(b, &cfg) _ = json.Unmarshal(b, &cfg)
} }
cfg.Http = httpVal cfg.Http = httpVal
cfg.Tls = tlsVal cfg.Tls = tlsVal
cfg.Socks = socksVal cfg.Socks = socksVal
// 写回 // 写回
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, 0644) return os.WriteFile(path, data, 0644)
} }
// handleCall 处理服务端的call回调消息 // handleCall 处理服务端的call回调消息
@@ -1021,12 +1071,16 @@ func getMemoryInfo() MemoryInfo {
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器 // StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter { func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
// 构建包含本机IP的WebSocket URL // 构建初始 WebSocket URL
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks) fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL) fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
reporter := NewWebSocketReporter(fullURL, secret) reporter := NewWebSocketReporter(fullURL, secret)
// 保存 addr, secret, version 供重连时使用
reporter.addr = addr
reporter.secret = secret
reporter.version = version
reporter.Start() reporter.Start()
return reporter return reporter
} }
@@ -1107,6 +1161,32 @@ func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float6
fmt.Printf("🔍 开始TCP ping测试: %s,次数: %d,超时: %dms\n", target, count, timeoutMs) fmt.Printf("🔍 开始TCP ping测试: %s,次数: %d,超时: %dms\n", target, count, timeoutMs)
// 如果是域名,先解析一次DNS,避免每次连接都重新解析导致延迟累加
if net.ParseIP(ip) == nil {
// 是域名,需要解析
fmt.Printf("🔍 检测到域名,正在解析DNS...\n")
dnsStart := time.Now()
addrs, err := net.LookupHost(ip)
dnsDuration := time.Since(dnsStart)
if err != nil {
return 0, 100.0, fmt.Errorf("DNS解析失败: %v", err)
}
if len(addrs) == 0 {
return 0, 100.0, fmt.Errorf("DNS解析未返回任何IP地址")
}
fmt.Printf("✅ DNS解析完成 (%.2fms),解析到 %d 个IP: %v\n",
dnsDuration.Seconds()*1000, len(addrs), addrs)
// 使用第一个解析到的IP进行测试
target = net.JoinHostPort(addrs[0], fmt.Sprintf("%d", port))
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
} else {
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
}
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
start := time.Now() start := time.Now()
+83 -10
View File
@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
# GitHub repo used for release downloads
REPO="Sagit-chu/flux-panel"
# 获取系统架构 # 获取系统架构
get_architecture() { get_architecture() {
ARCH=$(uname -m) ARCH=$(uname -m)
@@ -16,20 +19,90 @@ get_architecture() {
esac esac
} }
# 安装目录
INSTALL_DIR="/etc/flux_agent"
# 识别国家(用于镜像加速)
COUNTRY=$(curl -s https://ipinfo.io/country)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://ghfast.top/${url}"
else
echo "$url"
fi
}
resolve_latest_release_tag() {
local effective_url tag api_tag latest_url api_url
latest_url="https://github.com/${REPO}/releases/latest"
api_url="https://api.github.com/repos/${REPO}/releases/latest"
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
# CN 环境下可尝试通过镜像访问(不影响非 CN)
if [ "$COUNTRY" = "CN" ]; then
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
fi
# 方式2:GitHub API(无需 jq)
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
fi
return 1
}
resolve_version() {
if [[ -n "${VERSION:-}" ]]; then
echo "$VERSION"
return 0
fi
if [[ -n "${FLUX_VERSION:-}" ]]; then
echo "$FLUX_VERSION"
return 0
fi
if resolve_latest_release_tag; then
return 0
fi
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
return 1
}
# 构建下载地址 # 构建下载地址
build_download_url() { build_download_url() {
local ARCH=$(get_architecture) local ARCH=$(get_architecture)
echo "https://github.com/bqlpfy/flux-panel/releases/download/2.0.4-beta/gost-${ARCH}" echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
} }
# 下载地址 # 解析版本并构建下载地址
DOWNLOAD_URL=$(build_download_url) RESOLVED_VERSION=$(resolve_version) || exit 1
INSTALL_DIR="/etc/flux_agent" DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
COUNTRY=$(curl -s https://ipinfo.io/country)
if [ "$COUNTRY" = "CN" ]; then
# 拼接 URL
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
fi
@@ -365,4 +438,4 @@ main() {
} }
# 执行主函数 # 执行主函数
main main
+74 -8
View File
@@ -7,16 +7,82 @@ export LC_ALL=C
# 全局下载地址配置 # GitHub repo used for release downloads
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.4-beta/docker-compose-v4.yml" REPO="Sagit-chu/flux-panel"
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.4-beta/docker-compose-v6.yml"
COUNTRY=$(curl -s https://ipinfo.io/country) COUNTRY=$(curl -s https://ipinfo.io/country)
if [ "$COUNTRY" = "CN" ]; then
# 拼接 URL maybe_proxy_url() {
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}" local url="$1"
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}" if [ "$COUNTRY" = "CN" ]; then
fi echo "https://ghfast.top/${url}"
else
echo "$url"
fi
}
resolve_latest_release_tag() {
local effective_url tag api_tag latest_url api_url
latest_url="https://github.com/${REPO}/releases/latest"
api_url="https://api.github.com/repos/${REPO}/releases/latest"
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
fi
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
fi
return 1
}
resolve_version() {
if [[ -n "${VERSION:-}" ]]; then
echo "$VERSION"
return 0
fi
if [[ -n "${FLUX_VERSION:-}" ]]; then
echo "$FLUX_VERSION"
return 0
fi
if resolve_latest_release_tag; then
return 0
fi
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./panel_install.sh" >&2
return 1
}
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
RESOLVED_VERSION=$(resolve_version) || exit 1
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
+3
View File
@@ -0,0 +1,3 @@
<factorypath>
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
</factorypath>
+39
View File
@@ -0,0 +1,39 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # /api/v1/* endpoints
│ ├── entity/ # DB models
│ ├── mapper/ # MyBatis Plus mappers
│ ├── service/ # Business logic
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
│ └── common/ # DTOs, auth, exception handling, utilities
└── src/main/resources/
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
## COMMANDS
```bash
cd springboot-backend
mvn clean package
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
@@ -0,0 +1,32 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -20,6 +20,8 @@ public class ForwardUpdateDto {
@NotBlank(message = "转发名称不能为空") @NotBlank(message = "转发名称不能为空")
private String name; private String name;
private Integer tunnelId;
@NotBlank(message = "远程地址不能为空") @NotBlank(message = "远程地址不能为空")
private String remoteAddr; private String remoteAddr;
@@ -16,6 +16,10 @@ public class NodeDto {
@NotBlank(message = "服务器ip不能为空") @NotBlank(message = "服务器ip不能为空")
private String serverIp; private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用端口不能为空") @NotBlank(message = "可用端口不能为空")
private String port; private String port;
@@ -25,4 +29,4 @@ public class NodeDto {
private String udpListenAddr = "0.0.0.0"; private String udpListenAddr = "0.0.0.0";
} }
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
@NotBlank(message = "服务器ip不能为空") @NotBlank(message = "服务器ip不能为空")
private String serverIp; private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用port不能为空") @NotBlank(message = "可用port不能为空")
private String port; private String port;
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
private String tcpListenAddr = "0.0.0.0"; private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0"; private String udpListenAddr = "0.0.0.0";
} }
@@ -14,6 +14,12 @@ import java.util.List;
public class TunnelDetailDto { public class TunnelDetailDto {
private Long id; private Long id;
/**
* Display/order index for drag-sorting in admin UI.
* Lower value appears first.
*/
private Integer inx;
private String name; private String name;
@@ -40,4 +46,3 @@ public class TunnelDetailDto {
// 出口节点列表 // 出口节点列表
private List<ChainTunnel> outNodeId = new ArrayList<>(); private List<ChainTunnel> outNodeId = new ArrayList<>();
} }
@@ -30,4 +30,13 @@ public class TunnelUpdateDto {
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0") @DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0") @DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio; private BigDecimal trafficRatio;
// 入口节点配置(可选,为空时不更新节点配置)
private List<ChainTunnel> inNodeId;
// 转发链节点配置(二维数组,每一跳可有多个节点)
private List<List<ChainTunnel>> chainNodes;
// 出口节点配置
private List<ChainTunnel> outNodeId;
} }
@@ -0,0 +1,26 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class UserTunnelBatchAssignDto {
@NotNull(message = "用户ID不能为空")
private Integer userId;
@Valid
@NotEmpty(message = "隧道列表不能为空")
private List<TunnelAssignItem> tunnels;
@Data
public static class TunnelAssignItem {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
private Integer speedId;
}
}
@@ -13,28 +13,15 @@ public class UserTunnelDto {
@NotNull(message = "隧道ID不能为空") @NotNull(message = "隧道ID不能为空")
private Integer tunnelId; private Integer tunnelId;
@NotNull(message = "流量限制不能为空")
@Min(value = 0, message = "流量限制不能小于0") @Min(value = 0, message = "流量限制不能小于0")
private Long flow; private Long flow;
@NotNull(message = "转发数量不能为空")
@Min(value = 0, message = "转发数量不能小于0") @Min(value = 0, message = "转发数量不能小于0")
private Integer num; private Integer num;
/**
* 流量重置时间(时间戳)
*/
@NotNull(message = "流量重置时间不能为空")
private Long flowResetTime; private Long flowResetTime;
/**
* 到期时间(时间戳)
*/
@NotNull(message = "到期时间不能为空")
private Long expTime; private Long expTime;
/**
* 限速规则ID(可选,null表示不限速)
*/
private Integer speedId; private Integer speedId;
} }
@@ -0,0 +1,54 @@
package com.admin.common.migration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
/**
* Lightweight SQLite schema migration.
*
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
* won't automatically receive new columns. This runner adds missing columns in-place.
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SqliteSchemaMigration implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(ApplicationArguments args) {
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
}
private void ensureColumn(String table, String column, String columnDefinition) {
Set<String> columns = new HashSet<>(
jdbcTemplate.query(
"PRAGMA table_info(" + table + ")",
(rs, rowNum) -> rs.getString("name")
)
);
if (columns.contains(column)) {
return;
}
log.info("Adding missing column {}.{}", table, column);
jdbcTemplate.execute(
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
);
}
}
@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import java.util.regex.Pattern;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -43,6 +44,7 @@ public class GostUtil {
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) { public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
JSONArray nodes = new JSONArray(); JSONArray nodes = new JSONArray();
Node fromNode = node_s.get(node_id);
for (ChainTunnel chainTunnel : chainTunnels) { for (ChainTunnel chainTunnel : chainTunnels) {
JSONObject dialer = new JSONObject(); JSONObject dialer = new JSONObject();
dialer.put("type", chainTunnel.getProtocol()); dialer.put("type", chainTunnel.getProtocol());
@@ -53,7 +55,11 @@ public class GostUtil {
Node node_info = node_s.get(chainTunnel.getNodeId()); Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject(); JSONObject node = new JSONObject();
node.put("name", "node_" + chainTunnel.getInx()); node.put("name", "node_" + chainTunnel.getInx());
node.put("addr", processServerAddress(node_info.getServerIp()) + ":" + chainTunnel.getPort());
String dialHost = (fromNode != null && node_info != null)
? selectDialHost(fromNode, node_info)
: (node_info != null ? node_info.getServerIp() : null);
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
node.put("connector", connector); node.put("connector", connector);
node.put("dialer", dialer); node.put("dialer", dialer);
@@ -291,4 +297,115 @@ public class GostUtil {
long colonCount = address.chars().filter(ch -> ch == ':').count(); long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2; return colonCount >= 2;
} }
/**
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
*/
public static String selectDialHost(Node fromNode, Node toNode) {
if (fromNode == null || toNode == null) {
throw new IllegalArgumentException("node is null");
}
boolean fromV4 = supportsV4(fromNode);
boolean fromV6 = supportsV6(fromNode);
boolean toV4 = supportsV4(toNode);
boolean toV6 = supportsV6(toNode);
if (fromV4 && toV4) {
return pickToAddressV4(toNode);
}
if (fromV6 && toV6) {
return pickToAddressV6(toNode);
}
throw new RuntimeException(
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
);
}
private static String safeName(Node node) {
if (node.getName() == null || node.getName().isBlank()) {
return "node_" + node.getId();
}
return node.getName();
}
private static boolean supportsV4(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v6, treat as v6-only.
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (looksLikeIpv4(legacy)) {
return true;
}
if (isIPv6Address(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static boolean supportsV6(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v4, treat as v4-only.
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (isIPv6Address(legacy)) {
return true;
}
if (looksLikeIpv4(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static String pickToAddressV4(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
return toNode.getServerIpV4().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static String pickToAddressV6(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
return toNode.getServerIpV6().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static boolean looksLikeIpv4(String value) {
Pattern ipv4 = Pattern.compile("^(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]?)$");
return ipv4.matcher(value).matches();
}
} }
@@ -10,7 +10,12 @@ import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy; import javax.annotation.PreDestroy;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement; import java.sql.Statement;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/** /**
* SQLite 数据库配置 * SQLite 数据库配置
@@ -39,13 +44,98 @@ public class SQLiteConfig implements ApplicationRunner {
statement.execute("PRAGMA temp_store=MEMORY;"); statement.execute("PRAGMA temp_store=MEMORY;");
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时 statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
ensureNodeDualStackColumns(connection);
log.info("SQLite WAL mode configured successfully"); log.info("SQLite WAL mode configured successfully");
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to configure SQLite database", e); log.error("Failed to configure SQLite database", e);
throw e; throw e;
} }
} }
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
Set<String> cols = getTableColumns(connection, "node");
if (cols.isEmpty()) {
return;
}
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
backfillNodeDualStackColumns(connection);
}
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
Pattern ipv4 = Pattern.compile("^(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]?)$");
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
) {
while (rs.next()) {
long id = rs.getLong("id");
String serverIp = rs.getString("server_ip");
String v4 = rs.getString("server_ip_v4");
String v6 = rs.getString("server_ip_v6");
if (serverIp == null || serverIp.isBlank()) {
continue;
}
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
continue;
}
String trimmed = serverIp.trim();
if (ipv4.matcher(trimmed).matches()) {
updV4.setString(1, trimmed);
updV4.setLong(2, id);
updV4.executeUpdate();
} else {
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
if (colonCount >= 2) {
updV6.setString(1, trimmed);
updV6.setLong(2, id);
updV6.executeUpdate();
}
}
}
}
}
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
Set<String> cols = new HashSet<>();
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
while (rs.next()) {
String name = rs.getString("name");
if (name != null && !name.isBlank()) {
cols.add(name);
}
}
}
return cols;
}
private void ensureColumnIfMissing(
Connection connection,
Set<String> existingColumns,
String table,
String column,
String type
) throws Exception {
if (existingColumns.contains(column)) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
}
log.info("SQLite schema updated: added {}.{}", table, column);
}
/** /**
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库 * 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
@@ -80,4 +170,3 @@ public class SQLiteConfig implements ApplicationRunner {
} }
} }
} }
@@ -7,11 +7,13 @@ import com.admin.common.task.CheckGostConfigAsync;
import com.admin.common.utils.AESCrypto; import com.admin.common.utils.AESCrypto;
import com.admin.common.utils.GostUtil; import com.admin.common.utils.GostUtil;
import com.admin.entity.*; import com.admin.entity.*;
import com.admin.service.ChainTunnelService;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -64,6 +66,10 @@ public class FlowController extends BaseController {
@Resource @Resource
CheckGostConfigAsync checkGostConfigAsync; CheckGostConfigAsync checkGostConfigAsync;
@Resource
@Lazy
ChainTunnelService chainTunnelService;
/** /**
* 加密消息包装器 * 加密消息包装器
*/ */
@@ -306,17 +312,14 @@ public class FlowController extends BaseController {
} }
public void pauseService(List<Forward> forwardList, String name) { public void pauseService(List<Forward> forwardList, String name) {
// for (Forward forward : forwardList) { for (Forward forward : forwardList) {
// Tunnel tunnel = tunnelService.getById(forward.getTunnelId()); List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
// if (tunnel != null) { for (ChainTunnel chainTunnel : chainTunnels) {
// GostUtil.PauseService(tunnel.getInNodeId(), name); GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
// if (tunnel.getType() == 2) { }
// GostUtil.PauseRemoteService(tunnel.getOutNodeId(), name); forward.setStatus(0);
// } forwardService.updateById(forward);
// } }
// forward.setStatus(0);
// forwardService.updateById(forward);
// }
} }
private void updateForwardFlow(String forwardId, FlowDto flowStats) { private void updateForwardFlow(String forwardId, FlowDto flowStats) {
@@ -54,12 +54,24 @@ public class NodeController extends BaseController {
return nodeService.deleteNode(id); return nodeService.deleteNode(id);
} }
@LogAnnotation @LogAnnotation
@RequireRole @RequireRole
@PostMapping("/install") @PostMapping("/install")
public R getInstallCommand(@RequestBody Map<String, Object> params) { public R getInstallCommand(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString()); Long id = Long.valueOf(params.get("id").toString());
return nodeService.getInstallCommand(id); return nodeService.getInstallCommand(id);
} }
} /**
* 更新节点排序
* @param params 包含nodes数组的参数,每个元素包含id和inx
* @return 更新结果
*/
@LogAnnotation
@RequireRole
@PostMapping("/update-order")
public R updateNodeOrder(@RequestBody Map<String, Object> params) {
return nodeService.updateNodeOrder(params);
}
}
@@ -5,6 +5,7 @@ import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.TunnelDto; import com.admin.common.dto.TunnelDto;
import com.admin.common.dto.TunnelUpdateDto; import com.admin.common.dto.TunnelUpdateDto;
import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelDto; import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto; import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto; import com.admin.common.dto.UserTunnelUpdateDto;
@@ -78,6 +79,13 @@ public class TunnelController extends BaseController {
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) { public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) {
return userTunnelService.assignUserTunnel(userTunnelDto); return userTunnelService.assignUserTunnel(userTunnelDto);
} }
@LogAnnotation
@RequireRole
@PostMapping("/user/batch-assign")
public R batchAssignUserTunnel(@Validated @RequestBody UserTunnelBatchAssignDto batchAssignDto) {
return userTunnelService.batchAssignUserTunnel(batchAssignDto);
}
/** /**
* 查询用户隧道权限列表 * 查询用户隧道权限列表
@@ -137,4 +145,16 @@ public class TunnelController extends BaseController {
return tunnelService.diagnoseTunnel(tunnelId); return tunnelService.diagnoseTunnel(tunnelId);
} }
/**
* 更新隧道排序
* @param params 包含tunnels数组的参数,每个元素包含id和inx
* @return 更新结果
*/
@LogAnnotation
@RequireRole
@PostMapping("/update-order")
public R updateTunnelOrder(@RequestBody Map<String, Object> params) {
return tunnelService.updateTunnelOrder(params);
}
} }
@@ -14,15 +14,19 @@ import lombok.EqualsAndHashCode;
*/ */
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Node extends BaseEntity { public class Node extends BaseEntity {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private String name; private String name;
private String secret; private String secret;
private String serverIp; private String serverIp;
private String serverIpV4;
private String serverIpV6;
private String version; private String version;
@@ -38,6 +42,11 @@ public class Node extends BaseEntity {
private String tcpListenAddr; private String tcpListenAddr;
private String udpListenAddr; private String udpListenAddr;
} /**
* Display/order index for drag-sorting in admin UI.
* Lower value appears first.
*/
private Integer inx;
}
@@ -22,7 +22,7 @@ import lombok.EqualsAndHashCode;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@TableName(autoResultMap = true) @TableName(autoResultMap = true)
public class Tunnel extends BaseEntity { public class Tunnel extends BaseEntity {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@@ -32,7 +32,13 @@ public class Tunnel extends BaseEntity {
private int flow; private int flow;
private BigDecimal trafficRatio; private BigDecimal trafficRatio;
private String inIp; private String inIp;
}
/**
* Display/order index for drag-sorting in admin UI.
* Lower value appears first.
*/
private Integer inx;
}
@@ -3,8 +3,10 @@ package com.admin.service;
import com.admin.common.dto.NodeDto; import com.admin.common.dto.NodeDto;
import com.admin.common.dto.NodeUpdateDto; import com.admin.common.dto.NodeUpdateDto;
import com.admin.common.lang.R; import com.admin.common.lang.R;
import com.admin.entity.Node; import com.admin.entity.Node;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
/** /**
* <p> * <p>
@@ -14,7 +16,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
* @author QAQ * @author QAQ
* @since 2025-06-03 * @since 2025-06-03
*/ */
public interface NodeService extends IService<Node> { public interface NodeService extends IService<Node> {
R createNode(NodeDto nodeDto); R createNode(NodeDto nodeDto);
@@ -24,6 +26,12 @@ public interface NodeService extends IService<Node> {
R deleteNode(Long id); R deleteNode(Long id);
R getInstallCommand(Long id); R getInstallCommand(Long id);
} /**
* 更新节点排序(管理员)
* @param params 包含nodes数组的参数,每个元素包含id和inx
*/
R updateNodeOrder(Map<String, Object> params);
}
@@ -4,8 +4,10 @@ import com.admin.common.dto.TunnelDto;
import com.admin.common.dto.TunnelUpdateDto; import com.admin.common.dto.TunnelUpdateDto;
import com.admin.common.lang.R; import com.admin.common.lang.R;
import com.admin.entity.Tunnel; import com.admin.entity.Tunnel;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
/** /**
* <p> * <p>
@@ -15,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
* @author QAQ * @author QAQ
* @since 2025-06-03 * @since 2025-06-03
*/ */
public interface TunnelService extends IService<Tunnel> { public interface TunnelService extends IService<Tunnel> {
/** /**
* 创建隧道 * 创建隧道
@@ -55,5 +57,11 @@ public interface TunnelService extends IService<Tunnel> {
* @param tunnelId 隧道ID * @param tunnelId 隧道ID
* @return 诊断结果 * @return 诊断结果
*/ */
R diagnoseTunnel(Long tunnelId); R diagnoseTunnel(Long tunnelId);
}
/**
* 更新隧道排序(管理员)
* @param params 包含tunnels数组的参数,每个元素包含id和inx
*/
R updateTunnelOrder(Map<String, Object> params);
}
@@ -1,49 +1,23 @@
package com.admin.service; package com.admin.service;
import com.admin.common.dto.UserTunnelDto; import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelQueryDto; import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelUpdateDto; import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.lang.R; import com.admin.common.dto.UserTunnelUpdateDto;
import com.admin.entity.UserTunnel; import com.admin.common.lang.R;
import com.baomidou.mybatisplus.extension.service.IService; import com.admin.entity.UserTunnel;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p> public interface UserTunnelService extends IService<UserTunnel> {
* 用户隧道权限服务类
* </p> R assignUserTunnel(UserTunnelDto userTunnelDto);
*
* @author QAQ R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto);
* @since 2025-06-03
*/ R getUserTunnelList(UserTunnelQueryDto queryDto);
public interface UserTunnelService extends IService<UserTunnel> {
R removeUserTunnel(Integer id);
/**
* 分配用户隧道权限 R updateUserTunnel(UserTunnelUpdateDto updateDto);
* @param userTunnelDto 用户隧道权限数据
* @return 结果 }
*/
R assignUserTunnel(UserTunnelDto userTunnelDto);
/**
* 查询用户隧道权限列表
* @param queryDto 查询条件
* @return 结果
*/
R getUserTunnelList(UserTunnelQueryDto queryDto);
/**
* 删除用户隧道权限
* @param id ID
* @return 结果
*/
R removeUserTunnel(Integer id);
/**
* 更新用户隧道权限(包含流量、流量重置时间、到期时间)
* @param updateDto 更新数据
* @return 结果
*/
R updateUserTunnel(UserTunnelUpdateDto updateDto);
}
@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -169,7 +170,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
forward.setUpdatedTime(System.currentTimeMillis()); forward.setUpdatedTime(System.currentTimeMillis());
List<JSONObject> success = new ArrayList<>(); List<JSONObject> success = new ArrayList<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)); List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
chainTunnels = get_port(chainTunnels, forwardDto.getInPort()); chainTunnels = get_port(chainTunnels, forwardDto.getInPort(), 0L);
this.save(forward); this.save(forward);
for (ChainTunnel chainTunnel : chainTunnels) { for (ChainTunnel chainTunnel : chainTunnels) {
@@ -209,51 +210,93 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
} }
@Override @Override
@Transactional
public R updateForward(ForwardUpdateDto forwardUpdateDto) { public R updateForward(ForwardUpdateDto forwardUpdateDto) {
// 1. 获取当前用户信息
UserInfo currentUser = getCurrentUserInfo(); UserInfo currentUser = getCurrentUserInfo();
// 2. 检查转发是否存在
Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser); Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser);
if (existForward == null) { if (existForward == null) {
return R.err("转发不存在"); return R.err("转发不存在");
} }
Integer oldTunnelId = existForward.getTunnelId();
Integer newTunnelId = forwardUpdateDto.getTunnelId() != null ? forwardUpdateDto.getTunnelId() : oldTunnelId;
boolean tunnelChanged = !Objects.equals(oldTunnelId, newTunnelId);
Tunnel tunnel = validateTunnel(existForward.getTunnelId()); Tunnel oldTunnel = validateTunnel(oldTunnelId);
if (tunnel == null) { if (oldTunnel == null) {
return R.err("隧道不存在"); return R.err("原隧道不存在");
} }
UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, null); UserTunnel oldUserTunnel;
if (permissionResult.isHasError()) {
return R.err(permissionResult.getErrorMessage());
}
UserTunnel userTunnel;
if (currentUser.getRoleId() != 0) { if (currentUser.getRoleId() != 0) {
userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue()); oldUserTunnel = getUserTunnel(currentUser.getUserId(), oldTunnelId);
if (userTunnel == null) { if (oldUserTunnel == null) {
return R.err("你没有该隧道权限"); return R.err("你没有原隧道权限");
} }
} else { } else {
// 管理员用户也需要获取UserTunnel(如果存在的话),用于构建正确的服务名称 oldUserTunnel = getUserTunnel(existForward.getUserId(), oldTunnelId);
// 通过forward记录获取原始的用户ID }
userTunnel = getUserTunnel(existForward.getUserId(), tunnel.getId().intValue());
if (tunnelChanged) {
Tunnel newTunnel = validateTunnel(newTunnelId);
if (newTunnel == null) {
return R.err("新隧道不存在");
}
if (newTunnel.getStatus() != 1) {
return R.err("新隧道已禁用,无法切换");
}
UserPermissionResult newPermResult = checkUserPermissions(currentUser, newTunnel, null);
if (newPermResult.isHasError()) {
return R.err(newPermResult.getErrorMessage());
}
UserTunnel newUserTunnel;
if (currentUser.getRoleId() != 0) {
newUserTunnel = getUserTunnel(currentUser.getUserId(), newTunnelId);
if (newUserTunnel == null) {
return R.err("你没有新隧道权限");
}
} else {
newUserTunnel = getUserTunnel(existForward.getUserId(), newTunnelId);
}
releaseOldTunnelResources(existForward, oldTunnel, oldUserTunnel);
existForward.setTunnelId(newTunnelId);
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
existForward.setName(forwardUpdateDto.getName());
existForward.setStrategy(forwardUpdateDto.getStrategy());
existForward.setStatus(1);
existForward.setUpdatedTime(System.currentTimeMillis());
this.updateById(existForward);
R allocResult = allocateNewTunnelResources(existForward, newTunnel, forwardUpdateDto.getInPort(), newPermResult, newUserTunnel);
if (allocResult.getCode() != 0) {
throw new RuntimeException(allocResult.getMsg());
}
return R.ok();
}
UserPermissionResult permissionResult = checkUserPermissions(currentUser, oldTunnel, null);
if (permissionResult.isHasError()) {
return R.err(permissionResult.getErrorMessage());
} }
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr()); existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
existForward.setName(forwardUpdateDto.getName()); existForward.setName(forwardUpdateDto.getName());
existForward.setStrategy(forwardUpdateDto.getStrategy()); existForward.setStrategy(forwardUpdateDto.getStrategy());
existForward.setStatus(1); existForward.setStatus(1);
existForward.setUpdatedTime(System.currentTimeMillis());
this.updateById(existForward); this.updateById(existForward);
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", oldTunnel.getId()).eq("chain_type", 1));
chainTunnels = get_port(chainTunnels, forwardUpdateDto.getInPort(), existForward.getId());
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
chainTunnels = get_port(chainTunnels, forwardUpdateDto.getInPort());
for (ChainTunnel chainTunnel : chainTunnels) { for (ChainTunnel chainTunnel : chainTunnels) {
String serviceName = buildServiceName(existForward.getId(), existForward.getUserId(), userTunnel); String serviceName = buildServiceName(existForward.getId(), existForward.getUserId(), oldUserTunnel);
Integer limiter = permissionResult.getLimiter(); Integer limiter = permissionResult.getLimiter();
Node node = nodeService.getById(chainTunnel.getNodeId()); Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null) { if (node == null) {
@@ -265,13 +308,89 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
} }
forwardPort.setPort(chainTunnel.getPort()); forwardPort.setPort(chainTunnel.getPort());
forwardPortService.updateById(forwardPort); forwardPortService.updateById(forwardPort);
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, tunnel, "UpdateService"); GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, oldTunnel, "UpdateService");
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg()); if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
} }
return R.ok(); return R.ok();
} }
private void releaseOldTunnelResources(Forward forward, Tunnel oldTunnel, UserTunnel oldUserTunnel) {
List<ChainTunnel> oldInNodes = chainTunnelService.list(
new QueryWrapper<ChainTunnel>()
.eq("tunnel_id", oldTunnel.getId())
.eq("chain_type", 1)
);
for (ChainTunnel chainTunnel : oldInNodes) {
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), oldUserTunnel);
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node != null) {
JSONArray services = new JSONArray();
services.add(serviceName + "_tcp");
services.add(serviceName + "_udp");
GostUtil.DeleteService(node.getId(), services);
}
}
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
}
private R allocateNewTunnelResources(Forward forward, Tunnel newTunnel, Integer requestedPort,
UserPermissionResult permResult, UserTunnel newUserTunnel) {
List<ChainTunnel> newInNodes = chainTunnelService.list(
new QueryWrapper<ChainTunnel>()
.eq("tunnel_id", newTunnel.getId())
.eq("chain_type", 1)
);
newInNodes = get_port(newInNodes, requestedPort, forward.getId());
List<JSONObject> successServices = new ArrayList<>();
for (ChainTunnel chainTunnel : newInNodes) {
ForwardPort forwardPort = new ForwardPort();
forwardPort.setForwardId(forward.getId());
forwardPort.setNodeId(chainTunnel.getNodeId());
forwardPort.setPort(chainTunnel.getPort());
forwardPortService.save(forwardPort);
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), newUserTunnel);
Integer limiter = permResult.getLimiter();
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null) {
rollbackCreatedServices(successServices);
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
return R.err("新隧道部分节点不存在");
}
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, forwardPort, newTunnel, "AddService");
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackCreatedServices(successServices);
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
return R.err("在新隧道创建服务失败: " + gostDto.getMsg());
}
JSONObject data = new JSONObject();
data.put("node_id", node.getId());
data.put("name", serviceName);
successServices.add(data);
}
return R.ok();
}
private void rollbackCreatedServices(List<JSONObject> created) {
for (JSONObject jsonObject : created) {
JSONArray se = new JSONArray();
se.add(jsonObject.getString("name") + "_tcp");
se.add(jsonObject.getString("name") + "_udp");
GostUtil.DeleteService(jsonObject.getLong("node_id"), se);
}
}
@Override @Override
public R deleteForward(Long id) { public R deleteForward(Long id) {
@@ -395,10 +514,10 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
.filter(ct -> ct.getChainType() == 3) .filter(ct -> ct.getChainType() == 3)
.toList(); .toList();
List<DiagnosisResult> results = new ArrayList<>(); List<CompletableFuture<DiagnosisResult>> futures = new ArrayList<>();
String[] remoteAddresses = forward.getRemoteAddr().split(","); String[] remoteAddresses = forward.getRemoteAddr().split(",");
// 根据隧道类型执行不同的诊断策略 // 根据隧道类型执行不同的诊断策略(并行执行所有诊断任务)
if (tunnel.getType() == 1) { if (tunnel.getType() == 1) {
// 端口转发:入口节点直接TCP ping目标地址 // 端口转发:入口节点直接TCP ping目标地址
for (ChainTunnel inNode : inNodes) { for (ChainTunnel inNode : inNodes) {
@@ -408,12 +527,18 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
String targetIp = extractIpFromAddress(remoteAddress); String targetIp = extractIpFromAddress(remoteAddress);
int targetPort = extractPortFromAddress(remoteAddress); int targetPort = extractPortFromAddress(remoteAddress);
if (targetIp != null && targetPort != -1) { if (targetIp != null && targetPort != -1) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalNode = node;
node, targetIp, targetPort, final String finalTargetIp = targetIp;
"入口(" + node.getName() + ")->目标(" + remoteAddress + ")" final int finalTargetPort = targetPort;
); final String finalRemoteAddress = remoteAddress;
result.setFromChainType(1); futures.add(CompletableFuture.supplyAsync(() -> {
results.add(result); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
finalNode, finalTargetIp, finalTargetPort,
"入口(" + finalNode.getName() + ")->目标(" + finalRemoteAddress + ")"
);
result.setFromChainType(1);
return result;
}));
} }
} }
} }
@@ -429,27 +554,37 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
for (ChainTunnel firstChainNode : chainNodesList.getFirst()) { for (ChainTunnel firstChainNode : chainNodesList.getFirst()) {
Node toNode = nodeService.getById(firstChainNode.getNodeId()); Node toNode = nodeService.getById(firstChainNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), firstChainNode.getPort(), final Node finalToNode = toNode;
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")" final ChainTunnel finalFirstChainNode = firstChainNode;
); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromChainType(1); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(2); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalFirstChainNode.getPort(),
result.setToInx(firstChainNode.getInx()); "入口(" + finalFromNode.getName() + ")->第1跳(" + finalToNode.getName() + ")"
results.add(result); );
result.setFromChainType(1);
result.setToChainType(2);
result.setToInx(finalFirstChainNode.getInx());
return result;
}));
} }
} }
} else if (!outNodes.isEmpty()) { } else if (!outNodes.isEmpty()) {
for (ChainTunnel outNode : outNodes) { for (ChainTunnel outNode : outNodes) {
Node toNode = nodeService.getById(outNode.getNodeId()); Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), outNode.getPort(), final Node finalToNode = toNode;
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")" final ChainTunnel finalOutNode = outNode;
); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromChainType(1); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(3); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalOutNode.getPort(),
results.add(result); "入口(" + finalFromNode.getName() + ")->出口(" + finalToNode.getName() + ")"
);
result.setFromChainType(1);
result.setToChainType(3);
return result;
}));
} }
} }
} }
@@ -459,6 +594,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
// 2. 链路测试 // 2. 链路测试
for (int i = 0; i < chainNodesList.size(); i++) { for (int i = 0; i < chainNodesList.size(); i++) {
List<ChainTunnel> currentHop = chainNodesList.get(i); List<ChainTunnel> currentHop = chainNodesList.get(i);
final int hopIndex = i;
for (ChainTunnel currentNode : currentHop) { for (ChainTunnel currentNode : currentHop) {
Node fromNode = nodeService.getById(currentNode.getNodeId()); Node fromNode = nodeService.getById(currentNode.getNodeId());
@@ -468,29 +604,41 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
for (ChainTunnel nextNode : chainNodesList.get(i + 1)) { for (ChainTunnel nextNode : chainNodesList.get(i + 1)) {
Node toNode = nodeService.getById(nextNode.getNodeId()); Node toNode = nodeService.getById(nextNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), nextNode.getPort(), final Node finalToNode = toNode;
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")" final ChainTunnel finalCurrentNode = currentNode;
); final ChainTunnel finalNextNode = nextNode;
result.setFromChainType(2); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromInx(currentNode.getInx()); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(2); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalNextNode.getPort(),
result.setToInx(nextNode.getInx()); "第" + (hopIndex + 1) + "跳(" + finalFromNode.getName() + ")->第" + (hopIndex + 2) + "跳(" + finalToNode.getName() + ")"
results.add(result); );
result.setFromChainType(2);
result.setFromInx(finalCurrentNode.getInx());
result.setToChainType(2);
result.setToInx(finalNextNode.getInx());
return result;
}));
} }
} }
} else if (!outNodes.isEmpty()) { } else if (!outNodes.isEmpty()) {
for (ChainTunnel outNode : outNodes) { for (ChainTunnel outNode : outNodes) {
Node toNode = nodeService.getById(outNode.getNodeId()); Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), outNode.getPort(), final Node finalToNode = toNode;
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")" final ChainTunnel finalCurrentNode = currentNode;
); final ChainTunnel finalOutNode = outNode;
result.setFromChainType(2); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromInx(currentNode.getInx()); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(3); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalOutNode.getPort(),
results.add(result); "第" + (hopIndex + 1) + "跳(" + finalFromNode.getName() + ")->出口(" + finalToNode.getName() + ")"
);
result.setFromChainType(2);
result.setFromInx(finalCurrentNode.getInx());
result.setToChainType(3);
return result;
}));
} }
} }
} }
@@ -506,18 +654,29 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
String targetIp = extractIpFromAddress(remoteAddress); String targetIp = extractIpFromAddress(remoteAddress);
int targetPort = extractPortFromAddress(remoteAddress); int targetPort = extractPortFromAddress(remoteAddress);
if (targetIp != null && targetPort != -1) { if (targetIp != null && targetPort != -1) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalNode = node;
node, targetIp, targetPort, final String finalTargetIp = targetIp;
"出口(" + node.getName() + ")->目标(" + remoteAddress + ")" final int finalTargetPort = targetPort;
); final String finalRemoteAddress = remoteAddress;
result.setFromChainType(3); futures.add(CompletableFuture.supplyAsync(() -> {
results.add(result); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
finalNode, finalTargetIp, finalTargetPort,
"出口(" + finalNode.getName() + ")->目标(" + finalRemoteAddress + ")"
);
result.setFromChainType(3);
return result;
}));
} }
} }
} }
} }
} }
// 等待所有诊断任务完成并收集结果
List<DiagnosisResult> results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
// 构建诊断报告 // 构建诊断报告
Map<String, Object> diagnosisReport = new HashMap<>(); Map<String, Object> diagnosisReport = new HashMap<>();
diagnosisReport.put("forwardId", id); diagnosisReport.put("forwardId", id);
@@ -940,12 +1099,12 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
} }
public List<ChainTunnel> get_port(List<ChainTunnel> chainTunnelList, Integer in_port) { public List<ChainTunnel> get_port(List<ChainTunnel> chainTunnelList, Integer in_port, Long forward_id) {
List<List<Integer>> list = new ArrayList<>(); List<List<Integer>> list = new ArrayList<>();
// 获取每个节点的端口列表 // 获取每个节点的端口列表
for (ChainTunnel tunnel : chainTunnelList) { for (ChainTunnel tunnel : chainTunnelList) {
List<Integer> nodePort = getNodePort(tunnel.getNodeId()); List<Integer> nodePort = getNodePort(tunnel.getNodeId(), forward_id);
if (nodePort.isEmpty()) { if (nodePort.isEmpty()) {
throw new RuntimeException("暂无可用端口"); throw new RuntimeException("暂无可用端口");
} }
@@ -968,7 +1127,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
} }
// ========== 未指定 in_port 查找最小的共同端口 ========== // ========== 未指定 in_port 查找最小的共同端口 ==========
Set<Integer> intersection = new HashSet<>(list.get(0)); Set<Integer> intersection = new HashSet<>(list.getFirst());
for (int i = 1; i < list.size(); i++) { for (int i = 1; i < list.size(); i++) {
intersection.retainAll(list.get(i)); intersection.retainAll(list.get(i));
} }
@@ -995,7 +1154,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return chainTunnelList; return chainTunnelList;
} }
public List<Integer> getNodePort(Long nodeId) { public List<Integer> getNodePort(Long nodeId, Long forward_id) {
Node node = nodeService.getById(nodeId); Node node = nodeService.getById(nodeId);
if (node == null) { if (node == null) {
@@ -1012,7 +1171,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
.collect(Collectors.toSet()); .collect(Collectors.toSet());
List<ForwardPort> list = forwardPortService.list(new QueryWrapper<ForwardPort>().eq("node_id", nodeId)); List<ForwardPort> list = forwardPortService.list(new QueryWrapper<ForwardPort>().eq("node_id", nodeId).ne("forward_id", forward_id));
Set<Integer> forwardUsedPorts = new HashSet<>(); Set<Integer> forwardUsedPorts = new HashSet<>();
for (ForwardPort forwardPort : list) { for (ForwardPort forwardPort : list) {
forwardUsedPorts.add(forwardPort.getPort()); forwardUsedPorts.add(forwardPort.getPort());
@@ -9,26 +9,35 @@ import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil; import com.admin.common.utils.GostUtil;
import com.admin.common.utils.WebSocketServer; import com.admin.common.utils.WebSocketServer;
import com.admin.entity.*; import com.admin.entity.*;
import com.admin.mapper.NodeMapper; import com.admin.mapper.NodeMapper;
import com.admin.mapper.TunnelMapper; import com.admin.mapper.TunnelMapper;
import com.admin.service.*; import com.admin.service.*;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONArray;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.springframework.beans.BeanUtils; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.context.annotation.Lazy; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.regex.Pattern;
import javax.annotation.Resource; import org.springframework.beans.factory.annotation.Value;
import java.util.List; import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value; @Service
@Slf4j
@Service public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
@Resource @Resource
@@ -38,33 +47,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Resource @Resource
ViteConfigService viteConfigService; ViteConfigService viteConfigService;
@Resource @Resource
ChainTunnelService chainTunnelService; ChainTunnelService chainTunnelService;
@Resource
ForwardPortService forwardPortService;
@Override @Override
public R createNode(NodeDto nodeDto) { public R createNode(NodeDto nodeDto) {
validatePortRange(nodeDto.getPort()); validatePortRange(nodeDto.getPort());
Node node = new Node();
node.setSecret(IdUtil.simpleUUID()); String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
node.setStatus(0); String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
node.setPort(nodeDto.getPort()); String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
node.setName(nodeDto.getName());
node.setServerIp(nodeDto.getServerIp()); Node node = new Node();
long currentTime = System.currentTimeMillis(); node.setSecret(IdUtil.simpleUUID());
node.setCreatedTime(currentTime); node.setStatus(0);
node.setUpdatedTime(currentTime); node.setPort(nodeDto.getPort());
node.setInterfaceName(nodeDto.getInterfaceName()); node.setName(nodeDto.getName());
this.save(node); node.setServerIp(primaryServerIp);
return R.ok(); node.setServerIpV4(normalizedV4);
} node.setServerIpV6(normalizedV6);
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
node.setInterfaceName(nodeDto.getInterfaceName());
this.save(node);
return R.ok();
}
@Override @Override
public R getAllNodes() { public R getAllNodes() {
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByDesc("status")); List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
nodeList.forEach(node -> node.setSecret(null)); nodeList.forEach(node -> node.setSecret(null));
return R.ok(nodeList); return R.ok(nodeList);
} }
@Override
@Transactional
public R updateNodeOrder(Map<String, Object> params) {
if (!params.containsKey("nodes")) {
return R.err("缺少nodes参数");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> nodesList = (List<Map<String, Object>>) params.get("nodes");
if (nodesList == null || nodesList.isEmpty()) {
return R.err("nodes参数不能为空");
}
List<Node> nodesToUpdate = new ArrayList<>();
for (Map<String, Object> nodeData : nodesList) {
Long id = Long.valueOf(nodeData.get("id").toString());
Integer inx = Integer.valueOf(nodeData.get("inx").toString());
Node node = new Node();
node.setId(id);
node.setInx(inx);
nodesToUpdate.add(node);
}
this.updateBatchById(nodesToUpdate);
return R.ok();
}
@Override @Override
public R updateNode(NodeUpdateDto nodeUpdateDto) { public R updateNode(NodeUpdateDto nodeUpdateDto) {
@@ -96,25 +143,253 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
Node updateNode = buildUpdateNode(nodeUpdateDto); Node updateNode = buildUpdateNode(nodeUpdateDto);
this.updateById(updateNode); // Use LambdaUpdateWrapper to explicitly set nullable fields (serverIpV4/V6)
return R.ok(); // because updateById() skips null fields by default
} LambdaUpdateWrapper<Node> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Node::getId, updateNode.getId())
.set(Node::getName, updateNode.getName())
.set(Node::getServerIp, updateNode.getServerIp())
.set(Node::getServerIpV4, updateNode.getServerIpV4())
.set(Node::getServerIpV6, updateNode.getServerIpV6())
.set(Node::getPort, updateNode.getPort())
.set(Node::getHttp, updateNode.getHttp())
.set(Node::getTls, updateNode.getTls())
.set(Node::getSocks, updateNode.getSocks())
.set(Node::getInterfaceName, updateNode.getInterfaceName())
.set(Node::getTcpListenAddr, updateNode.getTcpListenAddr())
.set(Node::getUdpListenAddr, updateNode.getUdpListenAddr())
.set(Node::getUpdatedTime, updateNode.getUpdatedTime());
this.update(wrapper);
return R.ok();
}
@Override @Override
public R deleteNode(Long id) { public R deleteNode(Long id) {
Node node = this.getById(id); Node node = this.getById(id);
if (node == null) { if (node == null) {
return R.err("节点不存在"); return R.err("节点不存在");
} }
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id).groupBy("tunnel_id")); List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
for (ChainTunnel tunnel : list) { Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
tunnelService.deleteTunnel(tunnel.getTunnelId()); .filter(ct -> ct.getTunnelId() != null)
} .collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
this.removeById(id);
return R.ok(); for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
} Long tunnelId = entry.getKey();
Tunnel tunnel = tunnelService.getById(tunnelId);
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
// Remove the node from the tunnel definition (do NOT delete the tunnel).
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
if (tunnel == null) {
continue;
}
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType();
// Keep tunnel.inIp consistent when it was auto-derived from entry nodes.
String oldDerivedInIp = buildDerivedInIp(before);
String newDerivedInIp = buildDerivedInIp(after);
if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) {
updateTunnelInIp(tunnelId, newDerivedInIp);
}
boolean valid = isTunnelConfigValid(tunnel, after);
if (!valid) {
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete");
continue;
}
// For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config.
// Removing an entry node (chainType=1) does not affect remaining nodes' chain targets.
if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) {
try {
cleanupGostConfig(after, tunnelId);
rebuildGostConfig(after, tunnel);
} catch (Exception e) {
log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e);
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed");
}
}
}
// Remove per-forward port allocations on this node (avoid orphan ForwardPort rows).
try {
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("node_id", id));
} catch (Exception e) {
log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e);
}
this.removeById(id);
return R.ok();
}
private boolean isTunnelConfigValid(Tunnel tunnel, List<ChainTunnel> chainTunnels) {
if (tunnel == null || chainTunnels == null) {
return false;
}
long inCount = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.count();
if (inCount <= 0) {
return false;
}
if (tunnel.getType() != null && tunnel.getType() == 2) {
long outCount = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.count();
return outCount > 0;
}
return true;
}
private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) {
if (StrUtil.isBlank(currentInIp)) {
return true;
}
if (oldDerivedInIp == null) {
return false;
}
return Objects.equals(currentInIp, oldDerivedInIp);
}
private void updateTunnelInIp(Long tunnelId, String derivedInIp) {
Tunnel update = new Tunnel();
update.setId(tunnelId);
update.setInIp(derivedInIp == null ? "" : derivedInIp);
update.setUpdatedTime(System.currentTimeMillis());
tunnelService.updateById(update);
}
private String buildDerivedInIp(List<ChainTunnel> chainTunnels) {
if (chainTunnels == null) {
return "";
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
if (inNodes.isEmpty()) {
return "";
}
StringBuilder inIp = new StringBuilder();
for (ChainTunnel inNode : inNodes) {
Node n = this.getById(inNode.getNodeId());
if (n == null || StrUtil.isBlank(n.getServerIp())) {
return null;
}
inIp.append(n.getServerIp()).append(",");
}
inIp.deleteCharAt(inIp.length() - 1);
return inIp.toString();
}
private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List<ChainTunnel> remaining, String reason) {
try {
Tunnel update = new Tunnel();
update.setId(tunnel.getId());
update.setStatus(0);
update.setUpdatedTime(System.currentTimeMillis());
tunnelService.updateById(update);
} catch (Exception e) {
log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
}
if (tunnel.getType() != null && tunnel.getType() == 2) {
try {
cleanupGostConfig(remaining, tunnel.getId());
} catch (Exception e) {
log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
}
}
}
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
if (chainTunnels == null) {
return;
}
for (ChainTunnel chainTunnel : chainTunnels) {
if (chainTunnel.getChainType() == null) {
continue;
}
if (chainTunnel.getChainType() == 1) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
} else if (chainTunnel.getChainType() == 2) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
} else if (chainTunnel.getChainType() == 3) {
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
}
}
}
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
if (tunnel == null || chainTunnels == null) {
return;
}
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel ct : chainTunnels) {
Node n = this.getById(ct.getNodeId());
if (n != null) {
nodes.put(n.getId(), n);
}
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.collect(Collectors.toList());
List<ChainTunnel> outNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.collect(Collectors.toList());
if (tunnel.getType() != null && tunnel.getType() == 2) {
for (ChainTunnel inNode : inNodes) {
if (chainNodesList.isEmpty()) {
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
}
}
for (int i = 0; i < chainNodesList.size(); i++) {
for (ChainTunnel chainNode : chainNodesList.get(i)) {
if (i + 1 >= chainNodesList.size()) {
GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes);
}
GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes);
}
}
for (ChainTunnel outNode : outNodes) {
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
}
}
}
@Override @Override
@@ -126,8 +401,8 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip")); ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
if (viteConfig == null) return R.err("请先前往网站配置中设置ip"); if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
StringBuilder command = new StringBuilder(); StringBuilder command = new StringBuilder();
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.4-beta/install.sh") command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
.append(" -o ./install.sh && chmod +x ./install.sh && "); .append(" -o ./install.sh && chmod +x ./install.sh && ");
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue()); String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
command.append("./install.sh") command.append("./install.sh")
.append(" -a ").append(processedServerAddr) // 服务器地址 .append(" -a ").append(processedServerAddr) // 服务器地址
@@ -137,22 +412,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
} }
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) { private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
validatePortRange(nodeUpdateDto.getPort()); validatePortRange(nodeUpdateDto.getPort());
Node node = new Node();
node.setId(nodeUpdateDto.getId()); String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
node.setName(nodeUpdateDto.getName()); String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
node.setServerIp(nodeUpdateDto.getServerIp()); String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
node.setPort(nodeUpdateDto.getPort());
node.setHttp(nodeUpdateDto.getHttp()); Node node = new Node();
node.setTls(nodeUpdateDto.getTls()); node.setId(nodeUpdateDto.getId());
node.setSocks(nodeUpdateDto.getSocks()); node.setName(nodeUpdateDto.getName());
node.setUpdatedTime(System.currentTimeMillis()); node.setServerIp(primaryServerIp);
node.setInterfaceName(nodeUpdateDto.getInterfaceName()); node.setServerIpV4(normalizedV4);
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr()); node.setServerIpV6(normalizedV6);
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr()); node.setPort(nodeUpdateDto.getPort());
return node; node.setHttp(nodeUpdateDto.getHttp());
} node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
node.setUpdatedTime(System.currentTimeMillis());
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
return node;
}
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
return fallback != null ? fallback.trim() : null;
}
private String normalizeV4(String serverIpV4, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private String normalizeV6(String serverIpV6, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private boolean looksLikeIpv4(String value) {
// 仅用于判定地址族(不解析域名)
Pattern ipv4 = Pattern.compile("^(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]?)$");
return ipv4.matcher(value).matches();
}
private boolean looksLikeIpv6(String value) {
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
long colonCount = value.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
private void validatePortRange(String port) { private void validatePortRange(String port) {
@@ -18,9 +18,11 @@ import lombok.Data;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -116,14 +118,39 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
long currentTime = System.currentTimeMillis(); long currentTime = System.currentTimeMillis();
tunnel.setCreatedTime(currentTime); tunnel.setCreatedTime(currentTime);
tunnel.setUpdatedTime(currentTime); tunnel.setUpdatedTime(currentTime);
// When tunnels are ordered via `inx`, new tunnels should be appended.
// Only apply this when an order already exists (max `inx` > 0) to avoid
// changing behavior for deployments still relying on local ordering.
Tunnel lastByInx = this.getOne(new QueryWrapper<Tunnel>()
.select("inx")
.orderByDesc("inx")
.orderByDesc("id")
.last("LIMIT 1"));
Integer maxInx = lastByInx == null ? null : lastByInx.getInx();
if (maxInx != null && maxInx > 0) {
tunnel.setInx(maxInx + 1);
}
if (StringUtils.isEmpty(tunnel.getInIp())){ if (StringUtils.isEmpty(tunnel.getInIp())){
StringBuilder in_ip = new StringBuilder(); java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) { for (ChainTunnel chainTunnel : tunnelDto.getInNodeId()) {
Node node = nodes.get(chainTunnel.getNodeId()); Node node = nodes.get(chainTunnel.getNodeId());
in_ip.append(node.getServerIp()).append(","); if (node == null) continue;
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
inIps.add(node.getServerIpV4().trim());
}
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
inIps.add(node.getServerIpV6().trim());
}
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
inIps.add(node.getServerIp().trim());
}
}
if (!inIps.isEmpty()) {
tunnel.setInIp(String.join(",", inIps));
} }
in_ip.deleteCharAt(in_ip.length() - 1);
tunnel.setInIp(in_ip.toString());
} }
this.save(tunnel); this.save(tunnel);
@@ -142,11 +169,33 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel in_node : tunnelDto.getInNodeId()) { for (ChainTunnel in_node : tunnelDto.getInNodeId()) {
// 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口 // 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口
if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口 if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes); GostDto gostDto;
isError(gostDto); try {
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
return R.err(e.getMessage());
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
return R.err(gostDto.getMsg());
}
} else { } else {
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳 GostDto gostDto;
try {
gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){ if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
data.put("node_id", in_node.getNodeId()); data.put("node_id", in_node.getNodeId());
@@ -170,7 +219,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel chainTunnel : chainTunnels1) { for (ChainTunnel chainTunnel : chainTunnels1) {
int inx = i+1; int inx = i+1;
if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口 if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes); GostDto gostDto;
try {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){ if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId()); data.put("node_id", chainTunnel.getNodeId());
@@ -186,7 +246,18 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.err(gostDto.getMsg()); return R.err(gostDto.getMsg());
} }
} else { } else {
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes); GostDto gostDto;
try {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
} catch (RuntimeException e) {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(e.getMessage());
}
if (Objects.equals(gostDto.getMsg(), "OK")){ if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId()); data.put("node_id", chainTunnel.getNodeId());
@@ -252,7 +323,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
@Override @Override
public R getAllTunnels() { public R getAllTunnels() {
List<Tunnel> tunnelList = this.list(); List<Tunnel> tunnelList = this.list(new QueryWrapper<Tunnel>().orderByAsc("inx").orderByAsc("id"));
// 查询所有隧道的ChainTunnel信息 // 查询所有隧道的ChainTunnel信息
List<Long> tunnelIds = tunnelList.stream() List<Long> tunnelIds = tunnelList.stream()
@@ -317,11 +388,148 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.ok(detailDtoList); return R.ok(detailDtoList);
} }
@Override
@Transactional
public R updateTunnelOrder(Map<String, Object> params) {
if (!params.containsKey("tunnels")) {
return R.err("缺少tunnels参数");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> tunnelsList = (List<Map<String, Object>>) params.get("tunnels");
if (tunnelsList == null || tunnelsList.isEmpty()) {
return R.err("tunnels参数不能为空");
}
List<Tunnel> tunnelsToUpdate = new ArrayList<>();
for (Map<String, Object> tunnelData : tunnelsList) {
Long id = Long.valueOf(tunnelData.get("id").toString());
Integer inx = Integer.valueOf(tunnelData.get("inx").toString());
Tunnel tunnel = new Tunnel();
tunnel.setId(id);
tunnel.setInx(inx);
tunnelsToUpdate.add(tunnel);
}
this.updateBatchById(tunnelsToUpdate);
return R.ok();
}
@Override @Override
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) { public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
Tunnel existingTunnel = this.getById(tunnelUpdateDto.getId()); Tunnel existingTunnel = this.getById(tunnelUpdateDto.getId());
if (existingTunnel == null) return R.err("隧道不存在"); if (existingTunnel == null) return R.err("隧道不存在");
List<ChainTunnel> oldChainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelUpdateDto.getId())
);
boolean hasNodeChanges = detectNodeChanges(oldChainTunnels, tunnelUpdateDto);
if (hasNodeChanges && tunnelUpdateDto.getInNodeId() != null) {
List<ChainTunnel> backupChains = deepCopyChainTunnels(oldChainTunnels);
Set<Long> oldEntryNodeIds = oldChainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.map(ChainTunnel::getNodeId)
.collect(Collectors.toSet());
Set<Long> newEntryNodeIds = tunnelUpdateDto.getInNodeId().stream()
.map(ChainTunnel::getNodeId)
.collect(Collectors.toSet());
List<Long> nodeIds = new ArrayList<>();
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) {
nodeIds.add(inNode.getNodeId());
Node node = nodeService.getById(inNode.getNodeId());
if (node == null) return R.err("入口节点不存在: " + inNode.getNodeId());
if (node.getStatus() != 1) return R.err("入口节点不在线: " + node.getName());
nodes.put(node.getId(), node);
}
List<ChainTunnel> newChainTunnels = new ArrayList<>();
for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) {
inNode.setTunnelId(existingTunnel.getId());
inNode.setChainType(1);
newChainTunnels.add(inNode);
}
if (existingTunnel.getType() == 2) {
if (tunnelUpdateDto.getOutNodeId() == null || tunnelUpdateDto.getOutNodeId().isEmpty()) {
return R.err("隧道转发类型必须配置出口节点");
}
List<List<ChainTunnel>> chainNodes = tunnelUpdateDto.getChainNodes() == null ?
new ArrayList<>() : tunnelUpdateDto.getChainNodes();
int inx = 1;
for (List<ChainTunnel> hop : chainNodes) {
for (ChainTunnel chainNode : hop) {
nodeIds.add(chainNode.getNodeId());
Node node = nodeService.getById(chainNode.getNodeId());
if (node == null) return R.err("转发链节点不存在: " + chainNode.getNodeId());
if (node.getStatus() != 1) return R.err("转发链节点不在线: " + node.getName());
nodes.put(node.getId(), node);
Integer port = getNodePort(chainNode.getNodeId());
chainNode.setPort(port);
chainNode.setInx(inx);
chainNode.setChainType(2);
chainNode.setTunnelId(existingTunnel.getId());
newChainTunnels.add(chainNode);
}
inx++;
}
for (ChainTunnel outNode : tunnelUpdateDto.getOutNodeId()) {
nodeIds.add(outNode.getNodeId());
Node node = nodeService.getById(outNode.getNodeId());
if (node == null) return R.err("出口节点不存在: " + outNode.getNodeId());
if (node.getStatus() != 1) return R.err("出口节点不在线: " + node.getName());
nodes.put(node.getId(), node);
Integer port = getNodePort(outNode.getNodeId());
outNode.setPort(port);
outNode.setChainType(3);
outNode.setTunnelId(existingTunnel.getId());
newChainTunnels.add(outNode);
}
}
Set<Long> nodeIdSet = new HashSet<>(nodeIds);
if (nodeIdSet.size() != nodeIds.size()) {
return R.err("节点配置重复");
}
try {
cleanupGostConfig(oldChainTunnels, existingTunnel.getId());
chainTunnelService.remove(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", existingTunnel.getId())
);
R applyResult = applyNewGostConfig(tunnelUpdateDto, existingTunnel, nodes);
if (applyResult.getCode() != 0) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
return R.err("更新失败,已回滚: " + applyResult.getMsg());
}
chainTunnelService.saveBatch(newChainTunnels);
syncForwardsForEntryNodeChanges(existingTunnel.getId(), oldEntryNodeIds, newEntryNodeIds);
} catch (Exception e) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
return R.err("更新失败,已回滚: " + e.getMessage());
}
}
Tunnel tunnel = new Tunnel(); Tunnel tunnel = new Tunnel();
tunnel.setId(tunnelUpdateDto.getId()); tunnel.setId(tunnelUpdateDto.getId());
tunnel.setName(tunnelUpdateDto.getName()); tunnel.setName(tunnelUpdateDto.getName());
@@ -329,18 +537,34 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
tunnel.setTrafficRatio(tunnelUpdateDto.getTrafficRatio()); tunnel.setTrafficRatio(tunnelUpdateDto.getTrafficRatio());
tunnel.setInIp(tunnelUpdateDto.getInIp()); tunnel.setInIp(tunnelUpdateDto.getInIp());
if (StringUtils.isEmpty(tunnel.getInIp())){ boolean forceRegenerateInIp = hasNodeChanges && tunnelUpdateDto.getInNodeId() != null;
StringBuilder in_ip = new StringBuilder(); if (StringUtils.isEmpty(tunnel.getInIp()) || forceRegenerateInIp) {
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)); java.util.LinkedHashSet<String> inIps = new java.util.LinkedHashSet<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
);
for (ChainTunnel chainTunnel : chainTunnels) { for (ChainTunnel chainTunnel : chainTunnels) {
Node node = nodeService.getById(chainTunnel.getNodeId()); Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null)return R.err("隧道节点数据错误,部分节点不存在"); if (node == null) return R.err("隧道节点数据错误,部分节点不存在");
in_ip.append(node.getServerIp()).append(",");
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV4())) {
inIps.add(node.getServerIpV4().trim());
}
if (cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIpV6())) {
inIps.add(node.getServerIpV6().trim());
}
if (cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV4())
&& cn.hutool.core.util.StrUtil.isBlank(node.getServerIpV6())
&& cn.hutool.core.util.StrUtil.isNotBlank(node.getServerIp())) {
inIps.add(node.getServerIp().trim());
}
}
if (!inIps.isEmpty()) {
tunnel.setInIp(String.join(",", inIps));
} }
in_ip.deleteCharAt(in_ip.length() - 1);
tunnel.setInIp(in_ip.toString());
} }
tunnel.setUpdatedTime(System.currentTimeMillis());
this.updateById(tunnel); this.updateById(tunnel);
return R.ok(); return R.ok();
} }
@@ -386,7 +610,10 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Integer roleId = JwtUtil.getRoleIdFromToken(); Integer roleId = JwtUtil.getRoleIdFromToken();
Integer userId = JwtUtil.getUserIdFromToken(); Integer userId = JwtUtil.getUserIdFromToken();
if (roleId == 0) { if (roleId == 0) {
tunnelEntities = this.list(new QueryWrapper<Tunnel>().eq("status", 1)); tunnelEntities = this.list(new QueryWrapper<Tunnel>()
.eq("status", 1)
.orderByAsc("inx")
.orderByAsc("id"));
} else { } else {
tunnelEntities = java.util.Collections.emptyList(); // 返回空列表 tunnelEntities = java.util.Collections.emptyList(); // 返回空列表
List<UserTunnel> userTunnels = userTunnelMapper.selectList( List<UserTunnel> userTunnels = userTunnelMapper.selectList(
@@ -398,7 +625,9 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
.collect(Collectors.toList()); .collect(Collectors.toList());
tunnelEntities = this.list(new QueryWrapper<Tunnel>() tunnelEntities = this.list(new QueryWrapper<Tunnel>()
.in("id", tunnelIds) .in("id", tunnelIds)
.eq("status", 1)); .eq("status", 1)
.orderByAsc("inx")
.orderByAsc("id"));
} }
} }
@@ -441,17 +670,20 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
.filter(ct -> ct.getChainType() == 3) .filter(ct -> ct.getChainType() == 3)
.toList(); .toList();
List<DiagnosisResult> results = new ArrayList<>(); List<CompletableFuture<DiagnosisResult>> futures = new ArrayList<>();
if (tunnel.getType() == 1) { if (tunnel.getType() == 1) {
for (ChainTunnel inNode : inNodes) { for (ChainTunnel inNode : inNodes) {
Node node = nodeService.getById(inNode.getNodeId()); Node node = nodeService.getById(inNode.getNodeId());
if (node != null) { if (node != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalNode = node;
node, "www.google.com", 443, "入口(" + node.getName() + ")->外网" futures.add(CompletableFuture.supplyAsync(() -> {
); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setFromChainType(1); // 入口 finalNode, "www.google.com", 443, "入口(" + finalNode.getName() + ")->外网"
results.add(result); );
result.setFromChainType(1);
return result;
}));
} }
} }
} else if (tunnel.getType() == 2) { } else if (tunnel.getType() == 2) {
@@ -463,27 +695,37 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel firstChainNode : chainNodesList.getFirst()) { for (ChainTunnel firstChainNode : chainNodesList.getFirst()) {
Node toNode = nodeService.getById(firstChainNode.getNodeId()); Node toNode = nodeService.getById(firstChainNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), firstChainNode.getPort(), final Node finalToNode = toNode;
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")" final ChainTunnel finalFirstChainNode = firstChainNode;
); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromChainType(1); // 入口 DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(2); // 链 finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalFirstChainNode.getPort(),
result.setToInx(firstChainNode.getInx()); "入口(" + finalFromNode.getName() + ")->第1跳(" + finalToNode.getName() + ")"
results.add(result); );
result.setFromChainType(1);
result.setToChainType(2);
result.setToInx(finalFirstChainNode.getInx());
return result;
}));
} }
} }
} else if (!outNodes.isEmpty()) { } else if (!outNodes.isEmpty()) {
for (ChainTunnel outNode : outNodes) { for (ChainTunnel outNode : outNodes) {
Node toNode = nodeService.getById(outNode.getNodeId()); Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), outNode.getPort(), final Node finalToNode = toNode;
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")" final ChainTunnel finalOutNode = outNode;
); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromChainType(1); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(3); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalOutNode.getPort(),
results.add(result); "入口(" + finalFromNode.getName() + ")->出口(" + finalToNode.getName() + ")"
);
result.setFromChainType(1);
result.setToChainType(3);
return result;
}));
} }
} }
} }
@@ -492,6 +734,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (int i = 0; i < chainNodesList.size(); i++) { for (int i = 0; i < chainNodesList.size(); i++) {
List<ChainTunnel> currentHop = chainNodesList.get(i); List<ChainTunnel> currentHop = chainNodesList.get(i);
final int hopIndex = i;
for (ChainTunnel currentNode : currentHop) { for (ChainTunnel currentNode : currentHop) {
Node fromNode = nodeService.getById(currentNode.getNodeId()); Node fromNode = nodeService.getById(currentNode.getNodeId());
@@ -501,29 +744,41 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel nextNode : chainNodesList.get(i + 1)) { for (ChainTunnel nextNode : chainNodesList.get(i + 1)) {
Node toNode = nodeService.getById(nextNode.getNodeId()); Node toNode = nodeService.getById(nextNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), nextNode.getPort(), final Node finalToNode = toNode;
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")" final ChainTunnel finalCurrentNode = currentNode;
); final ChainTunnel finalNextNode = nextNode;
result.setFromChainType(2); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromInx(currentNode.getInx()); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(2); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalNextNode.getPort(),
result.setToInx(nextNode.getInx()); "第" + (hopIndex + 1) + "跳(" + finalFromNode.getName() + ")->第" + (hopIndex + 2) + "跳(" + finalToNode.getName() + ")"
results.add(result); );
result.setFromChainType(2);
result.setFromInx(finalCurrentNode.getInx());
result.setToChainType(2);
result.setToInx(finalNextNode.getInx());
return result;
}));
} }
} }
} else if (!outNodes.isEmpty()) { } else if (!outNodes.isEmpty()) {
for (ChainTunnel outNode : outNodes) { for (ChainTunnel outNode : outNodes) {
Node toNode = nodeService.getById(outNode.getNodeId()); Node toNode = nodeService.getById(outNode.getNodeId());
if (toNode != null) { if (toNode != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalFromNode = fromNode;
fromNode, toNode.getServerIp(), outNode.getPort(), final Node finalToNode = toNode;
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")" final ChainTunnel finalCurrentNode = currentNode;
); final ChainTunnel finalOutNode = outNode;
result.setFromChainType(2); futures.add(CompletableFuture.supplyAsync(() -> {
result.setFromInx(currentNode.getInx()); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setToChainType(3); finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalOutNode.getPort(),
results.add(result); "第" + (hopIndex + 1) + "跳(" + finalFromNode.getName() + ")->出口(" + finalToNode.getName() + ")"
);
result.setFromChainType(2);
result.setFromInx(finalCurrentNode.getInx());
result.setToChainType(3);
return result;
}));
} }
} }
} }
@@ -533,15 +788,22 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel outNode : outNodes) { for (ChainTunnel outNode : outNodes) {
Node node = nodeService.getById(outNode.getNodeId()); Node node = nodeService.getById(outNode.getNodeId());
if (node != null) { if (node != null) {
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck( final Node finalNode = node;
node, "www.google.com", 443, "出口(" + node.getName() + ")->外网" futures.add(CompletableFuture.supplyAsync(() -> {
); DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
result.setFromChainType(3); finalNode, "www.google.com", 443, "出口(" + finalNode.getName() + ")->外网"
results.add(result); );
result.setFromChainType(3);
return result;
}));
} }
} }
} }
List<DiagnosisResult> results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
Map<String, Object> diagnosisReport = new HashMap<>(); Map<String, Object> diagnosisReport = new HashMap<>();
diagnosisReport.put("tunnelId", tunnelId); diagnosisReport.put("tunnelId", tunnelId);
diagnosisReport.put("tunnelName", tunnel.getName()); diagnosisReport.put("tunnelName", tunnel.getName());
@@ -608,7 +870,12 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
} }
private void isError(GostDto gostDto){ private void isError(GostDto gostDto){
if (gostDto == null) {
throw new RuntimeException("节点无响应");
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
throw new RuntimeException(gostDto.getMsg());
}
} }
private DiagnosisResult performTcpPingDiagnosis(Node node, String targetIp, int port, String description) { private DiagnosisResult performTcpPingDiagnosis(Node node, String targetIp, int port, String description) {
@@ -706,5 +973,374 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
} }
} }
private boolean detectNodeChanges(List<ChainTunnel> oldChains, TunnelUpdateDto dto) {
if (dto.getInNodeId() == null) {
return false;
}
List<ChainTunnel> oldInNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
List<ChainTunnel> oldChainNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
.collect(Collectors.toList());
List<ChainTunnel> oldOutNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.collect(Collectors.toList());
Set<Long> oldInNodeIds = oldInNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
Set<Long> newInNodeIds = dto.getInNodeId().stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
if (!oldInNodeIds.equals(newInNodeIds)) {
return true;
}
List<ChainTunnel> flatNewChainNodes = (dto.getChainNodes() == null) ? new ArrayList<>() :
dto.getChainNodes().stream().flatMap(List::stream).collect(Collectors.toList());
if (oldChainNodes.size() != flatNewChainNodes.size()) {
return true;
}
for (int i = 0; i < oldChainNodes.size(); i++) {
ChainTunnel oldCt = oldChainNodes.get(i);
boolean found = flatNewChainNodes.stream().anyMatch(newCt ->
Objects.equals(oldCt.getNodeId(), newCt.getNodeId()) &&
Objects.equals(oldCt.getProtocol(), newCt.getProtocol()) &&
Objects.equals(oldCt.getStrategy(), newCt.getStrategy()) &&
Objects.equals(oldCt.getInx(), newCt.getInx())
);
if (!found) {
return true;
}
}
List<ChainTunnel> newOutNodes = (dto.getOutNodeId() == null) ? new ArrayList<>() : dto.getOutNodeId();
if (oldOutNodes.size() != newOutNodes.size()) {
return true;
}
Set<Long> oldOutNodeIds = oldOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
Set<Long> newOutNodeIds = newOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
if (!oldOutNodeIds.equals(newOutNodeIds)) {
return true;
}
for (ChainTunnel oldOut : oldOutNodes) {
boolean found = newOutNodes.stream().anyMatch(newOut ->
Objects.equals(oldOut.getNodeId(), newOut.getNodeId()) &&
Objects.equals(oldOut.getProtocol(), newOut.getProtocol()) &&
Objects.equals(oldOut.getStrategy(), newOut.getStrategy())
);
if (!found) {
return true;
}
}
return false;
}
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
for (ChainTunnel chainTunnel : chainTunnels) {
if (chainTunnel.getChainType() == 1) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
} else if (chainTunnel.getChainType() == 2) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
} else if (chainTunnel.getChainType() == 3) {
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
}
}
}
private List<ChainTunnel> deepCopyChainTunnels(List<ChainTunnel> original) {
List<ChainTunnel> copy = new ArrayList<>();
for (ChainTunnel ct : original) {
ChainTunnel newCt = new ChainTunnel();
newCt.setId(ct.getId());
newCt.setTunnelId(ct.getTunnelId());
newCt.setChainType(ct.getChainType());
newCt.setNodeId(ct.getNodeId());
newCt.setPort(ct.getPort());
newCt.setStrategy(ct.getStrategy());
newCt.setInx(ct.getInx());
newCt.setProtocol(ct.getProtocol());
copy.add(newCt);
}
return copy;
}
private R applyNewGostConfig(TunnelUpdateDto dto, Tunnel tunnel, Map<Long, Node> nodes) {
List<JSONObject> chainSuccess = new ArrayList<>();
List<JSONObject> serviceSuccess = new ArrayList<>();
if (tunnel.getType() == 2) {
List<List<ChainTunnel>> chainNodes = dto.getChainNodes() == null ? new ArrayList<>() : dto.getChainNodes();
for (ChainTunnel inNode : dto.getInNodeId()) {
GostDto gostDto;
if (chainNodes.isEmpty()) {
gostDto = GostUtil.AddChains(inNode.getNodeId(), dto.getOutNodeId(), nodes);
} else {
gostDto = GostUtil.AddChains(inNode.getNodeId(), chainNodes.get(0), nodes);
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建入口Chain失败: " + gostDto.getMsg());
}
JSONObject data = new JSONObject();
data.put("node_id", inNode.getNodeId());
data.put("name", "chains_" + tunnel.getId());
chainSuccess.add(data);
}
for (int i = 0; i < chainNodes.size(); i++) {
List<ChainTunnel> currentHop = chainNodes.get(i);
for (ChainTunnel chainTunnel : currentHop) {
GostDto gostDto;
if (i + 1 >= chainNodes.size()) {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), dto.getOutNodeId(), nodes);
} else {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), chainNodes.get(i + 1), nodes);
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建转发链Chain失败: " + gostDto.getMsg());
}
JSONObject chainData = new JSONObject();
chainData.put("node_id", chainTunnel.getNodeId());
chainData.put("name", "chains_" + tunnel.getId());
chainSuccess.add(chainData);
GostDto serviceResult = GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
if (!Objects.equals(serviceResult.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建转发链Service失败: " + serviceResult.getMsg());
}
JSONObject serviceData = new JSONObject();
serviceData.put("node_id", chainTunnel.getNodeId());
serviceData.put("name", tunnel.getId() + "_tls");
serviceSuccess.add(serviceData);
}
}
for (ChainTunnel outNode : dto.getOutNodeId()) {
GostDto gostDto = GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建出口Service失败: " + gostDto.getMsg());
}
JSONObject serviceData = new JSONObject();
serviceData.put("node_id", outNode.getNodeId());
serviceData.put("name", tunnel.getId() + "_tls");
serviceSuccess.add(serviceData);
}
}
return R.ok();
}
private void rollbackGostChanges(List<JSONObject> chainSuccess, List<JSONObject> serviceSuccess) {
for (JSONObject chain : chainSuccess) {
GostUtil.DeleteChains(chain.getLong("node_id"), chain.getString("name"));
}
for (JSONObject service : serviceSuccess) {
JSONArray services = new JSONArray();
services.add(service.getString("name"));
GostUtil.DeleteService(service.getLong("node_id"), services);
}
}
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel ct : chainTunnels) {
Node node = nodeService.getById(ct.getNodeId());
if (node != null) {
nodes.put(node.getId(), node);
}
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 1)
.collect(Collectors.toList());
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 2)
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.collect(Collectors.toList());
List<ChainTunnel> outNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 3)
.collect(Collectors.toList());
if (tunnel.getType() == 2) {
for (ChainTunnel inNode : inNodes) {
if (chainNodesList.isEmpty()) {
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
}
}
for (int i = 0; i < chainNodesList.size(); i++) {
for (ChainTunnel chainTunnel : chainNodesList.get(i)) {
if (i + 1 >= chainNodesList.size()) {
GostUtil.AddChains(chainTunnel.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(chainTunnel.getNodeId(), chainNodesList.get(i + 1), nodes);
}
GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
}
}
for (ChainTunnel outNode : outNodes) {
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
}
}
}
private void syncForwardsForEntryNodeChanges(Long tunnelId, Set<Long> oldEntryNodeIds, Set<Long> newEntryNodeIds) {
Set<Long> addedNodeIds = new HashSet<>(newEntryNodeIds);
addedNodeIds.removeAll(oldEntryNodeIds);
Set<Long> removedNodeIds = new HashSet<>(oldEntryNodeIds);
removedNodeIds.removeAll(newEntryNodeIds);
if (addedNodeIds.isEmpty() && removedNodeIds.isEmpty()) {
return;
}
List<Forward> forwards = forwardService.list(
new QueryWrapper<Forward>().eq("tunnel_id", tunnelId.intValue())
);
if (forwards.isEmpty()) {
return;
}
Tunnel tunnel = this.getById(tunnelId);
if (tunnel == null) {
return;
}
for (Forward forward : forwards) {
if (forward.getStatus() != 1) {
continue;
}
UserTunnel userTunnel = userTunnelService.getOne(
new QueryWrapper<UserTunnel>()
.eq("user_id", forward.getUserId())
.eq("tunnel_id", tunnelId.intValue())
);
for (Long removedNodeId : removedNodeIds) {
ForwardPort forwardPort = forwardPortService.getOne(
new QueryWrapper<ForwardPort>()
.eq("forward_id", forward.getId())
.eq("node_id", removedNodeId)
);
if (forwardPort != null) {
String serviceName = buildForwardServiceName(forward.getId(), forward.getUserId(), userTunnel);
JSONArray services = new JSONArray();
services.add(serviceName + "_tcp");
services.add(serviceName + "_udp");
GostUtil.DeleteService(removedNodeId, services);
forwardPortService.removeById(forwardPort.getId());
}
}
for (Long addedNodeId : addedNodeIds) {
ForwardPort existingPort = forwardPortService.getOne(
new QueryWrapper<ForwardPort>()
.eq("forward_id", forward.getId())
.eq("node_id", addedNodeId)
);
if (existingPort != null) {
continue;
}
List<ForwardPort> existingPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
);
Integer targetPort = null;
if (!existingPorts.isEmpty()) {
targetPort = existingPorts.get(0).getPort();
}
Integer allocatedPort = allocatePortForNode(addedNodeId, targetPort, forward.getId());
if (allocatedPort == null) {
System.err.println("Failed to allocate port on node " + addedNodeId + " for forward " + forward.getId());
continue;
}
ForwardPort newForwardPort = new ForwardPort();
newForwardPort.setForwardId(forward.getId());
newForwardPort.setNodeId(addedNodeId);
newForwardPort.setPort(allocatedPort);
forwardPortService.save(newForwardPort);
Node node = nodeService.getById(addedNodeId);
if (node != null) {
String serviceName = buildForwardServiceName(forward.getId(), forward.getUserId(), userTunnel);
Integer limiter = (userTunnel != null && userTunnel.getSpeedId() != null) ? userTunnel.getSpeedId() : null;
GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, newForwardPort, tunnel, "AddService");
}
}
}
}
private Integer allocatePortForNode(Long nodeId, Integer preferredPort, Long forwardId) {
Node node = nodeService.getById(nodeId);
if (node == null || node.getPort() == null) {
return null;
}
Set<Integer> usedPorts = new HashSet<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("node_id", nodeId)
);
for (ChainTunnel ct : chainTunnels) {
if (ct.getPort() != null) {
usedPorts.add(ct.getPort());
}
}
List<ForwardPort> forwardPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>()
.eq("node_id", nodeId)
.ne("forward_id", forwardId)
);
for (ForwardPort fp : forwardPorts) {
if (fp.getPort() != null) {
usedPorts.add(fp.getPort());
}
}
List<Integer> availablePorts = parsePorts(node.getPort());
if (preferredPort != null && availablePorts.contains(preferredPort) && !usedPorts.contains(preferredPort)) {
return preferredPort;
}
for (Integer port : availablePorts) {
if (!usedPorts.contains(port)) {
return port;
}
}
return null;
}
private String buildForwardServiceName(Long forwardId, Integer userId, UserTunnel userTunnel) {
int userTunnelId = (userTunnel != null) ? userTunnel.getId() : 0;
return forwardId + "_" + userId + "_" + userTunnelId;
}
} }
@@ -1,120 +1,181 @@
package com.admin.service.impl; package com.admin.service.impl;
import com.admin.common.dto.*; import com.admin.common.dto.*;
import com.admin.common.lang.R; import com.admin.common.lang.R;
import com.admin.entity.UserTunnel; import com.admin.entity.User;
import com.admin.mapper.TunnelMapper; import com.admin.entity.UserTunnel;
import com.admin.mapper.UserTunnelMapper; import com.admin.mapper.UserTunnelMapper;
import com.admin.service.TunnelService; import com.admin.service.UserService;
import com.admin.service.UserTunnelService; import com.admin.service.UserTunnelService;
import com.admin.service.ForwardService; import com.admin.service.ForwardService;
import com.admin.service.NodeService; import com.admin.entity.Forward;
import com.admin.common.utils.GostUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.admin.entity.Forward; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.admin.entity.Tunnel; import org.springframework.beans.BeanUtils;
import com.admin.entity.Node; import org.springframework.context.annotation.Lazy;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import javax.annotation.Resource;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import java.util.ArrayList;
import org.springframework.beans.BeanUtils; import java.util.LinkedHashMap;
import org.springframework.beans.factory.annotation.Autowired; import java.util.List;
import org.springframework.context.annotation.Lazy; import java.util.Map;
import org.springframework.stereotype.Service; import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import java.util.List; @Service
import java.util.Map; public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
/** @Resource
* <p> @Lazy
* 用户隧道权限服务实现类 private ForwardService forwardService;
* 提供用户隧道权限的分配、查询、更新和删除功能
* 支持流量限制、数量限制、过期时间和限速规则的管理 @Resource
* </p> @Lazy
* private UserService userService;
* @author QAQ
* @since 2025-06-03 @Override
*/ public R assignUserTunnel(UserTunnelDto userTunnelDto) {
@Service int count = this.count(new QueryWrapper<UserTunnel>()
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService { .eq("user_id", userTunnelDto.getUserId())
.eq("tunnel_id", userTunnelDto.getTunnelId()));
if (count > 0) return R.err("该用户已拥有此隧道权限");
@Resource
@Lazy User user = userService.getById(userTunnelDto.getUserId());
private ForwardService forwardService; if (user == null) return R.err("用户不存在");
@Override UserTunnel userTunnel = new UserTunnel();
public R assignUserTunnel(UserTunnelDto userTunnelDto) { userTunnel.setUserId(userTunnelDto.getUserId());
int count = this.count(new QueryWrapper<UserTunnel>().eq("user_id", userTunnelDto.getUserId()).eq("tunnel_id", userTunnelDto.getTunnelId())); userTunnel.setTunnelId(userTunnelDto.getTunnelId());
if (count > 0) return R.err("该用户已拥有此隧道权限"); userTunnel.setSpeedId(userTunnelDto.getSpeedId());
UserTunnel userTunnel = new UserTunnel(); userTunnel.setStatus(1);
BeanUtils.copyProperties(userTunnelDto, userTunnel); userTunnel.setInFlow(0L);
userTunnel.setStatus(1); userTunnel.setOutFlow(0L);
this.save(userTunnel);
return R.ok(); userTunnel.setFlow(userTunnelDto.getFlow() != null ? userTunnelDto.getFlow() : user.getFlow());
} userTunnel.setNum(userTunnelDto.getNum() != null ? userTunnelDto.getNum() : user.getNum());
userTunnel.setFlowResetTime(userTunnelDto.getFlowResetTime() != null ? userTunnelDto.getFlowResetTime() : user.getFlowResetTime());
@Override userTunnel.setExpTime(userTunnelDto.getExpTime() != null ? userTunnelDto.getExpTime() : user.getExpTime());
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId()); this.save(userTunnel);
return R.ok(userTunnelWithDetails); return R.ok();
} }
@Override @Override
public R removeUserTunnel(Integer id) { public R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto) {
UserTunnel userTunnel = this.getById(id); User user = userService.getById(batchAssignDto.getUserId());
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录"); if (user == null) return R.err("用户不存在");
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId())); Map<Integer, UserTunnelBatchAssignDto.TunnelAssignItem> uniqueTunnels = new LinkedHashMap<>();
for (Forward forward : forwardList) { for (UserTunnelBatchAssignDto.TunnelAssignItem item : batchAssignDto.getTunnels()) {
forwardService.deleteForward(forward.getId()); uniqueTunnels.putIfAbsent(item.getTunnelId(), item);
} }
this.removeById(id);
return R.ok(); Set<Integer> existingTunnelIds = this.list(
} new QueryWrapper<UserTunnel>()
.eq("user_id", batchAssignDto.getUserId())
@Override .in("tunnel_id", uniqueTunnels.keySet())
public R updateUserTunnel(UserTunnelUpdateDto updateDto) { ).stream().map(UserTunnel::getTunnelId).collect(Collectors.toSet());
UserTunnel userTunnel = this.getById(updateDto.getId());
if (userTunnel == null) return R.err("隧道不存在"); List<UserTunnel> toSave = new ArrayList<>();
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId()); List<Integer> skippedIds = new ArrayList<>();
userTunnel.setFlow(updateDto.getFlow());
userTunnel.setNum(updateDto.getNum()); for (UserTunnelBatchAssignDto.TunnelAssignItem item : uniqueTunnels.values()) {
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime()); if (existingTunnelIds.contains(item.getTunnelId())) {
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime()); skippedIds.add(item.getTunnelId());
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus()); continue;
userTunnel.setSpeedId(updateDto.getSpeedId()); }
this.updateById(userTunnel);
if (speedChanged) { UserTunnel ut = new UserTunnel();
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId())); ut.setUserId(batchAssignDto.getUserId());
for (Forward forward : forwardList) { ut.setTunnelId(item.getTunnelId());
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto(); ut.setSpeedId(item.getSpeedId());
forwardUpdateDto.setId(forward.getId()); ut.setStatus(1);
forwardUpdateDto.setUserId(forward.getUserId()); ut.setInFlow(0L);
forwardUpdateDto.setName(forward.getName()); ut.setOutFlow(0L);
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr()); ut.setFlow(user.getFlow());
forwardUpdateDto.setStrategy(forward.getStrategy()); ut.setNum(user.getNum());
forwardService.updateForward(forwardUpdateDto); ut.setFlowResetTime(user.getFlowResetTime());
} ut.setExpTime(user.getExpTime());
} toSave.add(ut);
return R.err("用户隧道权限更新失败"); }
}
if (toSave.isEmpty()) {
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) { return R.err("所选隧道用户均已拥有权限");
if (value != null) { }
setter.accept(value);
} this.saveBatch(toSave);
}
if (!skippedIds.isEmpty()) {
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) { return R.ok("成功分配 " + toSave.size() + " 个隧道,跳过 " + skippedIds.size() + " 个已有权限的隧道");
if (oldSpeedId == null && newSpeedId == null) { }
return false; return R.ok();
} }
if (oldSpeedId == null || newSpeedId == null) {
return true; @Override
} public R getUserTunnelList(UserTunnelQueryDto queryDto) {
return !oldSpeedId.equals(newSpeedId); List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
} return R.ok(userTunnelWithDetails);
}
}
@Override
public R removeUserTunnel(Integer id) {
UserTunnel userTunnel = this.getById(id);
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
this.removeById(id);
return R.ok();
}
@Override
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
UserTunnel userTunnel = this.getById(updateDto.getId());
if (userTunnel == null) return R.err("隧道不存在");
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
userTunnel.setFlow(updateDto.getFlow());
userTunnel.setNum(updateDto.getNum());
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
userTunnel.setSpeedId(updateDto.getSpeedId());
this.updateById(userTunnel);
if (speedChanged) {
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
forwardUpdateDto.setId(forward.getId());
forwardUpdateDto.setUserId(forward.getUserId());
forwardUpdateDto.setName(forward.getName());
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
forwardUpdateDto.setStrategy(forward.getStrategy());
forwardService.updateForward(forwardUpdateDto);
}
}
return R.ok();
}
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
if (value != null) {
setter.accept(value);
}
}
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
if (oldSpeedId == null && newSpeedId == null) {
return false;
}
if (oldSpeedId == null || newSpeedId == null) {
return true;
}
return !oldSpeedId.equals(newSpeedId);
}
}
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS node (
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL, secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL, server_ip VARCHAR(100) NOT NULL,
server_ip_v4 VARCHAR(100),
server_ip_v6 VARCHAR(100),
port TEXT NOT NULL, port TEXT NOT NULL,
interface_name VARCHAR(200), interface_name VARCHAR(200),
version VARCHAR(100), version VARCHAR(100),
@@ -39,7 +41,8 @@ CREATE TABLE IF NOT EXISTS node (
updated_time INTEGER, updated_time INTEGER,
status INTEGER NOT NULL, status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]', tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]' udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
inx INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS speed_limit ( CREATE TABLE IF NOT EXISTS speed_limit (
@@ -72,7 +75,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
created_time INTEGER NOT NULL, created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL, updated_time INTEGER NOT NULL,
status INTEGER NOT NULL, status INTEGER NOT NULL,
in_ip TEXT in_ip TEXT,
inx INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS chain_tunnel ( CREATE TABLE IF NOT EXISTS chain_tunnel (
@@ -123,4 +127,3 @@ CREATE TABLE IF NOT EXISTS vite_config (
value VARCHAR(200) NOT NULL, value VARCHAR(200) NOT NULL,
time INTEGER NOT NULL time INTEGER NOT NULL
); );
@@ -0,0 +1,162 @@
package com.admin;
import com.admin.common.lang.R;
import com.admin.entity.Node;
import com.admin.entity.Tunnel;
import com.admin.service.NodeService;
import com.admin.service.TunnelService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(properties = {
// use a local sqlite file for tests (resolved via ${DB_PATH} placeholder)
"DB_PATH=./target/test-gost-ordering.db",
})
class OrderingUpdateTests {
@Resource
private JdbcTemplate jdbcTemplate;
@Resource
private NodeService nodeService;
@Resource
private TunnelService tunnelService;
@BeforeEach
void cleanup() {
// keep it simple; other tables may have foreign references in real runs
jdbcTemplate.execute("DELETE FROM node");
jdbcTemplate.execute("DELETE FROM tunnel");
}
@Test
void updateNodeOrder_updatesInx() {
Node n1 = new Node();
n1.setName("n1");
n1.setSecret("s1");
n1.setServerIp("127.0.0.1");
n1.setPort("1000-2000");
n1.setInterfaceName("");
n1.setHttp(0);
n1.setTls(0);
n1.setSocks(0);
n1.setTcpListenAddr("[::]");
n1.setUdpListenAddr("[::]");
n1.setStatus(0);
n1.setInx(0);
long now = System.currentTimeMillis();
n1.setCreatedTime(now);
n1.setUpdatedTime(now);
assertTrue(nodeService.save(n1));
Node n2 = new Node();
n2.setName("n2");
n2.setSecret("s2");
n2.setServerIp("127.0.0.2");
n2.setPort("1000-2000");
n2.setInterfaceName("");
n2.setHttp(0);
n2.setTls(0);
n2.setSocks(0);
n2.setTcpListenAddr("[::]");
n2.setUdpListenAddr("[::]");
n2.setStatus(0);
n2.setInx(0);
n2.setCreatedTime(now);
n2.setUpdatedTime(now);
assertTrue(nodeService.save(n2));
Node n3 = new Node();
n3.setName("n3");
n3.setSecret("s3");
n3.setServerIp("127.0.0.3");
n3.setPort("1000-2000");
n3.setInterfaceName("");
n3.setHttp(0);
n3.setTls(0);
n3.setSocks(0);
n3.setTcpListenAddr("[::]");
n3.setUdpListenAddr("[::]");
n3.setStatus(0);
n3.setInx(0);
n3.setCreatedTime(now);
n3.setUpdatedTime(now);
assertTrue(nodeService.save(n3));
List<Map<String, Object>> nodes = new ArrayList<>();
nodes.add(mapIdInx(n2.getId(), 0));
nodes.add(mapIdInx(n1.getId(), 1));
nodes.add(mapIdInx(n3.getId(), 2));
Map<String, Object> params = new HashMap<>();
params.put("nodes", nodes);
R res = nodeService.updateNodeOrder(params);
assertEquals(0, res.getCode());
assertEquals(1, nodeService.getById(n1.getId()).getInx());
assertEquals(0, nodeService.getById(n2.getId()).getInx());
assertEquals(2, nodeService.getById(n3.getId()).getInx());
}
@Test
void updateTunnelOrder_updatesInx() {
long now = System.currentTimeMillis();
Tunnel t1 = new Tunnel();
t1.setName("t1");
t1.setType(1);
t1.setFlow(1);
t1.setTrafficRatio(new BigDecimal("1.0"));
t1.setInIp("");
t1.setStatus(1);
t1.setInx(0);
t1.setCreatedTime(now);
t1.setUpdatedTime(now);
assertTrue(tunnelService.save(t1));
Tunnel t2 = new Tunnel();
t2.setName("t2");
t2.setType(2);
t2.setFlow(2);
t2.setTrafficRatio(new BigDecimal("1.0"));
t2.setInIp("");
t2.setStatus(1);
t2.setInx(0);
t2.setCreatedTime(now);
t2.setUpdatedTime(now);
assertTrue(tunnelService.save(t2));
List<Map<String, Object>> tunnels = new ArrayList<>();
tunnels.add(mapIdInx(t2.getId(), 0));
tunnels.add(mapIdInx(t1.getId(), 1));
Map<String, Object> params = new HashMap<>();
params.put("tunnels", tunnels);
R res = tunnelService.updateTunnelOrder(params);
assertEquals(0, res.getCode());
assertEquals(1, tunnelService.getById(t1.getId()).getInx());
assertEquals(0, tunnelService.getById(t2.getId()).getInx());
}
private static Map<String, Object> mapIdInx(Long id, int inx) {
Map<String, Object> m = new HashMap<>();
m.put("id", id);
m.put("inx", inx);
return m;
}
}
+36
View File
@@ -0,0 +1,36 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Web management console for Flux Panel.
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
## STRUCTURE
```
vite-frontend/
├── src/
│ ├── pages/ # Route views (some very large single-file pages)
│ ├── components/ # Reusable UI parts
│ ├── layouts/ # Admin vs H5 layouts
│ ├── api/ # API functions + axios wrapper
│ ├── config/ # Site config (title, repo, version)
│ └── utils/ # Auth/JWT + WebView helpers
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
├── eslint.config.mjs # ESLint 9 flat config
└── package.json
```
## CONVENTIONS
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
## COMMANDS
```bash
cd vite-frontend
npm run dev
npm run build
npm run lint
```
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:20.19.0 AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install --legacy-peer-deps
COPY . . COPY . .
RUN npm run build RUN npm run build
+1
View File
@@ -54,6 +54,7 @@
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-router-dom": "6.23.0", "react-router-dom": "6.23.0",
"recharts": "^3.1.1", "recharts": "^3.1.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
+38
View File
@@ -0,0 +1,38 @@
# VITE FRONTEND (src) KNOWLEDGE BASE
## OVERVIEW
React app entry + routing + providers. This is where UI architecture decisions live.
## STRUCTURE
```
vite-frontend/src/
├── main.tsx # ReactDOM + BrowserRouter + Provider
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
├── api/ # Axios wrapper + typed endpoint helpers
├── pages/ # Route views (large)
├── layouts/ # Admin/H5 page chrome
├── components/ # Shared UI components
├── utils/ # JWT parsing + auth helpers + WebView utilities
└── styles/ # globals.css
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
## CONVENTIONS
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
## COMMANDS
```bash
cd vite-frontend
npm run dev
npm run lint
```
+68 -58
View File
@@ -12,11 +12,9 @@ import ProfilePage from "@/pages/profile";
import LimitPage from "@/pages/limit"; import LimitPage from "@/pages/limit";
import ConfigPage from "@/pages/config"; import ConfigPage from "@/pages/config";
import { SettingsPage } from "@/pages/settings"; import { SettingsPage } from "@/pages/settings";
import AdminLayout from "@/layouts/admin"; import AdminLayout from "@/layouts/admin";
import H5Layout from "@/layouts/h5"; import H5Layout from "@/layouts/h5";
import H5SimpleLayout from "@/layouts/h5-simple"; import H5SimpleLayout from "@/layouts/h5-simple";
import { isLoggedIn } from "@/utils/auth"; import { isLoggedIn } from "@/utils/auth";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
@@ -27,11 +25,14 @@ const useH5Mode = () => {
// 检测移动设备或小屏幕 // 检测移动设备或小屏幕
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
// 检测是否为移动端浏览器 // 检测是否为移动端浏览器
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isMobileBrowser =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
// 检测URL参数是否包含h5模式 // 检测URL参数是否包含h5模式
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const isH5Param = urlParams.get('h5') === 'true'; const isH5Param = urlParams.get("h5") === "true";
return isMobile || isMobileBrowser || isH5Param; return isMobile || isMobileBrowser || isH5Param;
}; };
@@ -42,39 +43,50 @@ const useH5Mode = () => {
// 检测移动设备或小屏幕 // 检测移动设备或小屏幕
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
// 检测是否为移动端浏览器 // 检测是否为移动端浏览器
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isMobileBrowser =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
// 检测URL参数是否包含h5模式 // 检测URL参数是否包含h5模式
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const isH5Param = urlParams.get('h5') === 'true'; const isH5Param = urlParams.get("h5") === "true";
setIsH5(isMobile || isMobileBrowser || isH5Param); setIsH5(isMobile || isMobileBrowser || isH5Param);
}; };
window.addEventListener('resize', checkH5Mode); window.addEventListener("resize", checkH5Mode);
return () => window.removeEventListener('resize', checkH5Mode); return () => window.removeEventListener("resize", checkH5Mode);
}, []); }, []);
return isH5; return isH5;
}; };
// 简化的路由保护组件 - 使用 React Router 导航避免循环 // 简化的路由保护组件 - 使用 React Router 导航避免循环
const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false }: { children: React.ReactNode, useSimpleLayout?: boolean, skipLayout?: boolean }) => { const ProtectedRoute = ({
children,
useSimpleLayout = false,
skipLayout = false,
}: {
children: React.ReactNode;
useSimpleLayout?: boolean;
skipLayout?: boolean;
}) => {
const authenticated = isLoggedIn(); const authenticated = isLoggedIn();
const isH5 = useH5Mode(); const isH5 = useH5Mode();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!authenticated) { if (!authenticated) {
// 使用 React Router 导航,避免无限跳转 // 使用 React Router 导航,避免无限跳转
navigate('/', { replace: true }); navigate("/", { replace: true });
} }
}, [authenticated, navigate]); }, [authenticated, navigate]);
if (!authenticated) { if (!authenticated) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black"> <div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
<div className="text-lg text-gray-700 dark:text-gray-200"></div> <div className="text-lg text-gray-700 dark:text-gray-200" />
</div> </div>
); );
} }
@@ -86,6 +98,7 @@ const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false
// 根据模式和页面类型选择布局 // 根据模式和页面类型选择布局
let Layout; let Layout;
if (isH5 && useSimpleLayout) { if (isH5 && useSimpleLayout) {
Layout = H5SimpleLayout; Layout = H5SimpleLayout;
} else if (isH5) { } else if (isH5) {
@@ -93,31 +106,30 @@ const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false
} else { } else {
Layout = AdminLayout; Layout = AdminLayout;
} }
return <Layout>{children}</Layout>; return <Layout>{children}</Layout>;
}; };
// 登录页面路由组件 - 已登录则重定向到dashboard // 登录页面路由组件 - 已登录则重定向到dashboard
const LoginRoute = () => { const LoginRoute = () => {
const authenticated = isLoggedIn(); const authenticated = isLoggedIn();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (authenticated) { if (authenticated) {
// 使用 React Router 导航,避免无限跳转 // 使用 React Router 导航,避免无限跳转
navigate('/dashboard', { replace: true }); navigate("/dashboard", { replace: true });
} }
}, [authenticated, navigate]); }, [authenticated, navigate]);
if (authenticated) { if (authenticated) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black"> <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
<div className="text-lg text-gray-700 dark:text-gray-200"></div> <div className="text-lg text-gray-700 dark:text-gray-200" />
</div> </div>
); );
} }
return <IndexPage />; return <IndexPage />;
}; };
@@ -125,18 +137,19 @@ function App() {
// 立即设置页面标题(使用已从缓存读取的配置) // 立即设置页面标题(使用已从缓存读取的配置)
useEffect(() => { useEffect(() => {
document.title = siteConfig.name; document.title = siteConfig.name;
// 异步检查是否有配置更新 // 异步检查是否有配置更新
const checkTitleUpdate = async () => { const checkTitleUpdate = async () => {
try { try {
// 引入必要的函数 // 引入必要的函数
const { getCachedConfig } = await import('@/config/site'); const { getCachedConfig } = await import("@/config/site");
const cachedAppName = await getCachedConfig('app_name'); const cachedAppName = await getCachedConfig("app_name");
if (cachedAppName && cachedAppName !== document.title) { if (cachedAppName && cachedAppName !== document.title) {
document.title = cachedAppName; document.title = cachedAppName;
} }
} catch (error) { } catch (error) {
console.warn('检查标题更新失败:', error); console.warn("检查标题更新失败:", error);
} }
}; };
@@ -148,83 +161,80 @@ function App() {
return ( return (
<Routes> <Routes>
<Route path="/" element={<LoginRoute />} /> <Route element={<LoginRoute />} path="/" />
<Route <Route
path="/change-password"
element={ element={
<ProtectedRoute skipLayout={true}> <ProtectedRoute skipLayout={true}>
<ChangePasswordPage /> <ChangePasswordPage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/change-password"
/> />
<Route <Route
path="/dashboard"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<DashboardPage /> <DashboardPage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/dashboard"
/> />
<Route <Route
path="/forward"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<ForwardPage /> <ForwardPage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/forward"
/> />
<Route <Route
path="/tunnel"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<TunnelPage /> <TunnelPage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/tunnel"
/> />
<Route <Route
path="/node"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<NodePage /> <NodePage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/node"
/> />
<Route <Route
path="/user"
element={ element={
<ProtectedRoute useSimpleLayout={true}> <ProtectedRoute useSimpleLayout={true}>
<UserPage /> <UserPage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/user"
/> />
<Route <Route
path="/profile"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<ProfilePage /> <ProfilePage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/profile"
/> />
<Route <Route
path="/limit"
element={ element={
<ProtectedRoute useSimpleLayout={true}> <ProtectedRoute useSimpleLayout={true}>
<LimitPage /> <LimitPage />
</ProtectedRoute> </ProtectedRoute>
} }
path="/limit"
/> />
<Route <Route
path="/config"
element={ element={
<ProtectedRoute useSimpleLayout={true}> <ProtectedRoute useSimpleLayout={true}>
<ConfigPage /> <ConfigPage />
</ProtectedRoute> </ProtectedRoute>
} }
/> path="/config"
<Route
path="/settings"
element={<SettingsPage />}
/> />
<Route element={<SettingsPage />} path="/settings" />
</Routes> </Routes>
); );
} }
+16
View File
@@ -0,0 +1,16 @@
# VITE FRONTEND (src/api) KNOWLEDGE BASE
## OVERVIEW
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
## CONVENTIONS
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
- 401 responses clear localStorage and redirect to `/`.
+67 -29
View File
@@ -1,4 +1,4 @@
import Network from './network'; import Network from "./network";
// 登陆相关接口 // 登陆相关接口
export interface LoginData { export interface LoginData {
@@ -14,11 +14,13 @@ export interface LoginResponse {
requirePasswordChange?: boolean; requirePasswordChange?: boolean;
} }
export const login = (data: LoginData) => Network.post<LoginResponse>("/user/login", data); export const login = (data: LoginData) =>
Network.post<LoginResponse>("/user/login", data);
// 用户CRUD操作 - 全部使用POST请求 // 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: any) => Network.post("/user/create", data); export const createUser = (data: any) => Network.post("/user/create", data);
export const getAllUsers = (pageData: any = {}) => Network.post("/user/list", pageData); export const getAllUsers = (pageData: any = {}) =>
Network.post("/user/list", pageData);
export const updateUser = (data: any) => Network.post("/user/update", data); export const updateUser = (data: any) => Network.post("/user/update", data);
export const deleteUser = (id: number) => Network.post("/user/delete", { id }); export const deleteUser = (id: number) => Network.post("/user/delete", { id });
export const getUserPackageInfo = () => Network.post("/user/package"); export const getUserPackageInfo = () => Network.post("/user/package");
@@ -28,64 +30,100 @@ export const createNode = (data: any) => Network.post("/node/create", data);
export const getNodeList = () => Network.post("/node/list"); export const getNodeList = () => Network.post("/node/list");
export const updateNode = (data: any) => Network.post("/node/update", data); export const updateNode = (data: any) => Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id }); export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id }); export const getNodeInstallCommand = (id: number) =>
Network.post("/node/install", { id });
export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data);
export const checkNodeStatus = (nodeId?: number) => { export const checkNodeStatus = (nodeId?: number) => {
const params = nodeId ? { nodeId } : {}; const params = nodeId ? { nodeId } : {};
return Network.post("/node/check-status", params); return Network.post("/node/check-status", params);
}; };
// 隧道CRUD操作 - 全部使用POST请求 // 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data); export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
export const getTunnelList = () => Network.post("/tunnel/list"); export const getTunnelList = () => Network.post("/tunnel/list");
export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id }); export const getTunnelById = (id: number) =>
Network.post("/tunnel/get", { id });
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data); export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); export const deleteTunnel = (id: number) =>
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId }); Network.post("/tunnel/delete", { id });
export const diagnoseTunnel = (tunnelId: number) =>
Network.post("/tunnel/diagnose", { tunnelId });
export const updateTunnelOrder = (data: {
tunnels: Array<{ id: number; inx: number }>;
}) => Network.post("/tunnel/update-order", data);
// 用户隧道权限管理操作 - 全部使用POST请求 // 用户隧道权限管理操作 - 全部使用POST请求
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data); export const assignUserTunnel = (data: any) =>
export const getUserTunnelList = (queryData: any = {}) => Network.post("/tunnel/user/list", queryData); Network.post("/tunnel/user/assign", data);
export const removeUserTunnel = (params: any) => Network.post("/tunnel/user/remove", params); export const batchAssignUserTunnel = (data: {
export const updateUserTunnel = (data: any) => Network.post("/tunnel/user/update", data); userId: number;
tunnels: Array<{ tunnelId: number; speedId?: number | null }>;
}) => Network.post("/tunnel/user/batch-assign", data);
export const getUserTunnelList = (queryData: any = {}) =>
Network.post("/tunnel/user/list", queryData);
export const removeUserTunnel = (params: any) =>
Network.post("/tunnel/user/remove", params);
export const updateUserTunnel = (data: any) =>
Network.post("/tunnel/user/update", data);
export const userTunnel = () => Network.post("/tunnel/user/tunnel"); export const userTunnel = () => Network.post("/tunnel/user/tunnel");
// 转发CRUD操作 - 全部使用POST请求 // 转发CRUD操作 - 全部使用POST请求
export const createForward = (data: any) => Network.post("/forward/create", data); export const createForward = (data: any) =>
Network.post("/forward/create", data);
export const getForwardList = () => Network.post("/forward/list"); export const getForwardList = () => Network.post("/forward/list");
export const updateForward = (data: any) => Network.post("/forward/update", data); export const updateForward = (data: any) =>
export const deleteForward = (id: number) => Network.post("/forward/delete", { id }); Network.post("/forward/update", data);
export const forceDeleteForward = (id: number) => Network.post("/forward/force-delete", { id }); export const deleteForward = (id: number) =>
Network.post("/forward/delete", { id });
export const forceDeleteForward = (id: number) =>
Network.post("/forward/force-delete", { id });
// 转发服务控制操作 - 通过Java后端接口 // 转发服务控制操作 - 通过Java后端接口
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId }); export const pauseForwardService = (forwardId: number) =>
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId }); Network.post("/forward/pause", { id: forwardId });
export const resumeForwardService = (forwardId: number) =>
Network.post("/forward/resume", { id: forwardId });
// 转发诊断操作 // 转发诊断操作
export const diagnoseForward = (forwardId: number) => Network.post("/forward/diagnose", { forwardId }); export const diagnoseForward = (forwardId: number) =>
Network.post("/forward/diagnose", { forwardId });
// 转发排序操作 // 转发排序操作
export const updateForwardOrder = (data: { forwards: Array<{ id: number; inx: number }> }) => Network.post("/forward/update-order", data); export const updateForwardOrder = (data: {
forwards: Array<{ id: number; inx: number }>;
}) => Network.post("/forward/update-order", data);
// 限速规则CRUD操作 - 全部使用POST请求 // 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data); export const createSpeedLimit = (data: any) =>
Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list"); export const getSpeedLimitList = () => Network.post("/speed-limit/list");
export const updateSpeedLimit = (data: any) => Network.post("/speed-limit/update", data); export const updateSpeedLimit = (data: any) =>
export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id }); Network.post("/speed-limit/update", data);
export const deleteSpeedLimit = (id: number) =>
Network.post("/speed-limit/delete", { id });
// 修改密码接口 // 修改密码接口
export const updatePassword = (data: any) => Network.post("/user/updatePassword", data); export const updatePassword = (data: any) =>
Network.post("/user/updatePassword", data);
// 重置流量接口 // 重置流量接口
export const resetUserFlow = (data: { id: number; type: number }) => Network.post("/user/reset", data); export const resetUserFlow = (data: { id: number; type: number }) =>
Network.post("/user/reset", data);
// 网站配置相关接口 // 网站配置相关接口
export const getConfigs = () => Network.post("/config/list"); export const getConfigs = () => Network.post("/config/list");
export const getConfigByName = (name: string) => Network.post("/config/get", { name }); export const getConfigByName = (name: string) =>
export const updateConfigs = (configMap: Record<string, string>) => Network.post("/config/update", configMap); Network.post("/config/get", { name });
export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value }); export const updateConfigs = (configMap: Record<string, string>) =>
Network.post("/config/update", configMap);
export const updateConfig = (name: string, value: string) =>
Network.post("/config/update-single", { name, value });
// 验证码相关接口 // 验证码相关接口
export const checkCaptcha = () => Network.post("/captcha/check"); export const checkCaptcha = () => Network.post("/captcha/check");
export const generateCaptcha = () => Network.post(`/captcha/generate`); export const generateCaptcha = () => Network.post(`/captcha/generate`);
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data); export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
Network.post("/captcha/verify", data);
+96 -70
View File
@@ -1,41 +1,42 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from "axios";
import { getPanelAddresses, isWebViewFunc} from '@/utils/panel';
import { getPanelAddresses, isWebViewFunc } from "@/utils/panel";
interface PanelAddress { interface PanelAddress {
name: string; name: string;
address: string; address: string;
inx: boolean; inx: boolean;
} }
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => { const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
newAddress.forEach(item => { newAddress.forEach((item) => {
if (item.inx) { if (item.inx) {
baseURL = `${item.address}/api/v1/`; baseURL = `${item.address}/api/v1/`;
axios.defaults.baseURL = baseURL; axios.defaults.baseURL = baseURL;
} }
}); });
}
function getWebViewPanelAddress() {
(window as any).setAddresses = setPanelAddressesFunc
getPanelAddresses("setAddresses");
}; };
let baseURL: string = ''; function getWebViewPanelAddress() {
(window as any).setAddresses = setPanelAddressesFunc;
getPanelAddresses("setAddresses");
}
let baseURL: string = "";
export const reinitializeBaseURL = () => { export const reinitializeBaseURL = () => {
if (isWebViewFunc()) { if (isWebViewFunc()) {
getWebViewPanelAddress(); getWebViewPanelAddress();
} else { } else {
baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/'; baseURL = import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/";
axios.defaults.baseURL = baseURL; axios.defaults.baseURL = baseURL;
} }
}; };
reinitializeBaseURL(); reinitializeBaseURL();
interface ApiResponse<T = any> { interface ApiResponse<T = any> {
code: number; code: number;
msg: string; msg: string;
@@ -45,98 +46,123 @@ interface ApiResponse<T = any> {
// 处理token失效的逻辑 // 处理token失效的逻辑
function handleTokenExpired() { function handleTokenExpired() {
// 清除localStorage中的token // 清除localStorage中的token
window.localStorage.removeItem('token'); window.localStorage.removeItem("token");
window.localStorage.removeItem('role_id'); window.localStorage.removeItem("role_id");
window.localStorage.removeItem('name'); window.localStorage.removeItem("name");
// 跳转到登录页面 // 跳转到登录页面
if (window.location.pathname !== '/') { if (window.location.pathname !== "/") {
window.location.href = '/'; window.location.href = "/";
} }
} }
// 检查响应是否为token失效 // 检查响应是否为token失效
function isTokenExpired(response: ApiResponse) { function isTokenExpired(response: ApiResponse) {
return response && response.code === 401 && return (
(response.msg === '未登录或token已过期' || response &&
response.msg === '无效的token或token已过期' || response.code === 401 &&
response.msg === '无法获取用户权限信息'); (response.msg === "未登录或token已过期" ||
response.msg === "无效的token或token已过期" ||
response.msg === "无法获取用户权限信息")
);
} }
const Network = { const Network = {
get: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> { get: function <T = any>(
return new Promise(function(resolve) { path: string = "",
data: any = {},
): Promise<ApiResponse<T>> {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址 // 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
if (baseURL === '') { if (baseURL === "") {
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T}); resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
return; return;
} }
axios.get(path, { axios
params: data, .get(path, {
timeout: 30000, params: data,
headers: { timeout: 30000,
"Authorization": window.localStorage.getItem('token') headers: {
} Authorization: window.localStorage.getItem("token"),
}) },
.then(function(response: AxiosResponse<ApiResponse<T>>) { })
.then(function (response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效 // 检查是否token失效
if (isTokenExpired(response.data)) { if (isTokenExpired(response.data)) {
handleTokenExpired(); handleTokenExpired();
return; return;
} }
resolve(response.data); resolve(response.data);
}) })
.catch(function(error: any) { .catch(function (error: any) {
console.error('GET请求错误:', error); console.error("GET请求错误:", error);
// 检查是否是401错误(token失效) // 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
handleTokenExpired(); handleTokenExpired();
return;
} return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
}); resolve({
code: -1,
msg: error.message || "网络请求失败",
data: null as T,
});
});
}); });
}, },
post: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> { post: function <T = any>(
return new Promise(function(resolve) { path: string = "",
data: any = {},
): Promise<ApiResponse<T>> {
return new Promise(function (resolve) {
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址 // 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
if (baseURL === '') { if (baseURL === "") {
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T}); resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
return; return;
} }
axios.post(path, data, { axios
timeout: 30000, .post(path, data, {
headers: { timeout: 30000,
"Authorization": window.localStorage.getItem('token'), headers: {
"Content-Type": "application/json" Authorization: window.localStorage.getItem("token"),
} "Content-Type": "application/json",
}) },
.then(function(response: AxiosResponse<ApiResponse<T>>) { })
.then(function (response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效 // 检查是否token失效
if (isTokenExpired(response.data)) { if (isTokenExpired(response.data)) {
handleTokenExpired(); handleTokenExpired();
return; return;
} }
resolve(response.data); resolve(response.data);
}) })
.catch(function(error: any) { .catch(function (error: any) {
console.error('POST请求错误:', error); console.error("POST请求错误:", error);
// 检查是否是401错误(token失效) // 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
handleTokenExpired(); handleTokenExpired();
return;
} return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
}); resolve({
code: -1,
msg: error.message || "网络请求失败",
data: null as T,
});
});
}); });
} },
}; };
export default Network; export default Network;
+8 -8
View File
@@ -84,11 +84,11 @@ export const SearchIcon = ({
> >
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
<path <path
d="M22 22L20.5 20.5" d="M22 22L20.5 20.5"
@@ -142,19 +142,19 @@ export const EditIcon = ({
> >
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
</svg> </svg>
); );
@@ -183,11 +183,11 @@ export const DeleteIcon = ({
/> />
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
</svg> </svg>
); );
@@ -209,19 +209,19 @@ export const UserIcon = ({
> >
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
</svg> </svg>
); );
@@ -243,19 +243,19 @@ export const SettingsIcon = ({
> >
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
<path <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" 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"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
fill="none"
/> />
</svg> </svg>
); );
+35 -14
View File
@@ -5,9 +5,9 @@ import {
NavbarBrand, NavbarBrand,
NavbarContent, NavbarContent,
} from "@heroui/navbar"; } from "@heroui/navbar";
import { isWebViewFunc } from '@/utils/panel';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isWebViewFunc } from "@/utils/panel";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { siteConfig, getCachedConfig } from "@/config/site"; import { siteConfig, getCachedConfig } from "@/config/site";
@@ -20,21 +20,21 @@ export const Navbar = () => {
// 检测是否在WebView中运行 // 检测是否在WebView中运行
useEffect(() => { useEffect(() => {
setIsWebView(isWebViewFunc()); setIsWebView(isWebViewFunc());
}, []); }, []);
useEffect(() => { useEffect(() => {
// 异步检查是否有更新的配置 // 异步检查是否有更新的配置
const checkForUpdates = async () => { const checkForUpdates = async () => {
try { try {
const cachedAppName = await getCachedConfig('app_name'); const cachedAppName = await getCachedConfig("app_name");
if (cachedAppName && cachedAppName !== appName) { if (cachedAppName && cachedAppName !== appName) {
setAppName(cachedAppName); setAppName(cachedAppName);
// 同步更新siteConfig // 同步更新siteConfig
siteConfig.name = cachedAppName; siteConfig.name = cachedAppName;
} }
} catch (error) { } catch (error) {
console.warn('检查配置更新失败:', error); console.warn("检查配置更新失败:", error);
} }
}; };
@@ -44,27 +44,33 @@ export const Navbar = () => {
// 监听配置更新事件 // 监听配置更新事件
const handleConfigUpdate = async () => { const handleConfigUpdate = async () => {
try { try {
const cachedAppName = await getCachedConfig('app_name'); const cachedAppName = await getCachedConfig("app_name");
if (cachedAppName) { if (cachedAppName) {
setAppName(cachedAppName); setAppName(cachedAppName);
siteConfig.name = cachedAppName; siteConfig.name = cachedAppName;
} }
} catch (error) { } catch (error) {
console.warn('更新配置失败:', error); console.warn("更新配置失败:", error);
} }
}; };
window.addEventListener('configUpdated', handleConfigUpdate); window.addEventListener("configUpdated", handleConfigUpdate);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
window.removeEventListener('configUpdated', handleConfigUpdate); window.removeEventListener("configUpdated", handleConfigUpdate);
}; };
}, [appName]); }, [appName]);
return ( return (
<> <>
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0"> <HeroUINavbar
className="shrink-0"
height="60px"
maxWidth="xl"
position="sticky"
>
<NavbarContent className="basis-1/5 sm:basis-full" justify="start"> <NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand className="gap-2 max-w-fit"> <NavbarBrand className="gap-2 max-w-fit">
<Link <Link
@@ -83,12 +89,27 @@ export const Navbar = () => {
{isWebView && ( {isWebView && (
<button <button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors" className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
onClick={() => navigate('/settings')}
title="面板设置" title="面板设置"
onClick={() => navigate("/settings")}
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> className="w-5 h-5"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
<path
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg> </svg>
</button> </button>
)} )}
+12 -13
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import AdminLayout from '@/layouts/admin';
import AdminLayout from "@/layouts/admin";
interface PageWrapperProps { interface PageWrapperProps {
children: React.ReactNode; children: React.ReactNode;
@@ -8,11 +9,11 @@ interface PageWrapperProps {
className?: string; className?: string;
} }
export default function PageWrapper({ export default function PageWrapper({
children, children,
title, title,
description, description,
className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8" className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8",
}: PageWrapperProps) { }: PageWrapperProps) {
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
@@ -31,8 +32,8 @@ export default function PageWrapper({
<div className="container mx-auto max-w-7xl px-3 lg:px-6 py-8"> <div className="container mx-auto max-w-7xl px-3 lg:px-6 py-8">
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full"></div> <div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full" />
<span className="text-default-600"></span> <span className="text-default-600" />
</div> </div>
</div> </div>
</div> </div>
@@ -45,12 +46,10 @@ export default function PageWrapper({
<div className={className}> <div className={className}>
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold mb-2 text-foreground">{title}</h1> <h1 className="text-2xl font-bold mb-2 text-foreground">{title}</h1>
{description && ( {description && <p className="text-default-600">{description}</p>}
<p className="text-default-600">{description}</p>
)}
</div> </div>
{children} {children}
</div> </div>
</AdminLayout> </AdminLayout>
); );
} }
+19 -13
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
import { useTheme } from '@heroui/use-theme'; import { useTheme } from "@heroui/use-theme";
interface ThemeProviderProps { interface ThemeProviderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,17 +11,21 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
useEffect(() => { useEffect(() => {
// 确保主题与HTML class同步 // 确保主题与HTML class同步
const updateThemeClass = (currentTheme: string) => { const updateThemeClass = (currentTheme: string) => {
if (currentTheme === 'dark') { if (currentTheme === "dark") {
document.documentElement.classList.add('dark'); document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = 'dark'; document.documentElement.style.colorScheme = "dark";
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = 'light'; document.documentElement.style.colorScheme = "light";
} }
}; };
// 始终跟随系统主题 // 始终跟随系统主题
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
if (systemTheme !== theme) { if (systemTheme !== theme) {
setTheme(systemTheme); setTheme(systemTheme);
} }
@@ -30,15 +34,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
updateThemeClass(theme); updateThemeClass(theme);
// 监听系统主题变化 // 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleThemeChange = (e: MediaQueryListEvent) => { const handleThemeChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light'; const newTheme = e.matches ? "dark" : "light";
setTheme(newTheme); setTheme(newTheme);
}; };
mediaQuery.addEventListener('change', handleThemeChange); mediaQuery.addEventListener("change", handleThemeChange);
return () => mediaQuery.removeEventListener('change', handleThemeChange);
return () => mediaQuery.removeEventListener("change", handleThemeChange);
}, [theme, setTheme]); }, [theme, setTheme]);
return <>{children}</>; return <>{children}</>;
}; };
+51 -34
View File
@@ -1,29 +1,35 @@
import { getConfigByName, getConfigs } from '@/api'; import { getConfigByName, getConfigs } from "@/api";
export type SiteConfig = typeof siteConfig; export type SiteConfig = typeof siteConfig;
// 缓存相关常量 // 缓存相关常量
const CACHE_PREFIX = 'vite_config_'; const CACHE_PREFIX = "vite_config_";
const VERSION = "2.0.4-beta"; const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
const APP_VERSION = "1.0.3"; const APP_VERSION = "1.0.3";
const GITHUB_REPO =
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
const getInitialConfig = () => { const getInitialConfig = () => {
if (typeof window === 'undefined') { if (typeof window === "undefined") {
return { return {
name: "flux", name: "flux",
version: VERSION, version: VERSION,
app_version: APP_VERSION, app_version: APP_VERSION,
github_repo: GITHUB_REPO,
};
}
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
if (cachedAppName) {
return {
name: cachedAppName,
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
}; };
} }
const cachedAppName = localStorage.getItem(CACHE_PREFIX + 'app_name');
if (cachedAppName) {
return {
name: cachedAppName,
version: VERSION,
app_version: APP_VERSION,
};
}
return { return {
name: "flux", name: "flux",
version: VERSION, version: VERSION,
@@ -38,44 +44,52 @@ export const configCache = {
// 获取缓存的配置 // 获取缓存的配置
get: (key: string): string | null => { get: (key: string): string | null => {
const cacheKey = CACHE_PREFIX + key; const cacheKey = CACHE_PREFIX + key;
return localStorage.getItem(cacheKey);
return localStorage.getItem(cacheKey);
}, },
// 设置缓存的配置 // 设置缓存的配置
set: (key: string, value: string): void => { set: (key: string, value: string): void => {
const cacheKey = CACHE_PREFIX + key; const cacheKey = CACHE_PREFIX + key;
localStorage.setItem(cacheKey, value);
localStorage.setItem(cacheKey, value);
}, },
// 删除指定配置的缓存 // 删除指定配置的缓存
remove: (key: string): void => { remove: (key: string): void => {
const cacheKey = CACHE_PREFIX + key; const cacheKey = CACHE_PREFIX + key;
localStorage.removeItem(cacheKey); localStorage.removeItem(cacheKey);
}, },
// 清空所有配置缓存 // 清空所有配置缓存
clear: (): void => { clear: (): void => {
// 获取所有localStorage的key // 获取所有localStorage的key
const keys = Object.keys(localStorage); const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith(CACHE_PREFIX)) { keys.forEach((key) => {
localStorage.removeItem(key); if (key.startsWith(CACHE_PREFIX)) {
} localStorage.removeItem(key);
}); }
} });
},
}; };
// 获取单个配置(优先从缓存) // 获取单个配置(优先从缓存)
export const getCachedConfig = async (key: string): Promise<string | null> => { export const getCachedConfig = async (key: string): Promise<string | null> => {
const cachedValue = configCache.get(key); const cachedValue = configCache.get(key);
if (cachedValue !== null) { if (cachedValue !== null) {
return cachedValue; return cachedValue;
} }
const response = await getConfigByName(key); const response = await getConfigByName(key);
if (response.code === 0 && response.data?.value) { if (response.code === 0 && response.data?.value) {
const value = response.data.value; const value = response.data.value;
configCache.set(key, value); configCache.set(key, value);
return value; return value;
} }
@@ -85,29 +99,31 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
// 获取所有配置(优先从缓存) // 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => { export const getCachedConfigs = async (): Promise<Record<string, string>> => {
// 尝试从缓存获取所有配置 // 尝试从缓存获取所有配置
const configKeys = ['app_name']; const configKeys = ["app_name"];
const cachedConfigs: Record<string, string> = {}; const cachedConfigs: Record<string, string> = {};
let hasCachedData = false; let hasCachedData = false;
configKeys.forEach(key => { configKeys.forEach((key) => {
const cachedValue = configCache.get(key); const cachedValue = configCache.get(key);
if (cachedValue !== null) { if (cachedValue !== null) {
cachedConfigs[key] = cachedValue; cachedConfigs[key] = cachedValue;
hasCachedData = true; hasCachedData = true;
} }
}); });
// 从API获取最新配置 // 从API获取最新配置
try { try {
const response = await getConfigs(); const response = await getConfigs();
if (response.code === 0 && response.data) { if (response.code === 0 && response.data) {
const configs = response.data; const configs = response.data;
// 将所有配置存入缓存 // 将所有配置存入缓存
Object.entries(configs).forEach(([key, value]) => { Object.entries(configs).forEach(([key, value]) => {
configCache.set(key, value as string); configCache.set(key, value as string);
}); });
return configs; return configs;
} }
} catch (error) { } catch (error) {
@@ -122,12 +138,13 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
// 动态更新网站配置 // 动态更新网站配置
export const updateSiteConfig = async () => { export const updateSiteConfig = async () => {
const appName = await getCachedConfig('app_name'); const appName = await getCachedConfig("app_name");
if (appName && appName !== siteConfig.name) {
siteConfig.name = appName; if (appName && appName !== siteConfig.name) {
// 更新页面标题 siteConfig.name = appName;
document.title = appName; // 更新页面标题
} document.title = appName;
}
}; };
// 清除配置缓存的工具函数 // 清除配置缓存的工具函数
@@ -137,7 +154,7 @@ export const updateSiteConfig = async () => {
export const clearConfigCache = (keys?: string[]) => { export const clearConfigCache = (keys?: string[]) => {
if (keys && keys.length > 0) { if (keys && keys.length > 0) {
// 删除指定的配置缓存 // 删除指定的配置缓存
keys.forEach(key => configCache.remove(key)); keys.forEach((key) => configCache.remove(key));
} else { } else {
// 清空所有配置缓存 // 清空所有配置缓存
configCache.clear(); configCache.clear();
@@ -145,7 +162,7 @@ export const clearConfigCache = (keys?: string[]) => {
}; };
// 在页面加载时异步更新配置(如果有更新的话) // 在页面加载时异步更新配置(如果有更新的话)
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
// 延迟执行,避免阻塞初始渲染 // 延迟执行,避免阻塞初始渲染
setTimeout(() => { setTimeout(() => {
updateSiteConfig(); updateSiteConfig();
+292 -181
View File
@@ -1,15 +1,27 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from "react-router-dom";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown"; import {
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal"; Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
} from "@heroui/dropdown";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@heroui/modal";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
import { toast } from 'react-hot-toast'; import { toast } from "react-hot-toast";
import { Logo } from '@/components/icons'; import { Logo } from "@/components/icons";
import { updatePassword } from '@/api'; import { updatePassword } from "@/api";
import { safeLogout } from '@/utils/logout'; import { safeLogout } from "@/utils/logout";
import { siteConfig } from '@/config/site'; import { siteConfig } from "@/config/site";
interface MenuItem { interface MenuItem {
path: string; path: string;
@@ -36,86 +48,106 @@ export default function AdminLayout({
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [mobileMenuVisible, setMobileMenuVisible] = useState(false); const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false); const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordForm>({ const [passwordForm, setPasswordForm] = useState<PasswordForm>({
newUsername: '', newUsername: "",
currentPassword: '', currentPassword: "",
newPassword: '', newPassword: "",
confirmPassword: '' confirmPassword: "",
}); });
// 菜单项配置 // 菜单项配置
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
path: '/dashboard', path: "/dashboard",
label: '仪表板', label: "仪表板",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" /> <path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg> </svg>
) ),
}, },
{ {
path: '/forward', path: "/forward",
label: '转发管理', label: "转发管理",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> <path
</svg> clipRule="evenodd"
) d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
}, fillRule="evenodd"
{ />
path: '/tunnel',
label: '隧道管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg> </svg>
), ),
adminOnly: true
}, },
{ {
path: '/node', path: "/tunnel",
label: '节点监控', label: "隧道管理",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
fillRule="evenodd"
/>
</svg> </svg>
), ),
adminOnly: true adminOnly: true,
}, },
{ {
path: '/limit', path: "/node",
label: '限速管理', label: "节点监控",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z"
fillRule="evenodd"
/>
</svg> </svg>
), ),
adminOnly: true adminOnly: true,
}, },
{ {
path: '/user', path: "/limit",
label: '用户管理', label: "限速管理",
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
fillRule="evenodd"
/>
</svg>
),
adminOnly: true,
},
{
path: "/user",
label: "用户管理",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" /> <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg> </svg>
), ),
adminOnly: true adminOnly: true,
}, },
{ {
path: '/config', path: "/config",
label: '网站配置', label: "网站配置",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
fillRule="evenodd"
/>
</svg> </svg>
), ),
adminOnly: true adminOnly: true,
} },
]; ];
// 检查移动端 // 检查移动端
@@ -128,33 +160,35 @@ export default function AdminLayout({
useEffect(() => { useEffect(() => {
// 获取用户信息 // 获取用户信息
const name = localStorage.getItem('name') || 'Admin'; const name = localStorage.getItem("name") || "Admin";
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) // 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem('admin') === 'true'; let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem('admin') === null) {
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10); if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0; adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断 // 补充设置admin字段,避免下次再次判断
localStorage.setItem('admin', adminFlag.toString()); localStorage.setItem("admin", adminFlag.toString());
} }
setUsername(name); setUsername(name);
setIsAdmin(adminFlag); setIsAdmin(adminFlag);
// 响应式检查 // 响应式检查
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener("resize", checkMobile);
return () => { return () => {
window.removeEventListener('resize', checkMobile); window.removeEventListener("resize", checkMobile);
}; };
}, []); }, []);
// 退出登录 // 退出登录
const handleLogout = () => { const handleLogout = () => {
safeLogout(); safeLogout();
navigate('/'); navigate("/");
}; };
// 切换移动端菜单 // 切换移动端菜单
@@ -178,29 +212,36 @@ export default function AdminLayout({
// 密码表单验证 // 密码表单验证
const validatePasswordForm = (): boolean => { const validatePasswordForm = (): boolean => {
if (!passwordForm.newUsername.trim()) { if (!passwordForm.newUsername.trim()) {
toast.error('请输入新用户名'); toast.error("请输入新用户名");
return false; return false;
} }
if (passwordForm.newUsername.length < 3) { if (passwordForm.newUsername.length < 3) {
toast.error('用户名长度至少3位'); toast.error("用户名长度至少3位");
return false; return false;
} }
if (!passwordForm.currentPassword) { if (!passwordForm.currentPassword) {
toast.error('请输入当前密码'); toast.error("请输入当前密码");
return false; return false;
} }
if (!passwordForm.newPassword) { if (!passwordForm.newPassword) {
toast.error('请输入新密码'); toast.error("请输入新密码");
return false; return false;
} }
if (passwordForm.newPassword.length < 6) { if (passwordForm.newPassword.length < 6) {
toast.error('新密码长度不能少于6位'); toast.error("新密码长度不能少于6位");
return false; return false;
} }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入密码不一致'); toast.error("两次输入密码不一致");
return false; return false;
} }
return true; return true;
}; };
@@ -211,16 +252,17 @@ export default function AdminLayout({
setPasswordLoading(true); setPasswordLoading(true);
try { try {
const response = await updatePassword(passwordForm); const response = await updatePassword(passwordForm);
if (response.code === 0) { if (response.code === 0) {
toast.success('密码修改成功,请重新登录'); toast.success("密码修改成功,请重新登录");
onOpenChange(); onOpenChange();
handleLogout(); handleLogout();
} else { } else {
toast.error(response.msg || '密码修改失败'); toast.error(response.msg || "密码修改失败");
} }
} catch (error) { } catch (error) {
toast.error('修改密码时发生错误'); toast.error("修改密码时发生错误");
console.error('修改密码错误:', error); console.error("修改密码错误:", error);
} finally { } finally {
setPasswordLoading(false); setPasswordLoading(false);
} }
@@ -229,92 +271,98 @@ export default function AdminLayout({
// 重置密码表单 // 重置密码表单
const resetPasswordForm = () => { const resetPasswordForm = () => {
setPasswordForm({ setPasswordForm({
newUsername: '', newUsername: "",
currentPassword: '', currentPassword: "",
newPassword: '', newPassword: "",
confirmPassword: '' confirmPassword: "",
}); });
}; };
// 过滤菜单项(根据权限) // 过滤菜单项(根据权限)
const filteredMenuItems = menuItems.filter(item => const filteredMenuItems = menuItems.filter(
!item.adminOnly || isAdmin (item) => !item.adminOnly || isAdmin,
); );
return ( return (
<div className={`flex ${isMobile ? 'min-h-screen' : 'h-screen'} bg-gray-100 dark:bg-black`}> <div
className={`flex ${isMobile ? "min-h-screen" : "h-screen"} bg-gray-100 dark:bg-black`}
>
{/* 移动端遮罩层 */} {/* 移动端遮罩层 */}
{isMobile && mobileMenuVisible && ( {isMobile && mobileMenuVisible && (
<div <div
className="fixed inset-0 backdrop-blur-sm bg-white/50 dark:bg-black/30 z-40" className="fixed inset-0 backdrop-blur-sm bg-white/50 dark:bg-black/30 z-40"
onClick={hideMobileMenu} onClick={hideMobileMenu}
/> />
)} )}
{/* 左侧菜单栏 */} {/* 左侧菜单栏 */}
<aside className={` <aside
${isMobile ? 'fixed' : 'relative'} className={`
${isMobile && !mobileMenuVisible ? '-translate-x-full' : 'translate-x-0'} ${isMobile ? "fixed" : "relative"}
${isMobile ? 'w-64' : 'w-72'} ${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
${isMobile ? "w-64" : "w-72"}
bg-white dark:bg-black bg-white dark:bg-black
shadow-lg shadow-lg
border-r border-gray-200 dark:border-gray-600 border-r border-gray-200 dark:border-gray-600
z-50 z-50
transition-transform duration-300 ease-in-out transition-transform duration-300 ease-in-out
flex flex-col flex flex-col
${isMobile ? 'h-screen' : 'h-full'} ${isMobile ? "h-screen" : "h-full"}
${isMobile ? 'top-0 left-0' : ''} ${isMobile ? "top-0 left-0" : ""}
`}> `}
{/* Logo 区域 */} >
<div className="px-3 py-3 h-14 flex items-center"> {/* Logo 区域 */}
<div className="flex items-center gap-2 w-full"> <div className="px-3 py-3 h-14 flex items-center">
<Logo size={24} /> <div className="flex items-center gap-2 w-full">
<div className="flex-1 min-w-0"> <Logo size={24} />
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">{siteConfig.name}</h1> <div className="flex-1 min-w-0">
<p className="text-xs text-default-500">v{siteConfig.version}</p> <h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
</div> {siteConfig.name}
</div> </h1>
</div> <p className="text-xs text-default-500">v{siteConfig.version}</p>
</div>
</div>
</div>
{/* 菜单导航 */} {/* 菜单导航 */}
<nav className="flex-1 px-4 py-6 overflow-y-auto"> <nav className="flex-1 px-4 py-6 overflow-y-auto">
<ul className="space-y-1"> <ul className="space-y-1">
{filteredMenuItems.map((item) => { {filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
<li key={item.path}> <li key={item.path}>
<button <button
onClick={() => handleMenuClick(item.path)} className={`
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
transition-colors duration-200 min-h-[44px] transition-colors duration-200 min-h-[44px]
${isActive ${
? 'bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300' isActive
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900' ? "bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
} }
`} `}
> onClick={() => handleMenuClick(item.path)}
<div className="flex-shrink-0"> >
{item.icon} <div className="flex-shrink-0">{item.icon}</div>
</div> <span className="font-medium text-sm">{item.label}</span>
<span className="font-medium text-sm">{item.label}</span> </button>
</button>
</li> </li>
); );
})} })}
</ul> </ul>
</nav> </nav>
{/* 底部版权信息 */} {/* 底部版权信息 */}
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0"> <div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
<div className="text-center"> <div className="text-center">
<p className="text-xs text-gray-400 dark:text-gray-500"> <p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '} Powered by{" "}
<a <a
href="https://github.com/bqlpfy/flux-panel"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
> >
flux-panel flux-panel
</a> </a>
@@ -324,20 +372,32 @@ export default function AdminLayout({
</aside> </aside>
{/* 主内容区域 */} {/* 主内容区域 */}
<div className={`flex flex-col flex-1 ${isMobile ? 'min-h-0' : 'h-full overflow-hidden'}`}> <div
{/* 顶部导航栏 */} className={`flex flex-col flex-1 ${isMobile ? "min-h-0" : "h-full overflow-hidden"}`}
<header className="bg-white dark:bg-black shadow-md border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 lg:px-6 relative z-10"> >
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-md border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 lg:px-6 relative z-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* 移动端菜单按钮 */} {/* 移动端菜单按钮 */}
{isMobile && ( {isMobile && (
<Button <Button
isIconOnly isIconOnly
className="lg:hidden"
variant="light" variant="light"
onPress={toggleMobileMenu} onPress={toggleMobileMenu}
className="lg:hidden"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 6h16M4 12h16M4 18h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg> </svg>
</Button> </Button>
)} )}
@@ -345,21 +405,40 @@ export default function AdminLayout({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* 用户菜单 */} {/* 用户菜单 */}
<Dropdown placement="bottom-end"> <Dropdown placement="bottom-end">
<DropdownTrigger> <DropdownTrigger>
<Button variant="light" className="text-sm font-medium text-foreground"> <Button
{username} className="text-sm font-medium text-foreground"
<svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20"> variant="light"
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" /> >
</svg> {username}
</Button> <svg
</DropdownTrigger> className="w-4 h-4 ml-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
fillRule="evenodd"
/>
</svg>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单"> <DropdownMenu aria-label="用户菜单">
<DropdownItem <DropdownItem
key="change-password" key="change-password"
startContent={ startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" /> className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
fillRule="evenodd"
/>
</svg> </svg>
} }
onPress={onOpen} onPress={onOpen}
@@ -368,13 +447,21 @@ export default function AdminLayout({
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem
key="logout" key="logout"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" />
</svg>
}
className="text-danger" className="text-danger"
color="danger" color="danger"
startContent={
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
fillRule="evenodd"
/>
</svg>
}
onPress={handleLogout} onPress={handleLogout}
> >
退出登录 退出登录
@@ -385,70 +472,94 @@ export default function AdminLayout({
</header> </header>
{/* 主内容 */} {/* 主内容 */}
<main className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? '' : 'overflow-y-auto'}`}> <main
className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? "" : "overflow-y-auto"}`}
>
{children} {children}
</main> </main>
</div> </div>
{/* 修改密码弹窗 */} {/* 修改密码弹窗 */}
<Modal <Modal
isOpen={isOpen} backdrop="blur"
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={() => { onOpenChange={() => {
onOpenChange(); onOpenChange();
resetPasswordForm(); resetPasswordForm();
}} }}
size="2xl"
scrollBehavior="outside"
backdrop="blur"
placement="center"
> >
<ModalContent> <ModalContent>
{(onClose: () => void) => ( {(onClose: () => void) => (
<> <>
<ModalHeader className="flex flex-col gap-1">修改密码</ModalHeader> <ModalHeader className="flex flex-col gap-1">
修改密码
</ModalHeader>
<ModalBody> <ModalBody>
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
label="新用户名" label="新用户名"
placeholder="请输入新用户名(至少3位)" placeholder="请输入新用户名(至少3位)"
value={passwordForm.newUsername} value={passwordForm.newUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))} variant="bordered"
variant="bordered" onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
/> setPasswordForm((prev) => ({
<Input ...prev,
label="当前密码" newUsername: e.target.value,
type="password" }))
placeholder="请输入当前密码" }
value={passwordForm.currentPassword} />
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))} <Input
variant="bordered" label="当前密码"
/> placeholder="请输入当前密码"
<Input type="password"
label="新密码" value={passwordForm.currentPassword}
type="password" variant="bordered"
placeholder="请输入新密码(至少6位)" onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
value={passwordForm.newPassword} setPasswordForm((prev) => ({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))} ...prev,
variant="bordered" currentPassword: e.target.value,
/> }))
<Input }
label="确认密码" />
type="password" <Input
placeholder="请再次输入新密码" label="新密码"
value={passwordForm.confirmPassword} placeholder="请输入新密码(至少6位)"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))} type="password"
variant="bordered" value={passwordForm.newPassword}
/> variant="bordered"
</div> onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordForm((prev) => ({
...prev,
newPassword: e.target.value,
}))
}
/>
<Input
label="确认密码"
placeholder="请再次输入新密码"
type="password"
value={passwordForm.confirmPassword}
variant="bordered"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordForm((prev) => ({
...prev,
confirmPassword: e.target.value,
}))
}
/>
</div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button color="default" variant="light" onPress={onClose}> <Button color="default" variant="light" onPress={onClose}>
取消 取消
</Button> </Button>
<Button <Button
color="primary" color="primary"
onPress={handlePasswordSubmit}
isLoading={passwordLoading} isLoading={passwordLoading}
onPress={handlePasswordSubmit}
> >
确定 确定
</Button> </Button>
@@ -459,4 +570,4 @@ export default function AdminLayout({
</Modal> </Modal>
</div> </div>
); );
} }
+18 -20
View File
@@ -1,9 +1,9 @@
import React from 'react'; import React from "react";
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from "react-router-dom";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Logo } from '@/components/icons'; import { Logo } from "@/components/icons";
import { siteConfig } from '@/config/site'; import { siteConfig } from "@/config/site";
export default function H5SimpleLayout({ export default function H5SimpleLayout({
children, children,
@@ -12,11 +12,11 @@ export default function H5SimpleLayout({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
// 路由切换时回到顶部,避免上一页滚动位置保留 // 路由切换时回到顶部,避免上一页滚动位置保留
React.useEffect(() => { React.useEffect(() => {
try { try {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch (e) { } catch (e) {
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@@ -25,7 +25,7 @@ export default function H5SimpleLayout({
}, [location.pathname]); }, [location.pathname]);
const handleBack = () => { const handleBack = () => {
navigate('/profile'); navigate("/profile");
}; };
return ( return (
@@ -33,28 +33,26 @@ export default function H5SimpleLayout({
{/* 顶部导航栏 */} {/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10"> <header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button isIconOnly size="sm" variant="light" onPress={handleBack}>
isIconOnly
variant="light"
size="sm"
onPress={handleBack}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
fillRule="evenodd"
/>
</svg> </svg>
</Button> </Button>
<Logo size={20} /> <Logo size={20} />
<h1 className="text-sm font-bold text-foreground">{siteConfig.name}</h1> <h1 className="text-sm font-bold text-foreground">
{siteConfig.name}
</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2" />
</div>
</header> </header>
{/* 主内容区域 */} {/* 主内容区域 */}
<main className="flex-1 bg-gray-100 dark:bg-black pb-0"> <main className="flex-1 bg-gray-100 dark:bg-black pb-0">{children}</main>
{children}
</main>
</div> </div>
); );
} }
+62 -58
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from "react-router-dom";
import { Logo } from '@/components/icons'; import { Logo } from "@/components/icons";
import { siteConfig } from '@/config/site'; import { siteConfig } from "@/config/site";
interface TabItem { interface TabItem {
path: string; path: string;
@@ -11,13 +11,7 @@ interface TabItem {
adminOnly?: boolean; adminOnly?: boolean;
} }
export default function H5Layout({ children }: { children: React.ReactNode }) {
export default function H5Layout({
children,
}: {
children: React.ReactNode;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
@@ -25,65 +19,77 @@ export default function H5Layout({
// Tabbar配置 // Tabbar配置
const tabItems: TabItem[] = [ const tabItems: TabItem[] = [
{ {
path: '/dashboard', path: "/dashboard",
label: '首页', label: "首页",
icon: ( icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /> <path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg> </svg>
) ),
}, },
{ {
path: '/forward', path: "/forward",
label: '转发', label: "转发",
icon: ( icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> <path
</svg> clipRule="evenodd"
) d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
}, fillRule="evenodd"
{ />
path: '/tunnel',
label: '隧道',
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg> </svg>
), ),
adminOnly: true
}, },
{ {
path: '/node', path: "/tunnel",
label: '节点', label: "隧道",
icon: ( icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
fillRule="evenodd"
/>
</svg> </svg>
), ),
adminOnly: true adminOnly: true,
}, },
{ {
path: '/profile', path: "/node",
label: '我的', label: "节点",
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
clipRule="evenodd"
d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z"
fillRule="evenodd"
/>
</svg>
),
adminOnly: true,
},
{
path: "/profile",
label: "我的",
icon: ( icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" /> <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg> </svg>
) ),
} },
]; ];
useEffect(() => { useEffect(() => {
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) // 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem('admin') === 'true'; let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem('admin') === null) {
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10); if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0; adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断 // 补充设置admin字段,避免下次再次判断
localStorage.setItem('admin', adminFlag.toString()); localStorage.setItem("admin", adminFlag.toString());
} }
setIsAdmin(adminFlag); setIsAdmin(adminFlag);
}, []); }, []);
@@ -94,14 +100,14 @@ export default function H5Layout({
}; };
// 过滤tab项(根据权限) // 过滤tab项(根据权限)
const filteredTabItems = tabItems.filter(item => const filteredTabItems = tabItems.filter(
!item.adminOnly || isAdmin (item) => !item.adminOnly || isAdmin,
); );
// 路由切换时回到页面顶部,避免上一页的滚动位置遗留 // 路由切换时回到页面顶部,避免上一页的滚动位置遗留
useEffect(() => { useEffect(() => {
try { try {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch (e) { } catch (e) {
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@@ -115,17 +121,16 @@ export default function H5Layout({
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10"> <header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Logo size={20} /> <Logo size={20} />
<h1 className="text-sm font-bold text-foreground">{siteConfig.name}</h1> <h1 className="text-sm font-bold text-foreground">
{siteConfig.name}
</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2" />
</div>
</header> </header>
{/* 主内容区域 */} {/* 主内容区域 */}
<main className="flex-1 bg-gray-100 dark:bg-black"> <main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
{children}
</main>
{/* 用于给固定 Tabbar 腾出空间的占位元素 */} {/* 用于给固定 Tabbar 腾出空间的占位元素 */}
<div aria-hidden className="h-16 safe-bottom" /> <div aria-hidden className="h-16 safe-bottom" />
@@ -134,28 +139,27 @@ export default function H5Layout({
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30"> <nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
{filteredTabItems.map((item) => { {filteredTabItems.map((item) => {
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
<button <button
key={item.path} key={item.path}
onClick={() => handleTabClick(item.path)}
className={` className={`
flex flex-col items-center justify-center flex-1 h-full flex flex-col items-center justify-center flex-1 h-full
transition-colors duration-200 min-h-[44px] transition-colors duration-200 min-h-[44px]
${isActive ${
? 'text-primary-600 dark:text-primary-400' isActive
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' ? "text-primary-600 dark:text-primary-400"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
} }
`} `}
onClick={() => handleTabClick(item.path)}
> >
<div className="flex-shrink-0 mb-1"> <div className="flex-shrink-0 mb-1">{item.icon}</div>
{item.icon}
</div>
<span className="text-xs font-medium">{item.label}</span> <span className="text-xs font-medium">{item.label}</span>
</button> </button>
); );
})} })}
</nav> </nav>
</div> </div>
); );
} }
+1 -3
View File
@@ -1,4 +1,3 @@
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
@@ -11,6 +10,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Provider> <Provider>
<App /> <App />
</Provider> </Provider>
</BrowserRouter> </BrowserRouter>,
); );
+30
View File
@@ -0,0 +1,30 @@
# VITE FRONTEND (pages) KNOWLEDGE BASE
## OVERVIEW
Route views rendered by `vite-frontend/src/App.tsx`. Several pages are large, single-file screens.
## STRUCTURE
```
vite-frontend/src/pages/
├── index.tsx # Login + captcha flow
├── dashboard.tsx
├── forward.tsx # Large
├── tunnel.tsx # Large
├── node.tsx # Large
├── user.tsx # Large
├── config.tsx
├── limit.tsx
├── profile.tsx
├── settings.tsx
└── change-password.tsx
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Login flow | `vite-frontend/src/pages/index.tsx` | Calls `login()` and stores `localStorage.token` |
| API calls | `vite-frontend/src/api/index.ts` | Thin wrappers around `Network.post` |
| Token expiration behavior | `vite-frontend/src/api/network.ts` | Clears localStorage + redirects on 401 |
## CONVENTIONS
- Pages call API wrappers from `vite-frontend/src/api/index.ts` (most endpoints are POST).
+78 -59
View File
@@ -3,7 +3,7 @@ import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast'; import toast from "react-hot-toast";
import { title } from "@/components/primitives"; import { title } from "@/components/primitives";
import { updatePassword } from "@/api"; import { updatePassword } from "@/api";
@@ -19,10 +19,10 @@ interface PasswordForm {
export default function ChangePasswordPage() { export default function ChangePasswordPage() {
const [form, setForm] = useState<PasswordForm>({ const [form, setForm] = useState<PasswordForm>({
newUsername: '', newUsername: "",
currentPassword: '', currentPassword: "",
newPassword: '', newPassword: "",
confirmPassword: '' confirmPassword: "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<PasswordForm>>({}); const [errors, setErrors] = useState<Partial<PasswordForm>>({});
@@ -32,39 +32,40 @@ export default function ChangePasswordPage() {
const newErrors: Partial<PasswordForm> = {}; const newErrors: Partial<PasswordForm> = {};
if (!form.newUsername.trim()) { if (!form.newUsername.trim()) {
newErrors.newUsername = '请输入新用户名'; newErrors.newUsername = "请输入新用户名";
} else if (form.newUsername.length < 3) { } else if (form.newUsername.length < 3) {
newErrors.newUsername = '用户名长度至少3位'; newErrors.newUsername = "用户名长度至少3位";
} else if (form.newUsername.length > 20) { } else if (form.newUsername.length > 20) {
newErrors.newUsername = '用户名长度不能超过20位'; newErrors.newUsername = "用户名长度不能超过20位";
} }
if (!form.currentPassword.trim()) { if (!form.currentPassword.trim()) {
newErrors.currentPassword = '请输入当前密码'; newErrors.currentPassword = "请输入当前密码";
} }
if (!form.newPassword.trim()) { if (!form.newPassword.trim()) {
newErrors.newPassword = '请输入新密码'; newErrors.newPassword = "请输入新密码";
} else if (form.newPassword.length < 6) { } else if (form.newPassword.length < 6) {
newErrors.newPassword = '新密码长度不能少于6位'; newErrors.newPassword = "新密码长度不能少于6位";
} else if (form.newPassword.length > 20) { } else if (form.newPassword.length > 20) {
newErrors.newPassword = '新密码长度不能超过20位'; newErrors.newPassword = "新密码长度不能超过20位";
} }
if (!form.confirmPassword.trim()) { if (!form.confirmPassword.trim()) {
newErrors.confirmPassword = '请再次输入新密码'; newErrors.confirmPassword = "请再次输入新密码";
} else if (form.confirmPassword !== form.newPassword) { } else if (form.confirmPassword !== form.newPassword) {
newErrors.confirmPassword = '两次输入密码不一致'; newErrors.confirmPassword = "两次输入密码不一致";
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const handleInputChange = (field: keyof PasswordForm, value: string) => { const handleInputChange = (field: keyof PasswordForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field]) { if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined })); setErrors((prev) => ({ ...prev, [field]: undefined }));
} }
}; };
@@ -74,23 +75,23 @@ export default function ChangePasswordPage() {
setLoading(true); setLoading(true);
try { try {
const response = await updatePassword(form); const response = await updatePassword(form);
if (response.code === 0) { if (response.code === 0) {
toast.success(response.msg || '账号密码修改成功'); toast.success(response.msg || "账号密码修改成功");
// 使用 toast 确认对话框的替代方案 // 使用 toast 确认对话框的替代方案
setTimeout(() => { setTimeout(() => {
toast.success('即将跳转到登陆页面,请重新登录'); toast.success("即将跳转到登陆页面,请重新登录");
setTimeout(() => { setTimeout(() => {
logout(); logout();
}, 1000); }, 1000);
}, 1000); }, 1000);
} else { } else {
toast.error(response.msg || '账号密码修改失败'); toast.error(response.msg || "账号密码修改失败");
} }
} catch (error) { } catch (error) {
console.error('修改账号密码错误:', error); console.error("修改账号密码错误:", error);
toast.error('修改账号密码时发生错误'); toast.error("修改账号密码时发生错误");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -98,11 +99,11 @@ export default function ChangePasswordPage() {
const logout = () => { const logout = () => {
safeLogout(); safeLogout();
navigate('/'); navigate("/");
}; };
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) { if (e.key === "Enter" && !loading) {
handleSubmit(); handleSubmit();
} }
}; };
@@ -114,79 +115,97 @@ export default function ChangePasswordPage() {
<Card className="w-full"> <Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center"> <CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3"> <div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-warning-600" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> className="w-6 h-6 text-warning-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
fillRule="evenodd"
/>
</svg> </svg>
</div> </div>
<h1 className={title({ size: "sm" })}>安全提醒</h1> <h1 className={title({ size: "sm" })}>安全提醒</h1>
<p className="text-small text-default-500 mt-2 text-center">检测到您使用的是默认账号密码,为了您的账户安全,请立即修改</p> <p className="text-small text-default-500 mt-2 text-center">
检测到您使用的是默认账号密码,为了您的账户安全,请立即修改
</p>
</CardHeader> </CardHeader>
<CardBody className="px-6 py-6"> <CardBody className="px-6 py-6">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Input <Input
errorMessage={errors.newUsername}
isDisabled={loading}
isInvalid={!!errors.newUsername}
label="新用户名" label="新用户名"
placeholder="请输入新用户名(至少3位)" placeholder="请输入新用户名(至少3位)"
value={form.newUsername} value={form.newUsername}
onChange={(e) => handleInputChange('newUsername', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered" variant="bordered"
isDisabled={loading} onChange={(e) =>
isInvalid={!!errors.newUsername} handleInputChange("newUsername", e.target.value)
errorMessage={errors.newUsername} }
onKeyDown={handleKeyPress}
/> />
<Input <Input
errorMessage={errors.currentPassword}
isDisabled={loading}
isInvalid={!!errors.currentPassword}
label="当前密码" label="当前密码"
placeholder="请输入当前密码" placeholder="请输入当前密码"
type="password" type="password"
value={form.currentPassword} value={form.currentPassword}
onChange={(e) => handleInputChange('currentPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered" variant="bordered"
isDisabled={loading} onChange={(e) =>
isInvalid={!!errors.currentPassword} handleInputChange("currentPassword", e.target.value)
errorMessage={errors.currentPassword} }
onKeyDown={handleKeyPress}
/> />
<Input <Input
errorMessage={errors.newPassword}
isDisabled={loading}
isInvalid={!!errors.newPassword}
label="新密码" label="新密码"
placeholder="请输入新密码(至少6位)" placeholder="请输入新密码(至少6位)"
type="password" type="password"
value={form.newPassword} value={form.newPassword}
onChange={(e) => handleInputChange('newPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered" variant="bordered"
isDisabled={loading} onChange={(e) =>
isInvalid={!!errors.newPassword} handleInputChange("newPassword", e.target.value)
errorMessage={errors.newPassword} }
onKeyDown={handleKeyPress}
/> />
<Input <Input
errorMessage={errors.confirmPassword}
isDisabled={loading}
isInvalid={!!errors.confirmPassword}
label="确认新密码" label="确认新密码"
placeholder="请再次输入新密码" placeholder="请再次输入新密码"
type="password" type="password"
value={form.confirmPassword} value={form.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered" variant="bordered"
isDisabled={loading} onChange={(e) =>
isInvalid={!!errors.confirmPassword} handleInputChange("confirmPassword", e.target.value)
errorMessage={errors.confirmPassword} }
onKeyDown={handleKeyPress}
/> />
<Button <Button
className="mt-2"
color="warning" color="warning"
disabled={loading}
isLoading={loading}
size="lg" size="lg"
onClick={handleSubmit} onClick={handleSubmit}
isLoading={loading}
disabled={loading}
className="mt-2"
> >
{loading ? "修改中..." : "立即修改账号密码"} {loading ? "修改中..." : "立即修改账号密码"}
</Button> </Button>
<div className="bg-warning-50 border border-warning-200 text-warning-700 px-3 py-2 rounded-lg text-sm text-center"> <div className="bg-warning-50 border border-warning-200 text-warning-700 px-3 py-2 rounded-lg text-sm text-center">
⚠️ 注意:修改账号密码后需要重新登录 ⚠️ 注意:修改账号密码后需要重新登录
</div> </div>
@@ -197,4 +216,4 @@ export default function ChangePasswordPage() {
</section> </section>
</DefaultLayout> </DefaultLayout>
); );
} }
+227 -214
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@heroui/card";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
@@ -7,23 +7,27 @@ import { Spinner } from "@heroui/spinner";
import { Divider } from "@heroui/divider"; import { Divider } from "@heroui/divider";
import { Switch } from "@heroui/switch"; import { Switch } from "@heroui/switch";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@heroui/select";
import toast from 'react-hot-toast'; import toast from "react-hot-toast";
import { updateConfigs } from '@/api';
import { SettingsIcon } from '@/components/icons';
import { isAdmin } from '@/utils/auth'; import { updateConfigs } from "@/api";
import { getCachedConfigs, clearConfigCache, updateSiteConfig } from '@/config/site'; import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import {
getCachedConfigs,
clearConfigCache,
updateSiteConfig,
} from "@/config/site";
// 简单的保存图标组件 // 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => ( const SaveIcon = ({ className }: { className?: string }) => (
<svg <svg
className={className} className={className}
viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
> >
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" /> <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17,21 17,13 7,13 7,21" /> <polyline points="17,21 17,13 7,13 7,21" />
@@ -36,7 +40,7 @@ interface ConfigItem {
label: string; label: string;
placeholder?: string; placeholder?: string;
description?: string; description?: string;
type: 'input' | 'switch' | 'select'; type: "input" | "switch" | "select";
options?: { label: string; value: string; description?: string }[]; options?: { label: string; value: string; description?: string }[];
dependsOn?: string; // 依赖的配置项key dependsOn?: string; // 依赖的配置项key
dependsValue?: string; // 依赖的配置项值 dependsValue?: string; // 依赖的配置项值
@@ -45,96 +49,102 @@ interface ConfigItem {
// 网站配置项定义 // 网站配置项定义
const CONFIG_ITEMS: ConfigItem[] = [ const CONFIG_ITEMS: ConfigItem[] = [
{ {
key: 'ip', key: "ip",
label: '面板后端地址', label: "面板后端地址",
placeholder: '请输入面板后端IP:PORT', placeholder: "请输入面板后端IP:PORT",
description: '格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密', description:
type: 'input' "格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
type: "input",
}, },
{ {
key: 'app_name', key: "app_name",
label: '应用名称', label: "应用名称",
placeholder: '请输入应用名称', placeholder: "请输入应用名称",
description: '在浏览器标签页和导航栏显示的应用名称', description: "在浏览器标签页和导航栏显示的应用名称",
type: 'input' type: "input",
}, },
{ {
key: 'captcha_enabled', key: "captcha_enabled",
label: '启用验证码', label: "启用验证码",
description: '开启后,用户登录时需要完成验证码验证', description: "开启后,用户登录时需要完成验证码验证",
type: 'switch' type: "switch",
}, },
{ {
key: 'captcha_type', key: "captcha_type",
label: '验证码类型', label: "验证码类型",
description: '选择验证码的显示类型,不同类型有不同的安全级别', description: "选择验证码的显示类型,不同类型有不同的安全级别",
type: 'select', type: "select",
dependsOn: 'captcha_enabled', dependsOn: "captcha_enabled",
dependsValue: 'true', dependsValue: "true",
options: [ options: [
{ {
label: '随机类型', label: "随机类型",
value: 'RANDOM', value: "RANDOM",
description: '系统随机选择验证码类型' description: "系统随机选择验证码类型",
}, },
{ {
label: '滑块验证码', label: "滑块验证码",
value: 'SLIDER', value: "SLIDER",
description: '拖动滑块完成拼图验证' description: "拖动滑块完成拼图验证",
}, },
{ {
label: '文字点选验证码', label: "文字点选验证码",
value: 'WORD_IMAGE_CLICK', value: "WORD_IMAGE_CLICK",
description: '按顺序点击指定文字' description: "按顺序点击指定文字",
}, },
{ {
label: '旋转验证码', label: "旋转验证码",
value: 'ROTATE', value: "ROTATE",
description: '旋转图片到正确角度' description: "旋转图片到正确角度",
}, },
{ {
label: '拼图验证码', label: "拼图验证码",
value: 'CONCAT', value: "CONCAT",
description: '拖动滑块完成图片拼接' description: "拖动滑块完成图片拼接",
} },
] ],
} },
]; ];
// 初始化时从缓存读取配置,避免闪烁 // 初始化时从缓存读取配置,避免闪烁
const getInitialConfigs = (): Record<string, string> => { const getInitialConfigs = (): Record<string, string> => {
if (typeof window === 'undefined') return {}; if (typeof window === "undefined") return {};
const configKeys = ['app_name', 'captcha_enabled', 'captcha_type', 'ip']; const configKeys = ["app_name", "captcha_enabled", "captcha_type", "ip"];
const initialConfigs: Record<string, string> = {}; const initialConfigs: Record<string, string> = {};
try { try {
configKeys.forEach(key => { configKeys.forEach((key) => {
const cachedValue = localStorage.getItem('vite_config_' + key); const cachedValue = localStorage.getItem("vite_config_" + key);
if (cachedValue) { if (cachedValue) {
initialConfigs[key] = cachedValue; initialConfigs[key] = cachedValue;
} }
}); });
} catch (error) { } catch (error) {}
}
return initialConfigs; return initialConfigs;
}; };
export default function ConfigPage() { export default function ConfigPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const initialConfigs = getInitialConfigs(); const initialConfigs = getInitialConfigs();
const [configs, setConfigs] = useState<Record<string, string>>(initialConfigs); const [configs, setConfigs] =
const [loading, setLoading] = useState(Object.keys(initialConfigs).length === 0); // 如果有缓存数据,不显示loading useState<Record<string, string>>(initialConfigs);
const [loading, setLoading] = useState(
Object.keys(initialConfigs).length === 0,
); // 如果有缓存数据,不显示loading
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [originalConfigs, setOriginalConfigs] = useState<Record<string, string>>(initialConfigs); const [originalConfigs, setOriginalConfigs] =
useState<Record<string, string>>(initialConfigs);
// 权限检查 // 权限检查
useEffect(() => { useEffect(() => {
if (!isAdmin()) { if (!isAdmin()) {
toast.error('权限不足,只有管理员可以访问此页面'); toast.error("权限不足,只有管理员可以访问此页面");
navigate('/dashboard', { replace: true }); navigate("/dashboard", { replace: true });
return; return;
} }
}, [navigate]); }, [navigate]);
@@ -143,17 +153,19 @@ export default function ConfigPage() {
const loadConfigs = async (currentConfigs?: Record<string, string>) => { const loadConfigs = async (currentConfigs?: Record<string, string>) => {
const configsToCompare = currentConfigs || configs; const configsToCompare = currentConfigs || configs;
const hasInitialData = Object.keys(configsToCompare).length > 0; const hasInitialData = Object.keys(configsToCompare).length > 0;
// 如果已有缓存数据,不显示loading,静默更新 // 如果已有缓存数据,不显示loading,静默更新
if (!hasInitialData) { if (!hasInitialData) {
setLoading(true); setLoading(true);
} }
try { try {
const configData = await getCachedConfigs(); const configData = await getCachedConfigs();
// 只有在数据有变化时才更新 // 只有在数据有变化时才更新
const hasDataChanged = JSON.stringify(configData) !== JSON.stringify(configsToCompare); const hasDataChanged =
JSON.stringify(configData) !== JSON.stringify(configsToCompare);
if (hasDataChanged) { if (hasDataChanged) {
setConfigs(configData); setConfigs(configData);
setOriginalConfigs({ ...configData }); setOriginalConfigs({ ...configData });
@@ -163,7 +175,7 @@ export default function ConfigPage() {
} catch (error) { } catch (error) {
// 只有在没有缓存数据时才显示错误 // 只有在没有缓存数据时才显示错误
if (!hasInitialData) { if (!hasInitialData) {
toast.error('加载配置出错,请重试'); toast.error("加载配置出错,请重试");
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -182,22 +194,25 @@ export default function ConfigPage() {
// 处理配置项变更 // 处理配置项变更
const handleConfigChange = (key: string, value: string) => { const handleConfigChange = (key: string, value: string) => {
let newConfigs = { ...configs, [key]: value }; let newConfigs = { ...configs, [key]: value };
// 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机 // 特殊处理:启用验证码时,如果验证码类型未设置,默认为随机
if (key === 'captcha_enabled' && value === 'true') { if (key === "captcha_enabled" && value === "true") {
if (!newConfigs.captcha_type) { if (!newConfigs.captcha_type) {
newConfigs.captcha_type = 'RANDOM'; newConfigs.captcha_type = "RANDOM";
} }
} }
setConfigs(newConfigs); setConfigs(newConfigs);
// 检查是否有变更 // 检查是否有变更
const hasChangesNow = Object.keys(newConfigs).some( const hasChangesNow =
k => newConfigs[k] !== originalConfigs[k] Object.keys(newConfigs).some(
) || Object.keys(originalConfigs).some( (k) => newConfigs[k] !== originalConfigs[k],
k => originalConfigs[k] !== newConfigs[k] ) ||
); Object.keys(originalConfigs).some(
(k) => originalConfigs[k] !== newConfigs[k],
);
setHasChanges(hasChangesNow); setHasChanges(hasChangesNow);
}; };
@@ -206,112 +221,115 @@ export default function ConfigPage() {
setSaving(true); setSaving(true);
try { try {
const response = await updateConfigs(configs); const response = await updateConfigs(configs);
if (response.code === 0) { if (response.code === 0) {
toast.success('配置保存成功'); toast.success("配置保存成功");
// 清除所有配置缓存,强制下次重新获取 // 清除所有配置缓存,强制下次重新获取
clearConfigCache(); clearConfigCache();
// 获取变更的配置项 // 获取变更的配置项
const changedKeys = Object.keys(configs).filter( const changedKeys = Object.keys(configs).filter(
key => configs[key] !== originalConfigs[key] (key) => configs[key] !== originalConfigs[key],
); );
setOriginalConfigs({ ...configs }); setOriginalConfigs({ ...configs });
setHasChanges(false); setHasChanges(false);
// 如果应用名称发生变化,立即更新网站配置 // 如果应用名称发生变化,立即更新网站配置
if (changedKeys.includes('app_name')) { if (changedKeys.includes("app_name")) {
await updateSiteConfig(); await updateSiteConfig();
} }
// 触发配置更新事件,通知其他组件 // 触发配置更新事件,通知其他组件
window.dispatchEvent(new CustomEvent('configUpdated', { window.dispatchEvent(
detail: { changedKeys } new CustomEvent("configUpdated", {
})); detail: { changedKeys },
}),
);
} else { } else {
toast.error('保存配置失败: ' + response.msg); toast.error("保存配置失败: " + response.msg);
} }
} catch (error) { } catch (error) {
toast.error('保存配置出错,请重试'); toast.error("保存配置出错,请重试");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
// 检查配置项是否应该显示(依赖检查) // 检查配置项是否应该显示(依赖检查)
const shouldShowItem = (item: ConfigItem): boolean => { const shouldShowItem = (item: ConfigItem): boolean => {
if (!item.dependsOn || !item.dependsValue) { if (!item.dependsOn || !item.dependsValue) {
return true; return true;
} }
return configs[item.dependsOn] === item.dependsValue; return configs[item.dependsOn] === item.dependsValue;
}; };
// 渲染不同类型的配置项 // 渲染不同类型的配置项
const renderConfigItem = (item: ConfigItem) => { const renderConfigItem = (item: ConfigItem) => {
const isChanged = hasChanges && configs[item.key] !== originalConfigs[item.key]; const isChanged =
hasChanges && configs[item.key] !== originalConfigs[item.key];
switch (item.type) { switch (item.type) {
case 'input': case "input":
return ( return (
<Input <Input
value={configs[item.key] || ''}
onChange={(e) => handleConfigChange(item.key, e.target.value)}
placeholder={item.placeholder}
variant="bordered"
size="md"
classNames={{ classNames={{
input: "text-sm", input: "text-sm",
inputWrapper: isChanged inputWrapper: isChanged
? "border-warning-300 data-[hover=true]:border-warning-400" ? "border-warning-300 data-[hover=true]:border-warning-400"
: "" : "",
}} }}
placeholder={item.placeholder}
size="md"
value={configs[item.key] || ""}
variant="bordered"
onChange={(e) => handleConfigChange(item.key, e.target.value)}
/> />
); );
case 'switch': case "switch":
return ( return (
<Switch <Switch
isSelected={configs[item.key] === 'true'}
onValueChange={(checked) => handleConfigChange(item.key, checked ? 'true' : 'false')}
color="primary"
size="md"
classNames={{ classNames={{
wrapper: isChanged ? "border-warning-300" : "" wrapper: isChanged ? "border-warning-300" : "",
}} }}
color="primary"
isSelected={configs[item.key] === "true"}
size="md"
onValueChange={(checked) =>
handleConfigChange(item.key, checked ? "true" : "false")
}
> >
<span className="text-sm text-gray-700 dark:text-gray-300"> <span className="text-sm text-gray-700 dark:text-gray-300">
{configs[item.key] === 'true' ? '已启用' : '已禁用'} {configs[item.key] === "true" ? "已启用" : "已禁用"}
</span> </span>
</Switch> </Switch>
); );
case 'select': case "select":
return ( return (
<Select <Select
classNames={{
trigger: isChanged
? "border-warning-300 data-[hover=true]:border-warning-400"
: "",
}}
placeholder="请选择验证码类型"
selectedKeys={configs[item.key] ? [configs[item.key]] : []} selectedKeys={configs[item.key] ? [configs[item.key]] : []}
size="md"
variant="bordered"
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string; const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) { if (selectedKey) {
handleConfigChange(item.key, selectedKey); handleConfigChange(item.key, selectedKey);
} }
}} }}
placeholder="请选择验证码类型"
variant="bordered"
size="md"
classNames={{
trigger: isChanged
? "border-warning-300 data-[hover=true]:border-warning-400"
: ""
}}
> >
{item.options?.map((option) => ( {item.options?.map((option) => (
<SelectItem <SelectItem key={option.value} description={option.description}>
key={option.value}
description={option.description}
>
{option.label} {option.label}
</SelectItem> </SelectItem>
)) || []} )) || []}
@@ -325,105 +343,100 @@ export default function ConfigPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center justify-center min-h-[400px]"> <Spinner label="加载配置中..." size="lg" />
<Spinner size="lg" label="加载配置中..." /> </div>
</div>
); );
} }
return ( return (
<div className="p-6 max-w-4xl mx-auto">
<div className="p-6 max-w-4xl mx-auto"> {/* 页面标题 */}
{/* 页面标题 */} <div className="flex items-center gap-3 mb-6">
<div className="flex items-center gap-3 mb-6"> <SettingsIcon className="w-8 h-8 text-primary" />
<SettingsIcon className="w-8 h-8 text-primary" /> <div>
<div> <h1 className="text-2xl font-bold">网站配置</h1>
<h1 className="text-2xl font-bold">网站配置</h1> <p className="text-gray-600 dark:text-gray-400">
<p className="text-gray-600 dark:text-gray-400"> 管理网站的基本信息和显示设置
管理网站的基本信息和显示设置 </p>
</p>
</div>
</div> </div>
</div>
<Card className="shadow-md"> <Card className="shadow-md">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex justify-between items-center w-full"> <div className="flex justify-between items-center w-full">
<div> <div>
<h2 className="text-xl font-semibold">基本设置</h2> <h2 className="text-xl font-semibold">基本设置</h2>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
配置网站的基本信息,这些设置会影响网站的显示效果 配置网站的基本信息,这些设置会影响网站的显示效果
</p> </p>
</div>
<div className="flex gap-2">
<Button
color="primary"
startContent={<SaveIcon className="w-4 h-4" />}
onClick={handleSave}
isLoading={saving}
disabled={!hasChanges}
>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
</div> </div>
</CardHeader> <div className="flex gap-2">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onClick={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
</CardHeader>
<Divider /> <Divider />
<CardBody className="space-y-6 pt-6"> <CardBody className="space-y-6 pt-6">
{CONFIG_ITEMS.map((item, index) => { {CONFIG_ITEMS.map((item, index) => {
// 检查配置项是否应该显示 // 检查配置项是否应该显示
if (!shouldShowItem(item)) { if (!shouldShowItem(item)) {
return null; return null;
} }
// 计算是否是最后一个显示的项目(用于决定是否显示分隔线) // 计算是否是最后一个显示的项目(用于决定是否显示分隔线)
const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(shouldShowItem); const remainingItems = CONFIG_ITEMS.slice(index + 1).filter(
const isLastItem = remainingItems.length === 0; shouldShowItem,
);
const isLastItem = remainingItems.length === 0;
return ( return (
<div key={item.key} className="space-y-3"> <div key={item.key} className="space-y-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{item.label} {item.label}
</label> </label>
{item.description && ( {item.description && (
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{item.description} {item.description}
</p> </p>
)}
</div>
{/* 渲染配置项 */}
{renderConfigItem(item)}
{/* 分隔线 */}
{!isLastItem && (
<Divider className="mt-6" />
)} )}
</div> </div>
);
})} {/* 渲染配置项 */}
{renderConfigItem(item)}
{/* 分隔线 */}
{!isLastItem && <Divider className="mt-6" />}
</div>
);
})}
</CardBody>
</Card>
{/* 操作提示 */}
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<CardBody className="py-3">
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
<span className="text-sm">
检测到配置变更,请记得保存您的修改
</span>
</div>
</CardBody> </CardBody>
</Card> </Card>
)}
{/* 操作提示 */} </div>
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<CardBody className="py-3">
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
<span className="text-sm">
检测到配置变更,请记得保存您的修改
</span>
</div>
</CardBody>
</Card>
)}
</div>
); );
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+96 -91
View File
@@ -3,10 +3,11 @@ import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast'; import toast from "react-hot-toast";
import axios from 'axios'; import axios from "axios";
import { isWebViewFunc } from '@/utils/panel';
import { siteConfig } from '@/config/site'; import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives"; import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default"; import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha } from "@/api"; import { login, LoginData, checkCaptcha } from "@/api";
@@ -14,15 +15,12 @@ import "@/utils/tac.css";
import "@/utils/tac.min.js"; import "@/utils/tac.min.js";
import bgImage from "@/images/bg.jpg"; import bgImage from "@/images/bg.jpg";
interface LoginForm { interface LoginForm {
username: string; username: string;
password: string; password: string;
captchaId: string; captchaId: string;
} }
interface CaptchaConfig { interface CaptchaConfig {
requestCaptchaDataUrl: string; requestCaptchaDataUrl: string;
validCaptchaUrl: string; validCaptchaUrl: string;
@@ -54,6 +52,7 @@ export default function IndexPage() {
const tacInstanceRef = useRef<any>(null); const tacInstanceRef = useRef<any>(null);
const captchaContainerRef = useRef<HTMLDivElement>(null); const captchaContainerRef = useRef<HTMLDivElement>(null);
const [isWebView, setIsWebView] = useState(false); const [isWebView, setIsWebView] = useState(false);
// 清理验证码实例 // 清理验证码实例
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -72,26 +71,26 @@ export default function IndexPage() {
const newErrors: Partial<LoginForm> = {}; const newErrors: Partial<LoginForm> = {};
if (!form.username.trim()) { if (!form.username.trim()) {
newErrors.username = '请输入用户名'; newErrors.username = "请输入用户名";
} }
if (!form.password.trim()) { if (!form.password.trim()) {
newErrors.password = '请输入密码'; newErrors.password = "请输入密码";
} else if (form.password.length < 6) { } else if (form.password.length < 6) {
newErrors.password = '密码长度至少6位'; newErrors.password = "密码长度至少6位";
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
// 处理输入变化 // 处理输入变化
const handleInputChange = (field: keyof LoginForm, value: string) => { const handleInputChange = (field: keyof LoginForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
// 清除该字段的错误 // 清除该字段的错误
if (errors[field]) { if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined })); setErrors((prev) => ({ ...prev, [field]: undefined }));
} }
}; };
@@ -109,16 +108,18 @@ export default function IndexPage() {
} }
// 使用axios的baseURL,确保在WebView中使用正确的面板地址 // 使用axios的baseURL,确保在WebView中使用正确的面板地址
const baseURL = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/'); const baseURL =
axios.defaults.baseURL ||
(import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/");
const config: CaptchaConfig = { const config: CaptchaConfig = {
requestCaptchaDataUrl: `${baseURL}captcha/generate`, requestCaptchaDataUrl: `${baseURL}captcha/generate`,
validCaptchaUrl: `${baseURL}captcha/verify`, validCaptchaUrl: `${baseURL}captcha/verify`,
bindEl: "#captcha-container", bindEl: "#captcha-container",
validSuccess: (res: any, _: any, tac: any) => { validSuccess: (res: any, _: any, tac: any) => {
form.captchaId = res.data.validToken;
form.captchaId = res.data.validToken
setShowCaptcha(false); setShowCaptcha(false);
tac.destroyWindow(); tac.destroyWindow();
@@ -134,30 +135,30 @@ export default function IndexPage() {
}, },
btnRefreshFun: (_event: any, tac: any) => { btnRefreshFun: (_event: any, tac: any) => {
tac.reloadCaptcha(); tac.reloadCaptcha();
} },
}; };
// 检测暗黑模式 // 检测暗黑模式
const isDarkMode = document.documentElement.classList.contains('dark') || const isDarkMode =
document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains("dark") ||
window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.getAttribute("data-theme") === "dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches;
// 根据主题调整颜色 // 根据主题调整颜色
const trackColor = isDarkMode ? "#4a5568" : "#7db0be"; // 暗黑模式使用更深的灰蓝色 const trackColor = isDarkMode ? "#4a5568" : "#7db0be"; // 暗黑模式使用更深的灰蓝色
const style: CaptchaStyle = { const style: CaptchaStyle = {
bgUrl: bgImage, bgUrl: bgImage,
logoUrl: null, logoUrl: null,
moveTrackMaskBgColor: trackColor, moveTrackMaskBgColor: trackColor,
moveTrackMaskBorderColor: trackColor moveTrackMaskBorderColor: trackColor,
}; };
tacInstanceRef.current = new window.TAC(config, style); tacInstanceRef.current = new window.TAC(config, style);
tacInstanceRef.current.init(); tacInstanceRef.current.init();
} catch (error) { } catch (error) {
console.error('初始化验证码失败:', error); console.error("初始化验证码失败:", error);
toast.error('验证码初始化失败,请刷新页面重试'); toast.error("验证码初始化失败,请刷新页面重试");
setShowCaptcha(false); setShowCaptcha(false);
setLoading(false); setLoading(false);
} }
@@ -165,8 +166,6 @@ export default function IndexPage() {
// 执行登录请求 // 执行登录请求
const performLogin = async () => { const performLogin = async () => {
try { try {
const loginData: LoginData = { const loginData: LoginData = {
username: form.username.trim(), username: form.username.trim(),
@@ -175,35 +174,36 @@ export default function IndexPage() {
}; };
const response = await login(loginData); const response = await login(loginData);
if (response.code !== 0) { if (response.code !== 0) {
toast.error(response.msg || "登录失败"); toast.error(response.msg || "登录失败");
return; return;
} }
// 检查是否需要强制修改密码 // 检查是否需要强制修改密码
if (response.data.requirePasswordChange) { if (response.data.requirePasswordChange) {
localStorage.setItem('token', response.data.token); localStorage.setItem("token", response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString()); localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name); localStorage.setItem("name", response.data.name);
localStorage.setItem("admin", (response.data.role_id === 0).toString()); localStorage.setItem("admin", (response.data.role_id === 0).toString());
toast.success('检测到默认密码,即将跳转到修改密码页面'); toast.success("检测到默认密码,即将跳转到修改密码页面");
navigate("/change-password"); navigate("/change-password");
return; return;
} }
// 保存登录信息 // 保存登录信息
localStorage.setItem('token', response.data.token); localStorage.setItem("token", response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString()); localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name); localStorage.setItem("name", response.data.name);
localStorage.setItem("admin", (response.data.role_id === 0).toString()); localStorage.setItem("admin", (response.data.role_id === 0).toString());
// 登录成功 // 登录成功
toast.success('登录成功'); toast.success("登录成功");
navigate("/dashboard"); navigate("/dashboard");
} catch (error) { } catch (error) {
console.error('登录错误:', error); console.error("登录错误:", error);
toast.error("网络错误,请稍后重试"); toast.error("网络错误,请稍后重试");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -218,10 +218,11 @@ export default function IndexPage() {
try { try {
// 先检查是否需要验证码 // 先检查是否需要验证码
const checkResponse = await checkCaptcha(); const checkResponse = await checkCaptcha();
if (checkResponse.code !== 0) { if (checkResponse.code !== 0) {
toast.error("检查验证码状态失败,请重试" + checkResponse.msg); toast.error("检查验证码状态失败,请重试" + checkResponse.msg);
setLoading(false); setLoading(false);
return; return;
} }
@@ -238,15 +239,14 @@ export default function IndexPage() {
}, 100); }, 100);
} }
} catch (error) { } catch (error) {
console.error('检查验证码状态错误:', error); console.error("检查验证码状态错误:", error);
toast.error("网络错误,请稍后重试" + error); toast.error("网络错误,请稍后重试" + error);
setLoading(false); setLoading(false);
} }
}; };
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) { if (e.key === "Enter" && !loading) {
handleLogin(); handleLogin();
} }
}; };
@@ -258,42 +258,47 @@ export default function IndexPage() {
<Card className="w-full"> <Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center"> <CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1> <h1 className={title({ size: "sm" })}>登陆</h1>
<p className="text-small text-default-500 mt-2">请输入您的账号信息</p> <p className="text-small text-default-500 mt-2">
请输入您的账号信息
</p>
</CardHeader> </CardHeader>
<CardBody className="px-6 py-6"> <CardBody className="px-6 py-6">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Input <Input
errorMessage={errors.username}
isDisabled={loading}
isInvalid={!!errors.username}
label="用户名" label="用户名"
placeholder="请输入用户名" placeholder="请输入用户名"
value={form.username} value={form.username}
onChange={(e) => handleInputChange('username', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered" variant="bordered"
isDisabled={loading} onChange={(e) =>
isInvalid={!!errors.username} handleInputChange("username", e.target.value)
errorMessage={errors.username} }
onKeyDown={handleKeyPress}
/> />
<Input <Input
isDisabled={loading}
isInvalid={!!errors.password}
label="密码" label="密码"
placeholder="请输入密码" placeholder="请输入密码"
type="password" type="password"
value={form.password} value={form.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered" variant="bordered"
isDisabled={loading} onChange={(e) =>
isInvalid={!!errors.password} handleInputChange("password", e.target.value)
}
onKeyDown={handleKeyPress}
/> />
<Button <Button
className="mt-2"
color="primary" color="primary"
disabled={loading}
isLoading={loading}
size="lg" size="lg"
onClick={handleLogin} onClick={handleLogin}
isLoading={loading}
disabled={loading}
className="mt-2"
> >
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"} {loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
</Button> </Button>
@@ -302,47 +307,47 @@ export default function IndexPage() {
</Card> </Card>
</div> </div>
{/* 版权信息 - 固定在底部,不占据布局空间 */}
{/* 版权信息 - 固定在底部,不占据布局空间 */} <div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
<div className="fixed inset-x-0 bottom-4 text-center py-4"> Powered by{" "}
<p className="text-xs text-gray-400 dark:text-gray-500"> <a
Powered by{' '} className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
<a href={siteConfig.github_repo}
href="https://github.com/bqlpfy/flux-panel" rel="noopener noreferrer"
target="_blank" target="_blank"
rel="noopener noreferrer" >
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" flux-panel
> </a>
flux-panel </p>
</a> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
</p> v{isWebView ? siteConfig.app_version : siteConfig.version}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> </p>
v{ isWebView ? siteConfig.app_version : siteConfig.version} </div>
</p>
</div>
{/* 验证码弹层 */} {/* 验证码弹层 */}
{showCaptcha && ( {showCaptcha && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */} {/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
<div className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" /> <div className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" />
{/* 验证码容器 */} {/* 验证码容器 */}
<div className="mb-4"> <div className="mb-4">
<div <div
id="captcha-container" ref={captchaContainerRef}
ref={captchaContainerRef} className="w-full flex justify-center"
className="w-full flex justify-center" id="captcha-container"
style={{ style={{
filter: document.documentElement.classList.contains('dark') || filter:
document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains("dark") ||
window.matchMedia('(prefers-color-scheme: dark)').matches document.documentElement.getAttribute("data-theme") ===
? 'brightness(0.8) contrast(0.9)' : 'none' "dark" ||
}} window.matchMedia("(prefers-color-scheme: dark)").matches
/> ? "brightness(0.8) contrast(0.9)"
</div> : "none",
}}
/>
</div>
</div> </div>
)} )}
</section> </section>
+319 -275
View File
@@ -3,18 +3,23 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@heroui/select";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal"; import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@heroui/modal";
import { Chip } from "@heroui/chip"; import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@heroui/spinner";
import toast from 'react-hot-toast'; import toast from "react-hot-toast";
import {
import { createSpeedLimit,
createSpeedLimit, getSpeedLimitList,
getSpeedLimitList, updateSpeedLimit,
updateSpeedLimit, deleteSpeedLimit,
deleteSpeedLimit, getTunnelList,
getTunnelList
} from "@/api"; } from "@/api";
interface SpeedLimitRule { interface SpeedLimitRule {
@@ -46,7 +51,7 @@ export default function LimitPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]); const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]); const [tunnels, setTunnels] = useState<Tunnel[]>([]);
// 模态框状态 // 模态框状态
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@@ -54,18 +59,18 @@ export default function LimitPage() {
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<SpeedLimitRule | null>(null); const [ruleToDelete, setRuleToDelete] = useState<SpeedLimitRule | null>(null);
// 表单状态 // 表单状态
const [form, setForm] = useState<SpeedLimitForm>({ const [form, setForm] = useState<SpeedLimitForm>({
name: '', name: "",
speed: 100, speed: 100,
tunnelId: null, tunnelId: null,
tunnelName: '', tunnelName: "",
status: 1 status: 1,
}); });
// 表单验证错误 // 表单验证错误
const [errors, setErrors] = useState<{[key: string]: string}>({}); const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -77,23 +82,23 @@ export default function LimitPage() {
try { try {
const [rulesRes, tunnelsRes] = await Promise.all([ const [rulesRes, tunnelsRes] = await Promise.all([
getSpeedLimitList(), getSpeedLimitList(),
getTunnelList() getTunnelList(),
]); ]);
if (rulesRes.code === 0) { if (rulesRes.code === 0) {
setRules(rulesRes.data || []); setRules(rulesRes.data || []);
} else { } else {
toast.error(rulesRes.msg || '获取限速规则失败'); toast.error(rulesRes.msg || "获取限速规则失败");
} }
if (tunnelsRes.code === 0) { if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []); setTunnels(tunnelsRes.data || []);
} else { } else {
console.warn('获取隧道列表失败:', tunnelsRes.msg); console.warn("获取隧道列表失败:", tunnelsRes.msg);
} }
} catch (error) { } catch (error) {
console.error('加载数据失败:', error); console.error("加载数据失败:", error);
toast.error('加载数据失败'); toast.error("加载数据失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -101,23 +106,24 @@ export default function LimitPage() {
// 表单验证 // 表单验证
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: {[key: string]: string} = {}; const newErrors: { [key: string]: string } = {};
if (!form.name.trim()) { if (!form.name.trim()) {
newErrors.name = '请输入规则名称'; newErrors.name = "请输入规则名称";
} else if (form.name.length < 2 || form.name.length > 50) { } else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = '规则名称长度应在2-50个字符之间'; newErrors.name = "规则名称长度应在2-50个字符之间";
} }
if (!form.speed || form.speed < 1) { if (!form.speed || form.speed < 1) {
newErrors.speed = '请输入有效的速度限制(≥1 Mbps)'; newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
} }
if (!form.tunnelId) { if (!form.tunnelId) {
newErrors.tunnelId = '请选择要绑定的隧道'; newErrors.tunnelId = "请选择要绑定的隧道";
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
@@ -125,11 +131,11 @@ export default function LimitPage() {
const handleAdd = () => { const handleAdd = () => {
setIsEdit(false); setIsEdit(false);
setForm({ setForm({
name: '', name: "",
speed: 100, speed: 100,
tunnelId: null, tunnelId: null,
tunnelName: '', tunnelName: "",
status: 1 status: 1,
}); });
setErrors({}); setErrors({});
setModalOpen(true); setModalOpen(true);
@@ -144,7 +150,7 @@ export default function LimitPage() {
speed: rule.speed, speed: rule.speed,
tunnelId: rule.tunnelId, tunnelId: rule.tunnelId,
tunnelName: rule.tunnelName, tunnelName: rule.tunnelName,
status: rule.status status: rule.status,
}); });
setErrors({}); setErrors({});
setModalOpen(true); setModalOpen(true);
@@ -159,20 +165,21 @@ export default function LimitPage() {
// 确认删除规则 // 确认删除规则
const confirmDelete = async () => { const confirmDelete = async () => {
if (!ruleToDelete) return; if (!ruleToDelete) return;
setDeleteLoading(true); setDeleteLoading(true);
try { try {
const res = await deleteSpeedLimit(ruleToDelete.id); const res = await deleteSpeedLimit(ruleToDelete.id);
if (res.code === 0) { if (res.code === 0) {
toast.success('删除成功'); toast.success("删除成功");
setDeleteModalOpen(false); setDeleteModalOpen(false);
loadData(); loadData();
} else { } else {
toast.error(res.msg || '删除失败'); toast.error(res.msg || "删除失败");
} }
} catch (error) { } catch (error) {
console.error('删除失败:', error); console.error("删除失败:", error);
toast.error('删除失败'); toast.error("删除失败");
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
} }
@@ -181,27 +188,29 @@ export default function LimitPage() {
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validateForm()) return; if (!validateForm()) return;
setSubmitLoading(true); setSubmitLoading(true);
try { try {
let res; let res;
if (isEdit) { if (isEdit) {
res = await updateSpeedLimit(form); res = await updateSpeedLimit(form);
} else { } else {
const { id, ...createData } = form; const { id, ...createData } = form;
res = await createSpeedLimit(createData); res = await createSpeedLimit(createData);
} }
if (res.code === 0) { if (res.code === 0) {
toast.success(isEdit ? '修改成功' : '创建成功'); toast.success(isEdit ? "修改成功" : "创建成功");
setModalOpen(false); setModalOpen(false);
loadData(); loadData();
} else { } else {
toast.error(res.msg || '操作失败'); toast.error(res.msg || "操作失败");
} }
} catch (error) { } catch (error) {
console.error('提交失败:', error); console.error("提交失败:", error);
toast.error('操作失败'); toast.error("操作失败");
} finally { } finally {
setSubmitLoading(false); setSubmitLoading(false);
} }
@@ -209,269 +218,304 @@ export default function LimitPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64">
<div className="flex items-center justify-center h-64"> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <Spinner size="sm" />
<Spinner size="sm" /> <span className="text-default-600">正在加载...</span>
<span className="text-default-600">正在加载...</span>
</div>
</div> </div>
</div>
); );
} }
return ( return (
<div className="px-3 lg:px-6 py-8">
<div className="px-3 lg:px-6 py-8"> {/* 页面头部 */}
{/* 页面头部 */} <div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-6"> <div className="flex-1" />
<div className="flex-1">
</div>
<Button <Button size="sm" variant="flat" color="primary" onPress={handleAdd}>
size="sm" 新增
variant="flat" </Button>
color="primary" </div>
onPress={handleAdd}
{/* 统一卡片网格 */}
{rules.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{rules.map((rule) => (
<Card
key={rule.id}
className="shadow-sm border border-gray-200 dark:border-gray-700"
> >
新增 <CardHeader className="pb-3">
</Button> <div className="flex justify-between items-start w-full">
</div> <div>
<h3 className="font-semibold text-foreground">
{/* 统一卡片网格 */} {rule.name}
{rules.length > 0 ? ( </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"> </div>
{rules.map((rule) => ( <Chip
<Card key={rule.id} className="shadow-sm border border-gray-200 dark:border-gray-700"> color={rule.status === 1 ? "success" : "danger"}
<CardHeader className="pb-3"> variant="flat"
<div className="flex justify-between items-start w-full"> size="sm"
<div> >
<h3 className="font-semibold text-foreground">{rule.name}</h3> {rule.status === 1 ? "运行" : "异常"}
</div> </Chip>
<Chip </div>
color={rule.status === 1 ? "success" : "danger"} </CardHeader>
variant="flat" <CardBody className="pt-0">
size="sm" <div className="space-y-3">
> <div className="flex justify-between items-center">
{rule.status === 1 ? '运行' : '异常'} <span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip> </Chip>
</div> </div>
</CardHeader> <div className="flex justify-between items-center">
<CardBody className="pt-0"> <span className="text-small text-default-600">
<div className="space-y-3"> 绑定隧道
<div className="flex justify-between items-center"> </span>
<span className="text-small text-default-600">速度限制</span> {rule.tunnelName ? (
<Chip color="secondary" variant="flat" size="sm"> <Chip color="primary" size="sm" variant="flat">
{rule.speed} Mbps {rule.tunnelName}
</Chip> </Chip>
</div> ) : (
<div className="flex justify-between items-center"> <span className="text-default-400 text-small">
<span className="text-small text-default-600">绑定隧道</span> 未绑定
{rule.tunnelName ? ( </span>
<Chip color="primary" variant="flat" size="sm"> )}
{rule.tunnelName}
</Chip>
) : (
<span className="text-default-400 text-small">未绑定</span>
)}
</div>
</div> </div>
</div>
<div className="flex gap-2 mt-4">
<Button <div className="flex gap-2 mt-4">
size="sm" <Button
variant="flat" className="flex-1"
color="primary" color="primary"
onPress={() => handleEdit(rule)} size="sm"
className="flex-1"
startContent={ startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-4 h-4" 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" /> <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> </svg>
} }
>
编辑
</Button>
<Button
size="sm"
variant="flat" variant="flat"
color="danger" onPress={() => handleEdit(rule)}
onPress={() => handleDelete(rule)} >
className="flex-1" 编辑
startContent={ </Button>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <Button
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" /> size="sm"
<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" /> variant="flat"
</svg> color="danger"
} onPress={() => handleDelete(rule)}
> className="flex-1"
删除 startContent={
</Button> <svg
</div> className="w-4 h-4"
</CardBody> fill="currentColor"
</Card> viewBox="0 0 20 20"
))} >
</div> <path
) : ( fillRule="evenodd"
/* 空状态 */ d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
<Card className="shadow-sm border border-gray-200 dark:border-gray-700"> clipRule="evenodd"
<CardBody className="text-center py-16"> />
<div className="flex flex-col items-center gap-4"> <path
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center"> fillRule="evenodd"
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 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"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z" /> clipRule="evenodd"
</svg> />
</div> </svg>
<div> }
<h3 className="text-lg font-semibold text-foreground">暂无限速规则</h3> >
<p className="text-default-500 text-sm mt-1">还没有创建任何限速规则,点击上方按钮开始创建</p> 删除
</Button>
</div> </div>
</CardBody>
</Card>
))}
</div>
) : (
/* 空状态 */
<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="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div> </div>
</CardBody> <div>
</Card> <h3 className="text-lg font-semibold text-foreground">
)} 暂无限速规则
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */} {/* 新增/编辑模态框 */}
<Modal <Modal
isOpen={modalOpen} isOpen={modalOpen}
onOpenChange={setModalOpen} onOpenChange={setModalOpen}
size="2xl" size="2xl"
scrollBehavior="outside" scrollBehavior="outside"
backdrop="blur" backdrop="blur"
placement="center" placement="center"
> >
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader className="flex flex-col gap-1"> <ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold"> <h2 className="text-xl font-bold">
{isEdit ? '编辑限速规则' : '新增限速规则'} {isEdit ? "编辑限速规则" : "新增限速规则"}
</h2> </h2>
<p className="text-small text-default-500"> <p className="text-small text-default-500">
{isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'} {isEdit
</p> ? "修改现有限速规则的配置信息"
</ModalHeader> : "创建新的限速规则并绑定到隧道"}
<ModalBody> </p>
<div className="space-y-4"> </ModalHeader>
<Input <ModalBody>
<div className="space-y-4">
<Input
errorMessage={errors.name}
isInvalid={!!errors.name}
label="规则名称" label="规则名称"
placeholder="请输入限速规则名称" placeholder="请输入限速规则名称"
value={form.name} value={form.name}
variant="bordered"
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name} />
errorMessage={errors.name}
variant="bordered" <Input
/> endContent={
<Input
label="速度限制"
placeholder="请输入速度限制"
type="number"
value={form.speed.toString()}
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
isInvalid={!!errors.speed}
errorMessage={errors.speed}
variant="bordered"
endContent={
<div className="pointer-events-none flex items-center"> <div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">Mbps</span> <span className="text-default-400 text-small">Mbps</span>
</div> </div>
} }
/> errorMessage={errors.speed}
isInvalid={!!errors.speed}
<Select label="速度限制"
label="绑定隧道" placeholder="请输入速度限制"
placeholder="请选择要绑定的隧道" type="number"
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []} value={form.speed.toString()}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(tunnel => tunnel.id === parseInt(selectedKey));
setForm(prev => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || ''
}));
} else {
setForm(prev => ({
...prev,
tunnelId: null,
tunnelName: ''
}));
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered" variant="bordered"
isDisabled={isEdit} onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
description={isEdit ? "编辑时无法修改绑定隧道" : undefined} />
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>
{tunnel.name}
</SelectItem>
))}
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{isEdit ? '保存修改' : '创建规则'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */} <Select
<Modal label="绑定隧道"
isOpen={deleteModalOpen} placeholder="请选择要绑定的隧道"
onOpenChange={setDeleteModalOpen} selectedKeys={
size="2xl" form.tunnelId ? [form.tunnelId.toString()] : []
}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(
(tunnel) => tunnel.id === parseInt(selectedKey),
);
setForm((prev) => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || "",
}));
} else {
setForm((prev) => ({
...prev,
tunnelId: null,
tunnelName: "",
}));
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
isDisabled={isEdit}
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
))}
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{isEdit ? "保存修改" : "创建规则"}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="2xl"
scrollBehavior="outside" scrollBehavior="outside"
backdrop="blur" backdrop="blur"
placement="center" placement="center"
> >
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader className="flex flex-col gap-1"> <ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-danger">确认删除</h2> <h2 className="text-lg font-bold text-danger">确认删除</h2>
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<p className="text-default-600"> <p className="text-default-600">
确定要删除限速规则 <span className="font-semibold text-foreground">"{ruleToDelete?.name}"</span> 吗? 确定要删除限速规则{" "}
</p> <span className="font-semibold text-foreground">
<p className="text-small text-default-500 mt-2"> "{ruleToDelete?.name}"
此操作无法撤销,删除后该规则将永久消失。 </span>{" "}
</p> 吗?
</ModalBody> </p>
<ModalFooter> <p className="text-small text-default-500 mt-2">
<Button variant="light" onPress={onClose}> 此操作无法撤销,删除后该规则将永久消失。
取消 </p>
</Button> </ModalBody>
<Button <ModalFooter>
color="danger" <Button variant="light" onPress={onClose}>
onPress={confirmDelete} 取消
isLoading={deleteLoading} </Button>
> <Button
确认删除 color="danger"
</Button> onPress={confirmDelete}
</ModalFooter> isLoading={deleteLoading}
</> >
)} 确认删除
</ModalContent> </Button>
</Modal> </ModalFooter>
</div> </>
)}
</ModalContent>
</Modal>
</div>
); );
} }
File diff suppressed because it is too large Load Diff
+201 -121
View File
@@ -1,14 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { Card, CardBody } from "@heroui/card"; import { Card, CardBody } from "@heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal"; import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@heroui/modal";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
import { toast } from 'react-hot-toast'; import { toast } from "react-hot-toast";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { isWebViewFunc } from '@/utils/panel';
import { siteConfig } from '@/config/site'; import { isWebViewFunc } from "@/utils/panel";
import { updatePassword } from '@/api'; import { siteConfig } from "@/config/site";
import { safeLogout } from '@/utils/logout'; import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
interface PasswordForm { interface PasswordForm {
newUsername: string; newUsername: string;
currentPassword: string; currentPassword: string;
@@ -16,7 +24,6 @@ interface PasswordForm {
confirmPassword: string; confirmPassword: string;
} }
interface MenuItem { interface MenuItem {
path: string; path: string;
label: string; label: string;
@@ -28,29 +35,31 @@ interface MenuItem {
export default function ProfilePage() { export default function ProfilePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { isOpen, onOpen, onOpenChange } = useDisclosure(); const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false); const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordForm>({ const [passwordForm, setPasswordForm] = useState<PasswordForm>({
newUsername: '', newUsername: "",
currentPassword: '', currentPassword: "",
newPassword: '', newPassword: "",
confirmPassword: '' confirmPassword: "",
}); });
useEffect(() => { useEffect(() => {
// 获取用户信息 // 获取用户信息
const name = localStorage.getItem('name') || 'Admin'; const name = localStorage.getItem("name") || "Admin";
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) // 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem('admin') === 'true'; let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem('admin') === null) {
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10); if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0; adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断 // 补充设置admin字段,避免下次再次判断
localStorage.setItem('admin', adminFlag.toString()); localStorage.setItem("admin", adminFlag.toString());
} }
setUsername(name); setUsername(name);
setIsAdmin(adminFlag); setIsAdmin(adminFlag);
}, []); }, []);
@@ -58,72 +67,89 @@ export default function ProfilePage() {
// 管理员菜单项 // 管理员菜单项
const adminMenuItems: MenuItem[] = [ const adminMenuItems: MenuItem[] = [
{ {
path: '/limit', path: "/limit",
label: '限速管理', label: "限速管理",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
fillRule="evenodd"
/>
</svg> </svg>
), ),
color: 'bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400', color:
description: '管理用户限速策略' "bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400",
description: "管理用户限速策略",
}, },
{ {
path: '/user', path: "/user",
label: '用户管理', label: "用户管理",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" /> <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg> </svg>
), ),
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400', color: "bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400",
description: '管理系统用户' description: "管理系统用户",
}, },
{ {
path: '/config', path: "/config",
label: '网站配置', label: "网站配置",
icon: ( icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" /> <path
clipRule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
fillRule="evenodd"
/>
</svg> </svg>
), ),
color: 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400', color:
description: '配置网站设置' "bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400",
} description: "配置网站设置",
},
]; ];
// 退出登录 // 退出登录
const handleLogout = () => { const handleLogout = () => {
safeLogout(); safeLogout();
navigate('/', { replace: true }); navigate("/", { replace: true });
}; };
// 密码表单验证 // 密码表单验证
const validatePasswordForm = (): boolean => { const validatePasswordForm = (): boolean => {
if (!passwordForm.newUsername.trim()) { if (!passwordForm.newUsername.trim()) {
toast.error('请输入新用户名'); toast.error("请输入新用户名");
return false; return false;
} }
if (passwordForm.newUsername.length < 3) { if (passwordForm.newUsername.length < 3) {
toast.error('用户名长度至少3位'); toast.error("用户名长度至少3位");
return false; return false;
} }
if (!passwordForm.currentPassword) { if (!passwordForm.currentPassword) {
toast.error('请输入当前密码'); toast.error("请输入当前密码");
return false; return false;
} }
if (!passwordForm.newPassword) { if (!passwordForm.newPassword) {
toast.error('请输入新密码'); toast.error("请输入新密码");
return false; return false;
} }
if (passwordForm.newPassword.length < 6) { if (passwordForm.newPassword.length < 6) {
toast.error('新密码长度不能少于6位'); toast.error("新密码长度不能少于6位");
return false; return false;
} }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入密码不一致'); toast.error("两次输入密码不一致");
return false; return false;
} }
return true; return true;
}; };
@@ -134,16 +160,17 @@ export default function ProfilePage() {
setPasswordLoading(true); setPasswordLoading(true);
try { try {
const response = await updatePassword(passwordForm); const response = await updatePassword(passwordForm);
if (response.code === 0) { if (response.code === 0) {
toast.success('密码修改成功,请重新登录'); toast.success("密码修改成功,请重新登录");
onOpenChange(); onOpenChange();
handleLogout(); handleLogout();
} else { } else {
toast.error(response.msg || '密码修改失败'); toast.error(response.msg || "密码修改失败");
} }
} catch (error) { } catch (error) {
toast.error('修改密码时发生错误'); toast.error("修改密码时发生错误");
console.error('修改密码错误:', error); console.error("修改密码错误:", error);
} finally { } finally {
setPasswordLoading(false); setPasswordLoading(false);
} }
@@ -152,38 +179,49 @@ export default function ProfilePage() {
// 重置密码表单 // 重置密码表单
const resetPasswordForm = () => { const resetPasswordForm = () => {
setPasswordForm({ setPasswordForm({
newUsername: '', newUsername: "",
currentPassword: '', currentPassword: "",
newPassword: '', newPassword: "",
confirmPassword: '' confirmPassword: "",
}); });
}; };
return ( return (
<div className="px-3 lg:px-6 py-8 flex flex-col h-full"> <div className="px-3 lg:px-6 py-8 flex flex-col h-full">
<div className="space-y-6 flex-1"> <div className="space-y-6 flex-1">
{/* 用户信息卡片 */} {/* 用户信息卡片 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow"> <Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-4"> <CardBody className="p-4">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-primary" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" /> className="w-6 h-6 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
fillRule="evenodd"
/>
</svg> </svg>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-base font-medium text-foreground">{username}</h3> <h3 className="text-base font-medium text-foreground">
{username}
</h3>
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
<span className={`px-2 py-1 rounded-md text-xs font-medium ${ <span
isAdmin className={`px-2 py-1 rounded-md text-xs font-medium ${
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300' isAdmin
: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' ? "bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300"
}`}> : "bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300"
{isAdmin ? '管理员' : '普通用户'} }`}
>
{isAdmin ? "管理员" : "普通用户"}
</span> </span>
<span className="text-xs text-default-500"> <span className="text-xs text-default-500">
{new Date().toLocaleDateString('zh-CN')} {new Date().toLocaleDateString("zh-CN")}
</span> </span>
</div> </div>
</div> </div>
@@ -196,119 +234,161 @@ export default function ProfilePage() {
<CardBody className="p-4"> <CardBody className="p-4">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{/* 管理员功能 */} {/* 管理员功能 */}
{isAdmin && adminMenuItems.map((item) => ( {isAdmin &&
<button adminMenuItems.map((item) => (
key={item.path} <button
onClick={() => navigate(item.path)} key={item.path}
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200" className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
> onClick={() => navigate(item.path)}
<div className={`w-10 h-10 ${item.color} rounded-full flex items-center justify-center mb-2`}> >
{item.icon} <div
</div> className={`w-10 h-10 ${item.color} rounded-full flex items-center justify-center mb-2`}
<span className="text-xs text-foreground text-center">{item.label}</span> >
</button> {item.icon}
))} </div>
<span className="text-xs text-foreground text-center">
{item.label}
</span>
</button>
))}
{/* 修改密码 */} {/* 修改密码 */}
<button <button
onClick={onOpen}
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200" className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
onClick={onOpen}
> >
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-full flex items-center justify-center mb-2"> <div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-full flex items-center justify-center mb-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" /> className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z"
fillRule="evenodd"
/>
</svg> </svg>
</div> </div>
<span className="text-xs text-foreground text-center">修改密码</span> <span className="text-xs text-foreground text-center">
修改密码
</span>
</button> </button>
{/* 退出登录 */} {/* 退出登录 */}
<button <button
onClick={handleLogout}
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200" className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
onClick={handleLogout}
> >
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-full flex items-center justify-center mb-2"> <div className="w-10 h-10 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-full flex items-center justify-center mb-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /> className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
fillRule="evenodd"
/>
</svg> </svg>
</div> </div>
<span className="text-xs text-foreground text-center">退出登录</span> <span className="text-xs text-foreground text-center">
退出登录
</span>
</button> </button>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
<div className="fixed inset-x-0 bottom-20 text-center py-4"> <div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500"> <p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '} Powered by{" "}
<a <a
href="https://github.com/bqlpfy/flux-panel" className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
target="_blank" href={siteConfig.github_repo}
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" target="_blank"
> >
flux-panel flux-panel
</a> </a>
</p> </p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{ isWebViewFunc() ? siteConfig.app_version : siteConfig.version} v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
</p> </p>
</div> </div>
</div> </div>
{/* 修改密码弹窗 */} {/* 修改密码弹窗 */}
<Modal <Modal
isOpen={isOpen} backdrop="blur"
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={() => { onOpenChange={() => {
onOpenChange(); onOpenChange();
resetPasswordForm(); resetPasswordForm();
}} }}
size="2xl"
scrollBehavior="outside"
backdrop="blur"
placement="center"
> >
<ModalContent> <ModalContent>
{(onClose: () => void) => ( {(onClose: () => void) => (
<> <>
<ModalHeader className="flex flex-col gap-1">修改密码</ModalHeader> <ModalHeader className="flex flex-col gap-1">
修改密码
</ModalHeader>
<ModalBody> <ModalBody>
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
label="新用户名" label="新用户名"
placeholder="请输入新用户名(至少3位)" placeholder="请输入新用户名(至少3位)"
value={passwordForm.newUsername} value={passwordForm.newUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))}
variant="bordered" variant="bordered"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordForm((prev) => ({
...prev,
newUsername: e.target.value,
}))
}
/> />
<Input <Input
label="当前密码" label="当前密码"
type="password"
placeholder="请输入当前密码" placeholder="请输入当前密码"
type="password"
value={passwordForm.currentPassword} value={passwordForm.currentPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
variant="bordered" variant="bordered"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordForm((prev) => ({
...prev,
currentPassword: e.target.value,
}))
}
/> />
<Input <Input
label="新密码" label="新密码"
type="password"
placeholder="请输入新密码(至少6位)" placeholder="请输入新密码(至少6位)"
type="password"
value={passwordForm.newPassword} value={passwordForm.newPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
variant="bordered" variant="bordered"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordForm((prev) => ({
...prev,
newPassword: e.target.value,
}))
}
/> />
<Input <Input
label="确认密码" label="确认密码"
type="password"
placeholder="请再次输入新密码" placeholder="请再次输入新密码"
type="password"
value={passwordForm.confirmPassword} value={passwordForm.confirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
variant="bordered" variant="bordered"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPasswordForm((prev) => ({
...prev,
confirmPassword: e.target.value,
}))
}
/> />
</div> </div>
</ModalBody> </ModalBody>
@@ -316,10 +396,10 @@ export default function ProfilePage() {
<Button color="default" variant="light" onPress={onClose}> <Button color="default" variant="light" onPress={onClose}>
取消 取消
</Button> </Button>
<Button <Button
color="primary" color="primary"
onPress={handlePasswordSubmit}
isLoading={passwordLoading} isLoading={passwordLoading}
onPress={handlePasswordSubmit}
> >
确定 确定
</Button> </Button>
+67 -39
View File
@@ -1,74 +1,77 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Input } from "@heroui/input"; import { Input } from "@heroui/input";
import { Button } from "@heroui/button"; import { Button } from "@heroui/button";
import { Card, CardBody } from "@heroui/card"; import { Card, CardBody } from "@heroui/card";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast'; import toast from "react-hot-toast";
import { reinitializeBaseURL } from '@/api/network';
import { import { reinitializeBaseURL } from "@/api/network";
getPanelAddresses, import {
savePanelAddress, getPanelAddresses,
setCurrentPanelAddress, savePanelAddress,
deletePanelAddress, setCurrentPanelAddress,
deletePanelAddress,
validatePanelAddress, validatePanelAddress,
} from '@/utils/panel'; } from "@/utils/panel";
interface PanelAddress { interface PanelAddress {
name: string; name: string;
address: string; address: string;
inx: boolean; inx: boolean;
} }
export const SettingsPage = () => { export const SettingsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]); const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState(''); const [newAddress, setNewAddress] = useState("");
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => { const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress); setPanelAddresses(newAddress);
} };
// 加载面板地址列表 // 加载面板地址列表
const loadPanelAddresses = async () => { const loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc (window as any).setPanelAddresses = setPanelAddressesFunc;
getPanelAddresses(); getPanelAddresses();
}; };
// 添加新面板地址 // 添加新面板地址
const addPanelAddress = async () => { const addPanelAddress = async () => {
if (!newName.trim() || !newAddress.trim()) { if (!newName.trim() || !newAddress.trim()) {
toast.error('请输入名称和地址'); toast.error("请输入名称和地址");
return; return;
} }
// 验证地址格式 // 验证地址格式
if (!validatePanelAddress(newAddress.trim())) { if (!validatePanelAddress(newAddress.trim())) {
toast.error('地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000'); toast.error(
"地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000",
);
return; return;
} }
(window as any).setPanelAddresses = setPanelAddressesFunc (window as any).setPanelAddresses = setPanelAddressesFunc;
savePanelAddress(newName.trim(), newAddress.trim()); savePanelAddress(newName.trim(), newAddress.trim());
setNewName(''); setNewName("");
setNewAddress(''); setNewAddress("");
toast.success('添加成功'); toast.success("添加成功");
}; };
// 设置当前面板地址 // 设置当前面板地址
const setCurrentPanel = async (name: string) => { const setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc (window as any).setPanelAddresses = setPanelAddressesFunc;
setCurrentPanelAddress(name); setCurrentPanelAddress(name);
reinitializeBaseURL(); reinitializeBaseURL();
}; };
// 删除面板地址 // 删除面板地址
const handleDeletePanelAddress = async (name: string) => { const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc (window as any).setPanelAddresses = setPanelAddressesFunc;
deletePanelAddress(name); deletePanelAddress(name);
reinitializeBaseURL(); reinitializeBaseURL();
toast.success('删除成功'); toast.success("删除成功");
}; };
// 页面加载时获取数据 // 页面加载时获取数据
@@ -84,15 +87,27 @@ export const SettingsPage = () => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
isIconOnly isIconOnly
className="text-gray-600 dark:text-gray-300"
variant="light" variant="light"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="text-gray-600 dark:text-gray-300"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M15 19l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg> </svg>
</Button> </Button>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">面板设置</h1> <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
面板设置
</h1>
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +118,9 @@ export const SettingsPage = () => {
{/* 添加新地址 */} {/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700"> <Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6"> <CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">添加新面板地址</h2> <h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
添加新面板地址
</h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input <Input
@@ -129,30 +146,41 @@ export const SettingsPage = () => {
{/* 地址列表 */} {/* 地址列表 */}
<Card className="border border-gray-200 dark:border-gray-700"> <Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6"> <CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">已保存的面板地址</h2> <h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
已保存的面板地址
</h2>
{panelAddresses.length === 0 ? ( {panelAddresses.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">暂无保存的面板地址</p> <p className="text-gray-500 dark:text-gray-400 text-center py-8">
暂无保存的面板地址
</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{panelAddresses.map((panel, index) => ( {panelAddresses.map((panel, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"> <div
key={index}
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{panel.name}</span> <span className="font-medium text-gray-900 dark:text-white">
{panel.name}
</span>
{panel.inx && ( {panel.inx && (
<span className="px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded"> <span className="px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded">
当前 当前
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{panel.address}</p> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{panel.address}
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!panel.inx && ( {!panel.inx && (
<Button <Button
size="sm"
color="primary" color="primary"
size="sm"
variant="flat" variant="flat"
onClick={() => setCurrentPanel(panel.name)} onClick={() => setCurrentPanel(panel.name)}
> >
@@ -160,8 +188,8 @@ export const SettingsPage = () => {
</Button> </Button>
)} )}
<Button <Button
size="sm"
color="danger" color="danger"
size="sm"
variant="light" variant="light"
onClick={() => handleDeletePanelAddress(panel.name)} onClick={() => handleDeletePanelAddress(panel.name)}
> >
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -1,12 +1,13 @@
import type { NavigateOptions } from "react-router-dom"; import type { NavigateOptions } from "react-router-dom";
import * as React from "react";
import * as React from "react";
import { HeroUIProvider } from "@heroui/system"; import { HeroUIProvider } from "@heroui/system";
import { useHref, useNavigate } from "react-router-dom"; import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from 'react-hot-toast'; import { Toaster } from "react-hot-toast";
import { ThemeProvider } from '@/components/theme-provider';
import { I18nProvider } from "@react-aria/i18n"; import { I18nProvider } from "@react-aria/i18n";
import { ThemeProvider } from "@/components/theme-provider";
declare module "@react-types/shared" { declare module "@react-types/shared" {
interface RouterConfig { interface RouterConfig {
routerOptions: NavigateOptions; routerOptions: NavigateOptions;
@@ -25,28 +26,28 @@ export function Provider({ children }: ProvidersProps) {
<HeroUIProvider navigate={navigate} useHref={useHref}> <HeroUIProvider navigate={navigate} useHref={useHref}>
<ThemeProvider> <ThemeProvider>
{children} {children}
<Toaster <Toaster
position="top-center" position="top-center"
toastOptions={{ toastOptions={{
duration: 2000, duration: 2000,
className: 'dark:bg-gray-800 dark:text-white', className: "dark:bg-gray-800 dark:text-white",
style: { style: {
background: 'var(--toaster-bg, #ffffff)', background: "var(--toaster-bg, #ffffff)",
color: 'var(--toaster-color, #000000)', color: "var(--toaster-color, #000000)",
border: '1px solid var(--toaster-border, #e5e7eb)', border: "1px solid var(--toaster-border, #e5e7eb)",
}, },
success: { success: {
duration: 2000, duration: 2000,
style: { style: {
background: '#10b981', background: "#10b981",
color: '#ffffff', color: "#ffffff",
}, },
}, },
error: { error: {
duration: 2000, duration: 2000,
style: { style: {
background: '#ef4444', background: "#ef4444",
color: '#ffffff', color: "#ffffff",
}, },
}, },
}} }}
+9
View File
@@ -58,6 +58,15 @@ export interface UserTunnelForm {
speedId: number | null; speedId: number | null;
} }
export interface TunnelAssignItem {
tunnelId: number;
speedId: number | null;
}
export interface UserTunnelBatchAssignForm {
tunnels: TunnelAssignItem[];
}
export interface Tunnel { export interface Tunnel {
id: number; id: number;
name: string; name: string;
+13
View File
@@ -0,0 +1,13 @@
declare module "recharts" {
import * as React from "react";
export const LineChart: React.ComponentType<Record<string, unknown>>;
export const Line: React.ComponentType<Record<string, unknown>>;
export const XAxis: React.ComponentType<Record<string, unknown>>;
export const YAxis: React.ComponentType<Record<string, unknown>>;
export const CartesianGrid: React.ComponentType<Record<string, unknown>>;
export const Tooltip: React.ComponentType<Record<string, unknown>>;
export const ResponsiveContainer: React.ComponentType<
Record<string, unknown>
>;
}
+1 -1
View File
@@ -44,7 +44,7 @@ interface CaptchaStyle {
declare class TAC { declare class TAC {
constructor(config: CaptchaConfig, style?: CaptchaStyle); constructor(config: CaptchaConfig, style?: CaptchaStyle);
init(): TAC; init(): TAC;
reloadCaptcha(): void; reloadCaptcha(): void;
destroyWindow(): void; destroyWindow(): void;
+12 -5
View File
@@ -1,4 +1,4 @@
import { getRoleIdFromToken, isTokenValid } from './jwt'; import { getRoleIdFromToken, isTokenValid } from "./jwt";
/** /**
* 权限工具类 * 权限工具类
@@ -9,7 +9,7 @@ import { getRoleIdFromToken, isTokenValid } from './jwt';
* @returns token * @returns token
*/ */
export function getToken(): string | null { export function getToken(): string | null {
return localStorage.getItem('token'); return localStorage.getItem("token");
} }
/** /**
@@ -18,9 +18,11 @@ export function getToken(): string | null {
*/ */
export function getCurrentUserRoleId(): number | null { export function getCurrentUserRoleId(): number | null {
const token = getToken(); const token = getToken();
if (!token || !isTokenValid(token)) { if (!token || !isTokenValid(token)) {
return null; return null;
} }
return getRoleIdFromToken(token); return getRoleIdFromToken(token);
} }
@@ -30,6 +32,7 @@ export function getCurrentUserRoleId(): number | null {
*/ */
export function isAdmin(): boolean { export function isAdmin(): boolean {
const roleId = getCurrentUserRoleId(); const roleId = getCurrentUserRoleId();
return roleId === 0; return roleId === 0;
} }
@@ -40,6 +43,7 @@ export function isAdmin(): boolean {
*/ */
export function hasRole(targetRoleId: number): boolean { export function hasRole(targetRoleId: number): boolean {
const roleId = getCurrentUserRoleId(); const roleId = getCurrentUserRoleId();
return roleId === targetRoleId; return roleId === targetRoleId;
} }
@@ -49,6 +53,7 @@ export function hasRole(targetRoleId: number): boolean {
*/ */
export function isLoggedIn(): boolean { export function isLoggedIn(): boolean {
const token = getToken(); const token = getToken();
return token ? isTokenValid(token) : false; return token ? isTokenValid(token) : false;
} }
@@ -59,14 +64,16 @@ export function isLoggedIn(): boolean {
* @returns 包装后的函数 * @returns 包装后的函数
*/ */
export function requireAdmin<T extends (...args: any[]) => any>( export function requireAdmin<T extends (...args: any[]) => any>(
fn: T, fn: T,
errorMsg: string = '权限不足,仅管理员可操作' errorMsg: string = "权限不足,仅管理员可操作",
): T { ): T {
return ((...args: Parameters<T>) => { return ((...args: Parameters<T>) => {
if (!isAdmin()) { if (!isAdmin()) {
console.warn(errorMsg); console.warn(errorMsg);
return false; return false;
} }
return fn(...args); return fn(...args);
}) as T; }) as T;
} }
+24 -13
View File
@@ -18,12 +18,14 @@ interface JWTPayload {
function getPayloadFromToken(token: string): JWTPayload | null { function getPayloadFromToken(token: string): JWTPayload | null {
try { try {
if (!token) return null; if (!token) return null;
const parts = token.split('.'); const parts = token.split(".");
if (parts.length !== 3) return null; if (parts.length !== 3) return null;
const encodedPayload = parts[1]; const encodedPayload = parts[1];
const decodedPayload = atob(encodedPayload); const decodedPayload = atob(encodedPayload);
return JSON.parse(decodedPayload) as JWTPayload; return JSON.parse(decodedPayload) as JWTPayload;
} catch (error) { } catch (error) {
return null; return null;
@@ -37,6 +39,7 @@ function getPayloadFromToken(token: string): JWTPayload | null {
*/ */
export function getUserIdFromToken(token: string): number | null { export function getUserIdFromToken(token: string): number | null {
const payload = getPayloadFromToken(token); const payload = getPayloadFromToken(token);
return payload ? parseInt(payload.sub) : null; return payload ? parseInt(payload.sub) : null;
} }
@@ -47,6 +50,7 @@ export function getUserIdFromToken(token: string): number | null {
*/ */
export function getRoleIdFromToken(token: string): number | null { export function getRoleIdFromToken(token: string): number | null {
const payload = getPayloadFromToken(token); const payload = getPayloadFromToken(token);
return payload ? payload.role_id : null; return payload ? payload.role_id : null;
} }
@@ -57,6 +61,7 @@ export function getRoleIdFromToken(token: string): number | null {
*/ */
export function getUsernameFromToken(token: string): string | null { export function getUsernameFromToken(token: string): string | null {
const payload = getPayloadFromToken(token); const payload = getPayloadFromToken(token);
return payload ? payload.user : null; return payload ? payload.user : null;
} }
@@ -67,9 +72,11 @@ export function getUsernameFromToken(token: string): string | null {
*/ */
export function isTokenValid(token: string): boolean { export function isTokenValid(token: string): boolean {
const payload = getPayloadFromToken(token); const payload = getPayloadFromToken(token);
if (!payload) return false; if (!payload) return false;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return payload.exp > now; return payload.exp > now;
} }
@@ -80,34 +87,38 @@ export const JwtUtil = {
* @returns 用户ID * @returns 用户ID
*/ */
getUserIdFromToken(): number | null { getUserIdFromToken(): number | null {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
return token ? getUserIdFromToken(token) : null; return token ? getUserIdFromToken(token) : null;
}, },
/** /**
* 从localStorage获取token并解析角色ID * 从localStorage获取token并解析角色ID
* @returns 角色ID * @returns 角色ID
*/ */
getRoleIdFromToken(): number | null { getRoleIdFromToken(): number | null {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
return token ? getRoleIdFromToken(token) : null; return token ? getRoleIdFromToken(token) : null;
}, },
/** /**
* 从localStorage获取token并解析用户名 * 从localStorage获取token并解析用户名
* @returns 用户名 * @returns 用户名
*/ */
getUsernameFromToken(): string | null { getUsernameFromToken(): string | null {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
return token ? getUsernameFromToken(token) : null; return token ? getUsernameFromToken(token) : null;
}, },
/** /**
* 验证localStorage中的token是否有效 * 验证localStorage中的token是否有效
* @returns 是否有效 * @returns 是否有效
*/ */
isTokenValid(): boolean { isTokenValid(): boolean {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
return token ? isTokenValid(token) : false; return token ? isTokenValid(token) : false;
} },
}; };
+1 -1
View File
@@ -4,4 +4,4 @@
*/ */
export const safeLogout = () => { export const safeLogout = () => {
localStorage.clear(); localStorage.clear();
}; };
+69 -46
View File
@@ -1,49 +1,66 @@
// 获取面板地址列表 // 获取面板地址列表
export async function getPanelAddresses(callback: string = "setPanelAddresses"){ export async function getPanelAddresses(
if ((window as any).JsInterface && (window as any).JsInterface.getPanelAddresses) { callback: string = "setPanelAddresses",
(window as any).JsInterface.getPanelAddresses(callback); ) {
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) { if (
(window as any).webkit.messageHandlers.getPanelAddresses.postMessage(callback); (window as any).JsInterface &&
} (window as any).JsInterface.getPanelAddresses
) {
(window as any).JsInterface.getPanelAddresses(callback);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.getPanelAddresses.postMessage(
callback,
);
}
} }
// 保存面板地址 // 保存面板地址
export async function savePanelAddress(name: string, address: string){ export async function savePanelAddress(name: string, address: string) {
if ((window as any).JsInterface) { if ((window as any).JsInterface) {
(window as any).JsInterface.savePanelAddress(name, address); (window as any).JsInterface.savePanelAddress(name, address);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) { } else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.savePanelAddress.postMessage({ name, address }); (window as any).webkit.messageHandlers.savePanelAddress.postMessage({
} name,
address,
});
}
} }
// 设置当前面板地址 // 设置当前面板地址
export async function setCurrentPanelAddress(name: string) { export async function setCurrentPanelAddress(name: string) {
if ((window as any).JsInterface) { if ((window as any).JsInterface) {
(window as any).JsInterface.setCurrentPanelAddress(name); (window as any).JsInterface.setCurrentPanelAddress(name);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) { } else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.setCurrentPanelAddress.postMessage({ name }); (window as any).webkit.messageHandlers.setCurrentPanelAddress.postMessage({
} name,
});
}
} }
// 删除面板地址 // 删除面板地址
export async function deletePanelAddress(name: string){ export async function deletePanelAddress(name: string) {
if ((window as any).JsInterface) { if ((window as any).JsInterface) {
(window as any).JsInterface.deletePanelAddress(name); (window as any).JsInterface.deletePanelAddress(name);
} else if ((window as any).webkit && (window as any).webkit.messageHandlers) { } else if ((window as any).webkit && (window as any).webkit.messageHandlers) {
(window as any).webkit.messageHandlers.deletePanelAddress.postMessage({ name }); (window as any).webkit.messageHandlers.deletePanelAddress.postMessage({
} name,
});
}
} }
export function isWebViewFunc(){ export function isWebViewFunc() {
if((window as any).JsInterface !== undefined && (window as any).JsInterface.getPanelAddresses !== undefined) { if (
(window as any).JsInterface !== undefined &&
(window as any).JsInterface.getPanelAddresses !== undefined
) {
return true; return true;
}else if((window as any).webkit && (window as any).webkit.messageHandlers && (window as any).webkit.messageHandlers.getPanelAddresses !== undefined) { } else if (
(window as any).webkit &&
(window as any).webkit.messageHandlers &&
(window as any).webkit.messageHandlers.getPanelAddresses !== undefined
) {
return true; return true;
}else { } else {
return false; return false;
} }
} }
@@ -52,52 +69,58 @@ export function isWebViewFunc(){
export function validatePanelAddress(address: string): boolean { export function validatePanelAddress(address: string): boolean {
try { try {
// 基本格式检查:必须以 http:// 或 https:// 开头 // 基本格式检查:必须以 http:// 或 https:// 开头
if (!address.startsWith('http://') && !address.startsWith('https://')) { if (!address.startsWith("http://") && !address.startsWith("https://")) {
return false; return false;
} }
// 使用URL构造函数验证完整URL格式 // 使用URL构造函数验证完整URL格式
const url = new URL(address); const url = new URL(address);
// 检查主机名不能为空 // 检查主机名不能为空
if (!url.hostname || url.hostname.trim() === '') { if (!url.hostname || url.hostname.trim() === "") {
return false; return false;
} }
// 检查主机名 // 检查主机名
const hostname = url.hostname; const hostname = url.hostname;
// 支持 localhost // 支持 localhost
if (hostname === 'localhost') { if (hostname === "localhost") {
return true; return true;
} }
// 支持 IPv4 地址 // 支持 IPv4 地址
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Pattern.test(hostname)) { if (ipv4Pattern.test(hostname)) {
const parts = hostname.split('.'); const parts = hostname.split(".");
return parts.every(part => {
return parts.every((part) => {
const num = parseInt(part); const num = parseInt(part);
return num >= 0 && num <= 255; return num >= 0 && num <= 255;
}); });
} }
// 支持 IPv6 地址 // 支持 IPv6 地址
const ipv6Pattern = /^\[([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$|^\[([0-9a-fA-F]{1,4}:)*:([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}\]$/; const ipv6Pattern =
/^\[([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$|^\[([0-9a-fA-F]{1,4}:)*:([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}\]$/;
if (ipv6Pattern.test(hostname)) { if (ipv6Pattern.test(hostname)) {
return true; return true;
} }
// 支持域名 // 支持域名
const domainPattern = /^[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])?)*\.[a-zA-Z]{2,}$/; const domainPattern =
/^[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])?)*\.[a-zA-Z]{2,}$/;
if (domainPattern.test(hostname)) { if (domainPattern.test(hostname)) {
return true; return true;
} }
return false; return false;
} catch (error) { } catch (error) {
// URL构造函数失败说明格式不正确 // URL构造函数失败说明格式不正确
return false; return false;
} }
} }
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
readonly VITE_GITHUB_REPO?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+10 -11
View File
@@ -1,12 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
react(), base: "/",
],
base: '/',
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
@@ -14,14 +13,14 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
host: '0.0.0.0' host: "0.0.0.0",
}, },
build: { build: {
outDir: 'dist', outDir: "dist",
sourcemap: false, sourcemap: false,
minify: false, minify: false,
rollupOptions: { rollupOptions: {
treeshake: false, treeshake: false,
} },
} },
}); });