Compare commits

..

11 Commits

Author SHA1 Message Date
sagit 42d6249af5 style: restore Prettier formatting in renewal.ts (#298) 2026-03-09 10:29:33 +08:00
sagit de9ab51def feat: node management enhancements and UX improvements (#297)
* feat(nodes): add renewal cycle and auto-advance scheduling

- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page

* fix(frontend): show rule count in compact view mode for forward pages

* style(frontend): format code and improve node card layout

Entire-Checkpoint: 08b760b76538

* fix: restore renewal.ts from main to resolve build errors
2026-03-09 10:01:55 +08:00
sagit 413081f72a feat(nodes): add renewal cycle and auto-advance scheduling (#296)
- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page
2026-03-08 22:05:25 +08:00
sagit 508a37a84c feat(nodes): add node metadata and expiry reminders (#293)
## Summary

- 为节点新增备注、标签、到期时间字段,并贯通后端存储、导入导出与前端 API
- 节点管理页支持编辑、搜索、筛选并高亮即将到期或已过期节点
- Dashboard 为管理员新增节点到期提醒卡片,集中展示 7 天内到期和已过期节点

## Changes

### Backend
- `node/create` 和 `node/update` 支持 `remark`、`tags`、`expiryTime`
- 节点列表接口返回新增字段
- 节点导入导出保留备注、标签与到期时间

### Frontend
- 节点页面新增备注、标签、到期时间表单项
- 节点列表支持按备注/标签搜索,并支持到期状态筛选
- 节点卡片展示备注、标签、到期时间以及过期提醒样式
- Dashboard 管理员视图新增节点到期提醒模块

## Plans

- `plans/021-node-remarks-tags-expiry.md`
- `plans/022-node-expiry-highlights-dashboard-reminders.md`

## Issue

Closes #246
2026-03-08 20:02:36 +08:00
sagitchu d60655045a feat(forward): display tunnel traffic ratio on forward page 2026-03-08 20:00:26 +08:00
sagitchu 31ef861504 feat(nodes): add node metadata and expiry reminders (#246)
Entire-Checkpoint: d8b429492cbe
2026-03-08 19:44:46 +08:00
sagitchu f1bdb2e2ef feat(frontend): implement AJAX no-refresh UX improvements (#276)
- Add dashboard auto-refresh with 5s polling and visibility-aware pause
- Harden node realtime reconnection with exponential backoff and polling fallback
- Implement local state patching for forward/tunnel/user mutations
- Add batch operation progress feedback UI
- Add shared list-state helpers for replace/remove operations
- Preserve derived UI state during server payload merges

Closes #276

Entire-Checkpoint: 7d6188355d7f
2026-03-08 19:15:02 +08:00
sagit bd30b61018 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)
## Summary

- Widens legacy PostgreSQL traffic/quota columns from `integer` to
`bigint` to prevent int4 overflow
- Fixes federation share creation failure when traffic limits exceed 2GB
(e.g., `536870912000` bytes = 500GB)
- Bumps schema version from 4 to 5 with auto-migration on backend
startup

## Affected Tables

- `user`: `flow`, `in_flow`, `out_flow`
- `forward`: `in_flow`, `out_flow`
- `statistics_flow`: `flow`, `total_flow`
- `tunnel`: `flow`
- `user_tunnel`: `flow`, `in_flow`, `out_flow`
- `peer_share`: `max_bandwidth`, `current_flow`

## Test Plan

- ✅ Unit tests added for migration execution and error handling
- ✅ Contract tests passed
- ✅ Repository tests passed

Commands:
```bash
cd go-backend && go test ./internal/store/repo/...
cd go-backend && go test ./tests/contract/...
```
2026-03-08 11:50:14 +08:00
sagitchu e0dd70a054 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint
Widens legacy PostgreSQL traffic/quota columns to BIGINT to prevent
int4 overflow when storing large values like 536870912000 (500GB).

Affected tables:
- user (flow, in_flow, out_flow)
- forward (in_flow, out_flow)
- statistics_flow (flow, total_flow)
- tunnel (flow)
- user_tunnel (flow, in_flow, out_flow)
- peer_share (max_bandwidth, current_flow)

Schema version bumped from 4 to 5 with auto-migration on startup.
2026-03-08 11:48:36 +08:00
sagit 4966a8aad1 fix(backend): sync user tunnel status and relax forward speedId permission check (#288)
## Summary

- Return actual `user_tunnel.status` in admin permission list instead of
hardcoded enabled state (1)
- Allow non-admin users to update forwards when keeping the same
`speedId` selection
- Add contract tests for user tunnel status mapping and forward
permission edge case

## Test Plan

- [x] Contract tests pass: `cd go-backend && go test
./tests/contract/...`
- [x] User tunnel permission list returns correct status values
(enabled/disabled)
- [x] Non-admin users can update forward details when keeping existing
speedId
2026-03-08 00:57:57 +08:00
sagitchu 3e11549370 fix(backend): sync user tunnel status and relax forward speedId permission check
- Return actual user_tunnel.status in admin permission list instead of hardcoded 1
- Allow non-admin users to update forwards when keeping the same speedId selection
- Add contract tests for user tunnel status mapping and forward permission edge case

Entire-Checkpoint: deb90fb942ee
2026-03-08 00:56:13 +08:00
76 changed files with 29268 additions and 465 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr
@@ -0,0 +1,6 @@
✅ 已完成!
- 提交: `e7548e7` - style(frontend): format code and improve node card layout
- 推送到: `origin/opencode/quiet-canyon`
- PR 创建成功: https://github.com/Sagit-chu/flvx/pull/297
注意: 有 12 个未跟踪的 `.entire/metadata/ses_*` 文件,这些看起来是会话临时文件,未包含在提交中。
@@ -0,0 +1,17 @@
# Session Context
Session ID: ses_32fb9de62ffeXG3aOGlPDusGE8
Commit Message: 规则标签页下,精简模式,和非精简模式一样要显示规则数量
## Prompts
### Prompt 1
规则标签页下,精简模式,和非精简模式一样要显示规则数量
## Summary
I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count ("{sortedForwards.length} 条规则") when the forward page is in the `compactMode` (精简模式) and `viewMode === "grouped"`. This ensures consistency with the regular view modes where the total rule count is already displayed.
The fix was committed and pushed to the repository in the recent step:
`fix(frontend): show rule count in compact view mode for forward pages`
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
规则标签页下,精简模式,和非精简模式一样要显示规则数量
@@ -0,0 +1,4 @@
I have successfully modified `vite-frontend/src/pages/forward.tsx` to include the rule count ("{sortedForwards.length} 条规则") when the forward page is in the `compactMode` (精简模式) and `viewMode === "grouped"`. This ensures consistency with the regular view modes where the total rule count is already displayed.
The fix was committed and pushed to the repository in the recent step:
`fix(frontend): show rule count in compact view mode for forward pages`
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
节点添加备注和下次续费后,卡片会被撑开,和其他卡片高度不统一,有什么优化方法,或者隐藏一些内容
---
1. 统一限制卡片高度
2. 限制「备注」只显示 1 行
3. 下次续费只显示距离到期
@@ -0,0 +1,30 @@
我已经详细查阅了节点卡片 (`vite-frontend/src/pages/node.tsx`) 的代码结构。由于现在处于**计划模式 (Plan Mode)**,我目前只能读取和分析,不能直接修改代码。
我已经为你准备了详细的修改方案,这套方案同时满足你提到的 3 个要求:
### 修改计划:
**1. 统一限制卡片高度**
* **目标代码位置**:修改包裹 `Card` 的外层 `SortableItem` 的 `className`,以及 `Card` 内部的结构。
* **具体修改**:
* 给 `SortableItem` 内部渲染的 `div` 增加 `flex flex-col h-full`,使其占满整个 Grid 格子的高度。
* 给 `Card` 增加 `h-full flex flex-col` 类名。
* 在 `<CardBody>` 内部,将顶部的基础信息区域(包含备注、续费等)和底部的控制区域(系统监控及按钮)分开。给包含底部按钮区域的 div 添加 `mt-auto`,这样它就会自动被推到卡片的最底部,使所有的操作按钮和监控进度条在同一行水平对齐,从而强制拉伸中间的空白区域以保证卡片等高。
**2. 限制「备注」只显示 1 行**
* **目标代码位置**:备注内容显示的 div (`{node.remark.trim()}`)。
* **具体修改**:
* 将包含备注文本的 `div` 上的 `whitespace-pre-wrap break-words` 替换为 `line-clamp-1 break-all`。
* 在这个文本外层包一个 `title={node.remark.trim()}` 属性(原生 Tooltip),这样用户只要把鼠标悬停在被截断的备注上,就能看到完整的备注内容。
**3. 下次续费只显示距离到期**
* **目标代码位置**:`{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && ( ... )}` 渲染逻辑内。
* **具体修改**:
* 精简现有的 3 行布局(具体时间、剩余时间 Chip、续费周期)。
* 将其压缩为 1 行显示,只展示带颜色的 Chip(里面包含了“还有 x 天”的 `expiryMeta.label`)。
* 为了保留完整信息,我们给这个 Chip 加上 `title` 属性,比如鼠标移上去会显示完整的:`下次续费:2026-12-01 (年付)`。
* 这会把原先极度占用垂直空间的 3 行内容直接缩减到单行。
---
如果这个方案符合你的预期,你可以退出计划模式(或者直接回复同意执行),我会在下一次交互中立即为你实施这些代码修改!
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
提交全部变更并且push,创建pr并且合并,合并后推送新tag,在已有的tag 递增 alpha版本
---
提交全部变更并且push,创建pr并且合并,合并后推送新tag,在已有的tag 递增 alpha版本
@@ -0,0 +1,3 @@
当前没有新的变更需要提交。工作目录干净,只有 `.entire/metadata/` 临时文件。最新 tag 是 `2.1.8-alpha4`。
如有新变更需要提交,请先进行修改。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
提交全部变更并且push
---
合并pr,发布新tag 2.1.8-alpha3
@@ -0,0 +1,3 @@
完成:
- PR #293 已合并
- Tag `2.1.8-alpha3` 已发布
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
设计一下这个issue实现
https://github.com/Sagit-chu/flvx/issues/280
@@ -0,0 +1,52 @@
我看过 issue #280,也把前后端相关实现路径过了一遍。这个需求适合做成“后端补字段,前端直接消费”的小改动,不建议只在前端二次拼装。
- 现在 `/forward/list` 的数据来自 `h.repo.ListForwards()`,对应查询在 `go-backend/internal/store/repo/repository.go:700`,当前只 `LEFT JOIN tunnel` 取了 `tunnel_name`,还没把 `tunnel.traffic_ratio` 带出来。
- 前端规则页自己的 `Forward` 类型定义在 `vite-frontend/src/pages/forward.tsx:102`,`mapForwardApiItems` 在 `vite-frontend/src/pages/forward.tsx:507` 负责把接口结果映射成本地状态;这里目前也没有倍率字段。
- 规则列表的主要展示点有 3 处:表格隧道列 `vite-frontend/src/pages/forward.tsx:3306`、卡片副标题 `vite-frontend/src/pages/forward.tsx:3469`、分组标题 `vite-frontend/src/pages/forward.tsx:2919`。如果只改一处,体验会不一致。
- 隧道页已经有统一的倍率展示格式 `vite-frontend/src/pages/tunnel.tsx:1336`,直接沿用 `"{trafficRatio}x"` 最稳。
建议实现方案
- 后端优先:在 `go-backend/internal/store/repo/repository.go:705` 的 `fwdRow` 增加 `TrafficRatio float64`,并在 `Select(...)` 中补上 `COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio`。
- 后端返回:在构造 `item` 的地方把 `trafficRatio` 以 `tunnelTrafficRatio` 键返回,保持前端语义清晰,避免和规则自身字段混淆。
- 前端类型:给 `vite-frontend/src/api/types.ts:45` 的 `ForwardApiItem` 增加 `tunnelTrafficRatio?: number`;给 `vite-frontend/src/pages/forward.tsx:102` 的本地 `Forward` interface 同步增加该字段。
- 前端映射:在 `vite-frontend/src/pages/forward.tsx:507` 的 `mapForwardApiItems` 里做默认值归一化,比如没有值时回退到 `1`,避免旧数据或异常响应导致显示空白。
- 前端展示:优先把倍率放进“隧道名称展示块”里,而不是新增独立列。原因是当前表格列已经偏宽,issue 也接受“名称旁边显示”;卡片和分组视图也更容易保持一致。建议形式:
- 表格:`TunnelName` 主文案 + 小号次级 `1x`
- 卡片:副标题显示为 `隧道名 · 1x`
- 分组标题:`隧道名` 后加一个轻量 Chip / muted 文本 `1x`
为什么不建议纯前端拼装
- `forward.tsx` 虽然在 `loadData` 里已经请求了 `getTunnelList()` 和 `userTunnel()`,但 `refreshForwardList()` 是独立刷新的,规则状态刷新时不保证隧道缓存始终同步,容易出现倍率滞后。
- 当前分组逻辑主要基于规则数据本身,若依赖前端再按 `tunnelId` 做二次映射,会把一个本来单接口即可闭环的信息拆成两份状态,后面维护更麻烦。
- 后端本来已经 `JOIN tunnel` 取名字,只是顺手再取一个倍率字段,成本最低。
推荐实施步骤
- 后端:扩展 `/forward/list` 查询与返回结构,默认倍率回退 `1.0`。
- 合约测试:在 `go-backend/tests/contract/forward_contract_test.go` 增加对 `/forward/list` 返回 `tunnelTrafficRatio` 的断言,至少覆盖一个非默认值例如 `2.5`。
- 前端类型:同步更新 `ForwardApiItem` 和页面内 `Forward`。
- 前端映射:在 `mapForwardApiItems` 里规范化 `tunnelTrafficRatio`。
- 前端 UI:统一更新表格、卡片、分组标题 3 处展示,避免 direct/grouped 两种模式信息不一致。
- 回归验证:确认管理员和普通用户视角下,`/forward/list` 权限过滤不受影响,因为过滤逻辑在 `go-backend/internal/http/handler/handler.go:395` 之后仅按 `userId` 过滤,不会受新增字段影响。
风险和注意点
- `ForwardApiItem` 现在是宽松结构,短期不会因新增字段报错,但如果只改页面本地类型不改 API 类型,后面别处复用时会产生类型漂移。
- 分组标题 `ForwardTunnelGroup` 目前只有 `tunnelName`,如果要在分组头也显示倍率,最好给分组结构补一个 `tunnelTrafficRatio?: number`,并在构建分组时从首条规则或同组规则里带出。
- 需要统一倍率格式,避免出现 `1`, `1.0`, `1.00` 混用。建议做一个小格式化函数:整数显示 `1x`,小数保留必要精度如 `0.5x`、`1.25x`。
- 若某条规则关联的隧道已删除,后端 `COALESCE(..., 1.0)` 能保证页面仍可显示默认 `1x`,不会变成空。
验收标准
- `/api/v1/forward/list` 每条规则都带 `tunnelTrafficRatio`。
- 规则页 direct 表格模式能看到倍率。
- grouped 卡片模式和分组标题也能看到倍率,信息一致。
- 隧道缺失或旧数据情况下仍稳定显示 `1x`。
- 普通用户只能看到自己的规则,但倍率字段仍正常返回。
如果你要我下一步继续,我建议按这个实现顺序做:
1. 先补后端查询和 contract test
2. 再补前端类型与统一展示
3. 最后跑 `go test ./tests/contract/...` 和前端 build 做回归
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
提交全部变更并且push,并且修改pr内容关联
https://github.com/Sagit-chu/flvx/issues/246
@@ -0,0 +1,15 @@
已完成。
- 提交了当前代码变更,commit 是 `31ef861`
- 已 push 到远端分支 `opencode/quiet-canyon`
- 已更新 PR `https://github.com/Sagit-chu/flvx/pull/293`
- PR 标题改为 `feat(nodes): add node metadata and expiry reminders`
- PR 内容已改成这次节点备注/标签/到期提醒相关说明,并加入 `Closes #246` 关联 issue
补充一点:
- 工作区里还有未提交的 `.entire/metadata/...` 会话元数据文件,它们没有被加入这次提交
如果你要,我下一步可以继续:
1. 帮你检查这条 PR 的 diff 和描述是否还需要再精简
2. 帮你补充 reviewer-friendly 的 PR checklist
3. 帮你查看 CI / checks 状态
@@ -0,0 +1,14 @@
# Session Context
Session ID: ses_332cb6fa9ffehf0BwC73iSeZUR
Commit Message: 你好
## Prompts
### Prompt 1
你好
## Summary
你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。
@@ -0,0 +1,125 @@
{
"info": {
"id": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"slug": "proud-pixel",
"projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c",
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"title": "问候",
"version": "1.2.21",
"summary": {
"additions": 0,
"deletions": 0,
"files": 0
},
"time": {
"created": 1772969300054,
"updated": 1772969309055
}
},
"messages": [
{
"info": {
"role": "user",
"time": {
"created": 1772969305082
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "api",
"modelID": "gpt-5.4"
},
"variant": "high",
"id": "msg_27c12106663448898b7e25726a28ce04",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR"
},
"parts": [
{
"type": "text",
"text": "你好",
"time": {
"start": 1772969305.0665522,
"end": 1772969305.066553
},
"id": "prt_ccd34a3fa0015dQBm2oO5f6vPN",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_27c12106663448898b7e25726a28ce04"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772969305091,
"completed": 1772969309611
},
"parentID": "msg_27c12106663448898b7e25726a28ce04",
"modelID": "gpt-5.4",
"providerID": "api",
"mode": "build",
"agent": "build",
"path": {
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon"
},
"cost": 0,
"tokens": {
"total": 17138,
"input": 2210,
"output": 80,
"reasoning": 45,
"cache": {
"read": 14848,
"write": 0
}
},
"variant": "high",
"finish": "stop",
"id": "msg_ccd34a4030014dwF4qM30pkR3k",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR"
},
"parts": [
{
"type": "step-start",
"snapshot": "d23faf4e134b40b649cf9ec6e7a21a632c4c61b1",
"id": "prt_ccd34b520001tv5ON6ERsriuUJ",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
},
{
"type": "text",
"text": "你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。",
"time": {
"start": 1772969309550,
"end": 1772969309550
},
"id": "prt_ccd34b521001WeBauxJnuorFa5",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "d23faf4e134b40b649cf9ec6e7a21a632c4c61b1",
"cost": 0,
"tokens": {
"total": 17138,
"input": 2210,
"output": 80,
"reasoning": 45,
"cache": {
"read": 14848,
"write": 0
}
},
"id": "prt_ccd34b56f001tUZXybdhjVzB22",
"sessionID": "ses_332cb6fa9ffehf0BwC73iSeZUR",
"messageID": "msg_ccd34a4030014dwF4qM30pkR3k"
}
]
}
]
}
@@ -0,0 +1 @@
你好
@@ -0,0 +1 @@
你好!我在这儿。要我帮你看代码、改功能、排查报错,还是跑命令都可以。
@@ -0,0 +1,14 @@
# Session Context
Session ID: ses_332ce68b1ffe0R9vUQZ6yq5gUd
Commit Message: 在agent.md中加上,尽量使用能使用的skills和mcp
## Prompts
### Prompt 1
在agent.md中加上,尽量使用能使用的skills和mcp
## Summary
已添加到 `AGENTS.md` 的 CONVENTIONS 部分。
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
在agent.md中加上,尽量使用能使用的skills和mcp
@@ -0,0 +1 @@
已添加到 `AGENTS.md` 的 CONVENTIONS 部分。
@@ -0,0 +1,11 @@
# Session Context
Session ID: ses_332d83674ffeNMa9Z9UerwU3tq
Commit Message: 提交全部变更并且push,创建pr
## Prompts
### Prompt 1
提交全部变更并且push,创建pr
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
提交全部变更并且push,创建pr
@@ -0,0 +1,8 @@
已完成!
**提交摘要:**
- 10 个文件变更(939 行新增,173 行删除)
- 新增计划文档 `plans/020-ajax-no-refresh-ux.md`
- 新增工具函数 `vite-frontend/src/utils/list-state.ts`
**PR 已创建:** https://github.com/Sagit-chu/flvx/pull/293
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
You are auditing the FLVX frontend for GitHub issue #276 (AJAX no-refresh UX improvements). Read-only only. Focus on existing implementation status for: 1) dashboard traffic auto-refresh, 2) node status real-time updates, 3) form submissions without full page reload, 4) local/partial list refresh after CRUD/toggle, 5) batch operation progress UI, 6) search/filter instant response. Inspect relevant files under vite-frontend/src/pages and src/api. Return a concise report with: a) what already exists, b) what is missing or partial, c) specific file paths and functions to touch for an implementation plan, d) notable risks or edge cases. Do not edit anything.
@@ -0,0 +1,230 @@
Concise audit for issue #276, focused on `vite-frontend/src/pages` and `src/api`.
a) What already exists
1. Dashboard traffic auto-refresh
- Dashboard data is loaded via async API calls in a hook, not by page reload:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
- `useDashboardData()` calls `getUserPackageInfo()` and `getAnnouncement()`.
- The 24h traffic chart is already rendered from `statisticsFlows`:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx`
2. Node status real-time updates
- This is the strongest existing area.
- WebSocket realtime hook already exists:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
- `useNodeRealtime()`
- Offline debounce exists:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-offline-timers.ts`
- Node page consumes websocket messages and updates local state without reload:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `handleWebSocketMessage()`
- `loadNodes()`
- Realtime also includes system info updates and upgrade progress updates.
3. Form submissions without full page reload
- Broadly already implemented across main CRUD pages using async handlers + modal close + state refresh.
- Examples:
- Nodes: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `handleSubmit()`
- Tunnels: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
- `handleSubmit()`
- Forwards: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- `handleSubmit()`
- Users: `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `handleSubmitUser()`
- I did not find page reload patterns in these CRUD pages (`location.reload` / `window.location.reload` absent there).
4. Local/partial list refresh after CRUD/toggle
- Some local optimistic/partial updates already exist:
- Node delete updates local state directly:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `confirmDelete()`
- Node edit updates local item directly:
- `handleSubmit()` edit branch
- Forward service toggle uses optimistic local update + rollback on failure:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- `handleServiceToggle()`
- Sorting persists locally and updates item `inx` in-memory after success:
- Node `handleDragEnd()`
- Tunnel `handleDragEnd()`
- Forward `handleDragEnd()`
5. Batch operation progress UI
- Node upgrade has actual per-node progress UI:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `upgradeProgress` state
- websocket `type === "upgrade_progress"` handling in `handleWebSocketMessage()`
- `<Progress />` render around lines ~1307+
- Forward batch actions are factored and return structured outcomes:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward/batch-actions.ts`
6. Search/filter instant response
- Instant client-side filtering already exists in:
- Nodes: `sortedNodes` memo filters by `searchKeyword`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- Tunnels: `sortedTunnels`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
- Forwards: `orderedForwards`
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- Forward also has instant client-side filter dropdowns (`filterUserId`, `filterTunnelId`).
b) What is missing or partial
1. Dashboard traffic auto-refresh: missing
- `useDashboardData()` only fetches once on mount.
- No polling, websocket, refetch trigger, focus refresh, or manual refresh hook.
- So “AJAX no-refresh” exists only for initial load, not ongoing refresh.
2. Node realtime: partial but good
- Realtime status/system-info exists only for nodes.
- It reconnects with limited retries (5 attempts) and then stops.
- `loadNodes()` initially sets `systemInfo: null`, so a full reload can temporarily wipe metrics until WS catches up again.
- No obvious fallback polling if websocket dies permanently.
3. Form submission no full reload: mostly done
- CRUD forms are already async and modal-driven.
- But post-submit refresh style is inconsistent:
- some do local patch,
- many do full page-level list refetch (`loadData()`, `loadUsers()`, `loadNodes()`).
4. Local/partial list refresh after CRUD/toggle: partial/inconsistent
- Forward toggle is good and local.
- Node edit/delete are relatively local.
- But many operations still refetch the whole page dataset:
- Tunnel create/update/delete -> `loadData()`
- Forward create/update/delete -> `loadData()`
- User create/update/delete -> `loadUsers()`
- User tunnel assignment/update/remove -> `loadUserTunnels()`
- This is AJAX and avoids full browser reload, but not “partial/local refresh” in the stronger UX sense.
5. Batch operation progress UI: largely missing outside node upgrade
- Tunnel batch delete/redeploy: only loading spinner/toast + full reload, no per-item progress/result panel.
- Forward batch actions: summarized via toast and optional refresh, but no visible progress timeline/progress list.
- User batch tunnel assignment: no progress UI beyond loading state.
6. Search/filter instant response: mixed
- Nodes/tunnels/forwards are instant client-side.
- Users are not fully instant:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `loadUsers()` is server-driven and tied to `useEffect([pagination.current, pagination.size, searchKeyword])`
- Enter key also manually calls `handleSearch()`
- So user search is AJAX but not purely local/instant in the same way.
c) Specific file paths and functions to touch for an implementation plan
Primary targets
1. Dashboard auto-refresh
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
- `useDashboardData()`
- extract `loadPackageData()` / `loadAnnouncement()` into stable callbacks
- add polling or visibility/focus-based refetch
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/dashboard.tsx`
- if adding manual refresh indicator/button, this is the page entry
- API source already exists:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/api/index.ts`
- `getUserPackageInfo()`, `getAnnouncement()`
2. Node realtime hardening / fallback
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
- `useNodeRealtime()`
- retry policy, reconnect strategy, possible fallback refresh trigger
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `handleWebSocketMessage()`
- `loadNodes()`
- `handleSubmit()`
- `confirmDelete()`
- `handleBatchDelete()`
3. Replace whole-list reloads with local/partial updates
- Tunnels:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/tunnel.tsx`
- `loadData()`
- `handleSubmit()`
- `confirmDelete()`
- `handleBatchDelete()`
- `handleBatchRedeploy()`
- Forwards:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx`
- `loadData()`
- `handleSubmit()`
- `confirmDelete()`
- `handleServiceToggle()`
- `handleBatchDelete()`
- `handleBatchToggleService()`
- `handleBatchRedeploy()`
- `handleBatchChangeTunnel()`
- Users:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `loadUsers()`
- `handleSubmitUser()`
- `handleConfirmDelete()`
- `loadUserTunnels()`
- `handleBatchAssignTunnel()`
- `handleUpdateTunnel()`
- `handleConfirmRemoveTunnel()`
- `handleConfirmResetFlow()`
- `handleConfirmResetTunnelFlow()`
4. Batch progress/result UX
- Nodes already have a model to copy:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx`
- `upgradeProgress`
- websocket `upgrade_progress`
- Forward batch abstraction:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward/batch-actions.ts`
- likely extend return shape if backend can expose richer progress/events
- API endpoints involved:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/api/index.ts`
- `batchDeleteForwards()`
- `batchPauseForwards()`
- `batchResumeForwards()`
- `batchRedeployForwards()`
- `batchChangeTunnel()`
- `batchDeleteTunnels()`
- `batchRedeployTunnels()`
- `batchDeleteNodes()`
- `batchUpgradeNodes()`
5. Search/filter behavior normalization
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/user.tsx`
- `loadUsers()`
- `handleSearch()`
- search input around lines ~718+
- Nodes/tunnels/forwards already have memo-based client filtering and are the pattern to follow.
d) Notable risks / edge cases
1. Dashboard polling could spam toasts
- `checkExpirationNotifications()` runs inside package load flow.
- If polling is added naively, expiration toasts could repeat or localStorage notification keys could churn unexpectedly.
2. Dashboard chart semantics
- `statisticsFlows` appears to be a snapshot list, not a stream API.
- Frequent polling may redraw aggressively and may need dedupe/staleness handling.
3. Node websocket auth/security
- Realtime URL sends token in query string (`secret=${getToken()}`) in:
- `/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node/use-node-realtime.ts`
- Any changes should be careful not to break backend expectations.
4. Realtime/local state merge complexity
- `loadNodes()` normalizes nodes and clears `systemInfo`.
- If partial refetch is introduced, merging fetched node metadata with live websocket state must avoid wiping online metrics/progress.
5. Batch APIs may not provide granular progress
- Current batch helpers mostly return summary counts, not per-item progress.
- True progress UI may require backend support, not only frontend work.
6. Forward toggle consistency
- `handleServiceToggle()` optimistically flips `serviceRunning` and later patches `status`.
- If backend returns partial failure or delayed actual state, UI may drift until a refresh.
7. User search behavior currently depends on backend pagination/search
- Moving to fully instant local filtering may conflict with server-side pagination semantics and large datasets.
Bottom line
- Already present: async modal CRUD, no browser reloads, strong node realtime, optimistic forward toggle, instant search/filter on node/tunnel/forward pages.
- Missing/partial for #276: dashboard traffic auto-refresh, consistent partial/local state updates after CRUD, batch progress UI outside node upgrade, and more unified instant-search UX on the user page.
@@ -0,0 +1,41 @@
# Session Context
Session ID: ses_332fdb603ffeYxhpzgAB64sSTN
Commit Message: 开始实施
## Prompts
### Prompt 1
https://github.com/Sagit-chu/flvx/issues/276
计划一下这个issue实现
### Prompt 2
写出 plans/020-ajax-no-refresh-ux.md 的完整实施计划内容
### Prompt 3
开始实施
## Summary
我先把 Phase 1 落地了,重点做了仪表盘自动刷新和节点实时连接兜底,并把计划文档同步更新了。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:55` 现在加入了 5 秒静默轮询、标签页可见性恢复刷新,以及更稳定的加载回调拆分,避免每次刷新都走整套初始化逻辑。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:82` 把过期提醒的去重 key 改成基于用户/隧道到期信息的稳定标识,轮询刷新流量时不会反复弹 toast。
- `vite-frontend/src/pages/node/use-node-realtime.ts:13` 补强了 WebSocket 重连策略:前几次按递增延迟重连,超过阈值后进入长间隔 fallback 模式,而不是彻底停掉。
- `vite-frontend/src/pages/node.tsx:68` 增加了节点列表 merge 逻辑;fallback 刷新时会保留已有 `systemInfo`、升级/复制/回退 loading 状态,避免列表刷新把实时信息清空。
- `vite-frontend/src/pages/node.tsx:1160` 节点页断线提示现在会区分“正在重连”和“已切换为列表自动刷新兜底模式”。
- `vite-frontend/src/vite-env.d.ts:4` 补上了 `VITE_API_BASE` 类型声明,顺手消掉这次构建会碰到的环境变量类型报错。
- `plans/020-ajax-no-refresh-ux.md:19` 已把本次完成的 3 个 Phase 1 checklist 勾掉。
验证情况:
- 运行了 `cd vite-frontend && npm install`
- 运行了 `cd vite-frontend && npm run build`
- 前端构建已通过
这次额外生成了依赖文件:
- `vite-frontend/package-lock.json`
下一步自然就是继续 Phase 2,把 `forward` / `tunnel` / `user` 的“操作后整页数据重拉”改成局部 patch。你要我继续的话,我建议从 `vite-frontend/src/pages/forward.tsx` 开始,因为它已经有一部分本地更新模式,改起来最顺。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
https://github.com/Sagit-chu/flvx/issues/276
计划一下这个issue实现
---
写出 plans/020-ajax-no-refresh-ux.md 的完整实施计划内容
---
开始实施
@@ -0,0 +1,19 @@
我先把 Phase 1 落地了,重点做了仪表盘自动刷新和节点实时连接兜底,并把计划文档同步更新了。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:55` 现在加入了 5 秒静默轮询、标签页可见性恢复刷新,以及更稳定的加载回调拆分,避免每次刷新都走整套初始化逻辑。
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts:82` 把过期提醒的去重 key 改成基于用户/隧道到期信息的稳定标识,轮询刷新流量时不会反复弹 toast。
- `vite-frontend/src/pages/node/use-node-realtime.ts:13` 补强了 WebSocket 重连策略:前几次按递增延迟重连,超过阈值后进入长间隔 fallback 模式,而不是彻底停掉。
- `vite-frontend/src/pages/node.tsx:68` 增加了节点列表 merge 逻辑;fallback 刷新时会保留已有 `systemInfo`、升级/复制/回退 loading 状态,避免列表刷新把实时信息清空。
- `vite-frontend/src/pages/node.tsx:1160` 节点页断线提示现在会区分“正在重连”和“已切换为列表自动刷新兜底模式”。
- `vite-frontend/src/vite-env.d.ts:4` 补上了 `VITE_API_BASE` 类型声明,顺手消掉这次构建会碰到的环境变量类型报错。
- `plans/020-ajax-no-refresh-ux.md:19` 已把本次完成的 3 个 Phase 1 checklist 勾掉。
验证情况:
- 运行了 `cd vite-frontend && npm install`
- 运行了 `cd vite-frontend && npm run build`
- 前端构建已通过
这次额外生成了依赖文件:
- `vite-frontend/package-lock.json`
下一步自然就是继续 Phase 2,把 `forward` / `tunnel` / `user` 的“操作后整页数据重拉”改成局部 patch。你要我继续的话,我建议从 `vite-frontend/src/pages/forward.tsx` 开始,因为它已经有一部分本地更新模式,改起来最顺。
+1
View File
@@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## CONVENTIONS
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
+1 -1
View File
@@ -571,7 +571,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"userId": t.UserID,
"tunnelId": t.TunnelID,
"tunnelName": t.TunnelName,
"status": 1,
"status": t.Status,
"flow": t.Flow,
"num": t.Num,
"expTime": t.ExpTime,
+38 -1
View File
@@ -18,11 +18,12 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(2)
h.jobsWG.Add(3)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
go h.runDailyMaintenanceLoop(ctx)
go h.runNodeRenewalCycleLoop(ctx)
}
func (h *Handler) StopBackgroundJobs() {
@@ -176,3 +177,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
_ = h.repo.DisableUserTunnel(item.ID)
}
}
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
defer h.jobsWG.Done()
for {
wait := durationUntilNextNodeRenewalCycle(time.Now())
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
h.runNodeRenewalCycleJob(time.Now())
}
}
}
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
return next.Sub(now)
}
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
if h == nil || h.repo == nil {
return
}
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
if err != nil {
return
}
_ = advanced
}
@@ -0,0 +1,55 @@
package handler
import (
"database/sql"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
dbPath := t.TempDir() + "/renewal-test.db"
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
nowMs := now.UnixMilli()
nodeID := int64(101)
err = r.DB().Exec(`
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
if err != nil {
t.Fatalf("insert test node: %v", err)
}
quarterNodeID := int64(102)
err = r.DB().Exec(`
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
if err != nil {
t.Fatalf("insert test node: %v", err)
}
h := &Handler{repo: r}
h.runNodeRenewalCycleJob(now)
var anchor sql.NullInt64
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
if err != nil {
t.Fatalf("query expiry_time: %v", err)
}
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
if !anchor.Valid || anchor.Int64 != expectedAnchor {
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
}
}
+38 -6
View File
@@ -261,6 +261,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
defaultString(asString(req["port"]), "1000-65535"),
nullableText(asString(req["interfaceName"])),
nullableText(""),
nullableText(strings.TrimSpace(asString(req["remark"]))),
nullableText(strings.TrimSpace(asString(req["tags"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
asInt(req["http"], 0),
asInt(req["tls"], 0),
asInt(req["socks"], 0),
@@ -326,6 +330,10 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
defaultString(asString(req["port"]), "1000-65535"),
nullableText(asString(req["interfaceName"])),
nullableText(asString(req["extraIPs"])),
nullableText(strings.TrimSpace(asString(req["remark"]))),
nullableText(strings.TrimSpace(asString(req["tags"]))),
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
newHTTP,
newTLS,
newSocks,
@@ -1279,13 +1287,13 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" {
strategy = forward.Strategy
}
if actorRole != 0 {
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
rawSpeedID, hasSpeedID := req["speedId"]
requestedSpeedID := asAnyToInt64Ptr(rawSpeedID)
if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID := requestedSpeedID
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -3380,6 +3388,14 @@ func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
return speedID, nil
}
func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool {
if requested == nil {
return !current.Valid
}
return current.Valid && current.Int64 == *requested
}
func asAnySlice(v interface{}) []interface{} {
if v == nil {
return nil
@@ -3526,6 +3542,22 @@ func nullableText(s string) interface{} {
return s
}
func nullableUnixMilli(v int64) interface{} {
if v <= 0 {
return nil
}
return v
}
func normalizeNodeRenewalCycle(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "month", "quarter", "year":
return strings.ToLower(strings.TrimSpace(v))
default:
return ""
}
}
func nullableInt(v *int64) interface{} {
if v == nil {
return nil
+9
View File
@@ -60,6 +60,10 @@ func (ForwardPort) TableName() string { return "forward_port" }
type Node struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Remark sql.NullString `gorm:"column:remark;type:text"`
Tags sql.NullString `gorm:"column:tags;type:text"`
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
Secret string `gorm:"type:varchar(100);not null"`
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
@@ -336,6 +340,10 @@ type UserBackup struct {
type NodeBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Remark string `json:"remark,omitempty"`
Tags string `json:"tags,omitempty"`
ExpiryTime int64 `json:"expiryTime,omitempty"`
RenewalCycle string `json:"renewalCycle,omitempty"`
Secret string `json:"secret"`
ServerIP string `json:"serverIp"`
ServerIPv4 string `json:"serverIpV4,omitempty"`
@@ -573,6 +581,7 @@ type UserTunnelDetail struct {
UserID int64
TunnelID int64
TunnelName string
Status int
TunnelFlow int
Flow int64
InFlow int64
+123 -21
View File
@@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
m := db.Migrator()
if m.HasTable(&model.Node{}) {
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "Tags", "ExpiryTime", "RenewalCycle"} {
if m.HasColumn(&model.Node{}, field) {
continue
}
@@ -447,7 +447,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta
}
var items []model.UserTunnelDetail
err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed").
Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ?", userID).
@@ -634,7 +634,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
for _, n := range nodes {
items = append(items, map[string]interface{}{
"id": n.ID, "inx": n.Inx, "name": n.Name,
"ip": n.ServerIP, "serverIp": n.ServerIP,
"remark": nullableString(n.Remark),
"tags": nullableString(n.Tags),
"expiryTime": nullableInt64(n.ExpiryTime),
"renewalCycle": nullableString(n.RenewalCycle),
"ip": n.ServerIP, "serverIp": n.ServerIP,
"serverIpV4": nullableString(n.ServerIPV4),
"serverIpV6": nullableString(n.ServerIPV6),
"extraIPs": nullableString(n.ExtraIPs),
@@ -700,25 +704,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
}
type fwdRow struct {
ID int64
UserID int64
UserName string
Name string
TunnelID int64
TunnelName string
RemoteAddr string
Strategy string
InFlow int64
OutFlow int64
CreatedTime int64
Status int
Inx int
SpeedID sql.NullInt64
ID int64
UserID int64
UserName string
Name string
TunnelID int64
TunnelName string
TrafficRatio float64
RemoteAddr string
Strategy string
InFlow int64
OutFlow int64
CreatedTime int64
Status int
Inx int
SpeedID sql.NullInt64
}
var rows []fwdRow
err := r.db.Model(&model.Forward{}).
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Order("forward.inx ASC, forward.id ASC").
Find(&rows).Error
@@ -735,7 +740,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
item := map[string]interface{}{
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
"tunnelTrafficRatio": row.TrafficRatio,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
"inFlow": row.InFlow, "outFlow": row.OutFlow,
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
@@ -1814,11 +1820,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
for _, n := range nodes {
b := model.NodeBackup{
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
Remark: n.Remark.String, Tags: n.Tags.String, RenewalCycle: n.RenewalCycle.String,
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
CreatedTime: n.CreatedTime, Status: n.Status,
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
Inx: n.Inx, IsRemote: n.IsRemote,
}
if n.ExpiryTime.Valid {
b.ExpiryTime = n.ExpiryTime.Int64
}
if n.UpdatedTime.Valid {
b.UpdatedTime = n.UpdatedTime.Int64
}
@@ -2168,6 +2178,10 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
item := model.Node{
ID: n.ID,
Name: n.Name,
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""},
Secret: n.Secret,
ServerIP: n.ServerIP,
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
@@ -2192,7 +2206,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
"name", "remark", "tags", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
}),
@@ -2612,11 +2626,12 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
// ─── Migration ───────────────────────────────────────────────────────
const currentSchemaVersion = 4
const currentSchemaVersion = 5
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding
var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns
func getSchemaVersion(db *gorm.DB) int {
var v model.SchemaVersion
@@ -2680,6 +2695,12 @@ func migrateSchema(db *gorm.DB) error {
}
}
if ver < 5 {
if err := migratePostgresTrafficInt64ColumnsFn(db); err != nil {
return err
}
}
setSchemaVersion(db, currentSchemaVersion)
return nil
}
@@ -2744,6 +2765,87 @@ func migrateSpeedLimitTunnelBinding(db *gorm.DB) error {
return nil
}
func migratePostgresTrafficInt64Columns(db *gorm.DB) error {
if db == nil {
return errors.New("nil db")
}
if db.Dialector.Name() != "postgres" {
return nil
}
type trafficColumn struct {
TableName string
ColumnName string
}
columns := []trafficColumn{
{TableName: "user", ColumnName: "flow"},
{TableName: "user", ColumnName: "in_flow"},
{TableName: "user", ColumnName: "out_flow"},
{TableName: "forward", ColumnName: "in_flow"},
{TableName: "forward", ColumnName: "out_flow"},
{TableName: "statistics_flow", ColumnName: "flow"},
{TableName: "statistics_flow", ColumnName: "total_flow"},
{TableName: "tunnel", ColumnName: "flow"},
{TableName: "user_tunnel", ColumnName: "flow"},
{TableName: "user_tunnel", ColumnName: "in_flow"},
{TableName: "user_tunnel", ColumnName: "out_flow"},
{TableName: "peer_share", ColumnName: "max_bandwidth"},
{TableName: "peer_share", ColumnName: "current_flow"},
}
for _, column := range columns {
if err := alterPostgresColumnToBigIntIfNeeded(db, column.TableName, column.ColumnName); err != nil {
return err
}
}
return nil
}
func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error {
if db == nil {
return errors.New("nil db")
}
if tableName == "" || columnName == "" {
return errors.New("empty table or column name")
}
type columnRow struct {
DataType string `gorm:"column:data_type"`
}
var row columnRow
if err := db.Raw(
`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = ?
AND column_name = ?`,
tableName, columnName,
).Scan(&row).Error; err != nil {
return fmt.Errorf("inspect %s.%s type: %w", tableName, columnName, err)
}
if row.DataType == "" || strings.EqualFold(row.DataType, "bigint") {
return nil
}
if !strings.EqualFold(row.DataType, "integer") {
return nil
}
if err := db.Exec(fmt.Sprintf(
"ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT",
quoteSQLIdentifier(tableName),
quoteSQLIdentifier(columnName),
)).Error; err != nil {
return fmt.Errorf("alter %s.%s to bigint: %w", tableName, columnName, err)
}
return nil
}
func ensurePostgresIDDefaults(db *gorm.DB) error {
if db.Dialector.Name() != "postgres" {
return nil
@@ -3,13 +3,61 @@ package repo
import (
"database/sql"
"errors"
"strings"
"testing"
gsqlite "github.com/glebarez/sqlite"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`
CREATE TABLE node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
)
`).Error; err != nil {
t.Fatalf("create legacy node table: %v", err)
}
if err := prepareSQLiteLegacyColumns(db); err != nil {
t.Fatalf("prepareSQLiteLegacyColumns: %v", err)
}
m := db.Migrator()
for _, field := range []string{"Remark", "Tags", "ExpiryTime", "RenewalCycle"} {
if !m.HasColumn(&model.Node{}, field) {
t.Fatalf("expected node.%s column to exist", field)
}
}
}
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
@@ -250,3 +298,115 @@ func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
}
}
func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
called := 0
originalMigrate := migratePostgresTrafficInt64ColumnsFn
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
migratePostgresTrafficInt64ColumnsFn = originalMigrate
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected traffic bigint migration to run once, got %d", called)
}
var schemaVersion int
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
t.Fatalf("query schema_version: %v", err)
}
if schemaVersion != currentSchemaVersion {
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
}
}
func TestMigrateSchemaReturnsTrafficInt64MigrationError(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
wantErr := errors.New("traffic bigint migration failed")
originalMigrate := migratePostgresTrafficInt64ColumnsFn
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
migratePostgresTrafficInt64ColumnsFn = originalMigrate
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) {
if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") {
t.Fatalf("expected nil db error, got %v", err)
}
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
t.Fatalf("expected empty name error, got %v", err)
}
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
t.Fatalf("expected empty name error, got %v", err)
}
}
@@ -3,6 +3,7 @@ package repo
import (
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
"strings"
@@ -196,12 +197,16 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
}
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
node := model.Node{
Name: name,
Remark: nullStringFromInterface(remark),
Tags: nullStringFromInterface(tags),
ExpiryTime: nullInt64FromInterface(expiryTime),
RenewalCycle: nullStringFromInterface(renewalCycle),
Secret: secret,
ServerIP: serverIP,
ServerIPV4: nullStringFromInterface(serverIPV4),
@@ -239,7 +244,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
return node.Status, node.HTTP, node.TLS, node.Socks, nil
}
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -247,6 +252,10 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"remark": nullStringFromInterface(remark),
"tags": nullStringFromInterface(tags),
"expiry_time": nullInt64FromInterface(expiryTime),
"renewal_cycle": nullStringFromInterface(renewalCycle),
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
@@ -1477,3 +1486,59 @@ func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64
}
return affectedGroupIDs, nil
}
func (r *Repository) AdvanceNodeRenewalCycles(now int64) (int, error) {
if r == nil || r.db == nil {
return 0, nil
}
var nodes []model.Node
if err := r.db.Where("renewal_cycle IS NOT NULL AND renewal_cycle != '' AND expiry_time IS NOT NULL").Find(&nodes).Error; err != nil {
return 0, fmt.Errorf("list nodes with renewal cycle: %w", err)
}
advanced := 0
for _, node := range nodes {
if !node.ExpiryTime.Valid || node.ExpiryTime.Int64 <= 0 {
continue
}
cycleMonths := 0
switch node.RenewalCycle.String {
case "month":
cycleMonths = 1
case "quarter":
cycleMonths = 3
case "year":
cycleMonths = 12
default:
continue
}
anchorTime := node.ExpiryTime.Int64
for anchorTime <= now {
nextAnchor := advanceByMonths(anchorTime, cycleMonths)
if nextAnchor <= anchorTime {
break
}
anchorTime = nextAnchor
}
if anchorTime == node.ExpiryTime.Int64 {
continue
}
if err := r.db.Model(&model.Node{}).Where("id = ?", node.ID).Update("expiry_time", anchorTime).Error; err != nil {
continue
}
advanced++
}
return advanced, nil
}
func advanceByMonths(timestamp int64, months int) int64 {
t := time.Unix(timestamp/1000, 0)
next := t.AddDate(0, months, 0)
return next.UnixMilli()
}
@@ -31,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
`, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
@@ -118,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if got := int64(idFloat); got != userForwardID {
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
}
ratioFloat, ok := item["tunnelTrafficRatio"].(float64)
if !ok {
t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"])
}
if ratioFloat != 2.5 {
t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat)
}
})
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
@@ -1310,6 +1317,30 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
assertCode(t, res, 0)
})
t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) {
if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil {
t.Fatalf("assign forward speed limit: %v", err)
}
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-keep-speed",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-null-values",
@@ -0,0 +1,105 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel enabled: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel disabled: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert enabled user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
`).Error; err != nil {
t.Fatalf("insert disabled user_tunnel: %v", err)
}
body := bytes.NewBufferString(`{"userId":201}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
items, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected array data, got %T", out.Data)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
statusByTunnelID := make(map[int64]int, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("expected object item, got %T", item)
}
tunnelID, ok := obj["tunnelId"].(float64)
if !ok {
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
}
status, ok := obj["status"].(float64)
if !ok {
t.Fatalf("expected status to be float64, got %T", obj["status"])
}
statusByTunnelID[int64(tunnelID)] = int(status)
}
if statusByTunnelID[301] != 1 {
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
}
if statusByTunnelID[302] != 0 {
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
}
}
@@ -0,0 +1,12 @@
# 018 User Tunnel Disable Status Sync
## Checklist
- [x] Inspect the user tunnel permission edit flow and identify why disabling an assigned tunnel appears ineffective.
- [x] Return the real `user_tunnel.status` value from the admin permission list API instead of a hardcoded enabled state.
- [x] Add contract coverage for the user tunnel permission list status mapping and run focused backend verification.
## Test Record
- Command: `cd go-backend && go test ./tests/contract/...`
- Result: passed.
@@ -0,0 +1,21 @@
# 019 Federation Share Traffic Bigint Migration
## Checklist
- [x] Inspect federation share creation failure and identify the PostgreSQL `int4` overflow source.
- [x] Audit other traffic-related legacy PostgreSQL columns that may still be `integer` despite Go models using `int64`.
- [x] Add a schema migration that widens legacy traffic/quota columns from `integer` to `bigint`.
- [x] Add migration tests covering the new schema version branch and error propagation.
- [x] Run focused backend verification for the migration changes.
## Notes
- The reported failing value `536870912000` is 500 GiB in bytes and overflows PostgreSQL `int4`.
- The fix widens historical PostgreSQL traffic columns in `user`, `forward`, `statistics_flow`, `tunnel`, `user_tunnel`, and `peer_share` to `BIGINT` when needed.
## Test Record
- Command: `cd go-backend && go test ./internal/store/repo/...`
- Result: passed.
- Command: `cd go-backend && go test ./tests/contract/...`
- Result: passed.
+164
View File
@@ -0,0 +1,164 @@
# 020 AJAX No-refresh UX
## Objective
- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.
- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.
- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.
## Non-goals
- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.
- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.
- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.
- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.
## Current State
- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.
- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.
- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records.
- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations.
## Design Principles
- Prefer local state patching after successful mutations when the changed record set is known.
- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset.
- Use polling only where realtime transport does not already exist.
- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic.
- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions.
## Checklist
- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`.
- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication.
- [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails.
- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers.
- [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe.
- [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe.
- [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe.
- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations.
- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative.
- [ ] Run focused frontend verification and record the result in this plan after implementation.
## Implementation Plan
### Phase 1 - Dashboard auto-refresh and node realtime resilience
#### 1. Dashboard traffic/statistics auto-refresh
- Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence.
- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`.
- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates.
- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again.
- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker.
#### 2. Dashboard notification safety
- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings.
- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle.
- Ensure refreshes that only change traffic counters do not retrigger expiry toasts.
#### 3. Node realtime hardening
- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget.
- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached.
- If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery.
- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`.
### Phase 2 - Local mutation updates and partial refreshes
#### 4. Shared list-patching helpers
- Add small reusable helpers for common state operations such as:
- replace one item by `id`
- remove one or many items by `id`
- upsert a created or updated item into an ordered list
- preserve derived UI-only fields during server payload merges
- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction.
#### 5. Forward page partial refresh conversion
- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns.
- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure.
- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient.
- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state.
- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations.
#### 6. Tunnel page partial refresh conversion
- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`.
- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload.
- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent.
#### 7. User page partial refresh conversion
- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely.
- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations.
- Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh.
- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues.
### Phase 3 - Batch progress UX and search/filter polish
#### 8. Batch progress UX
- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations.
- Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress.
- If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count.
- If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups.
#### 9. Search and filter responsiveness
- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages.
- Audit the user page separately because it depends on server-side pagination and keyword search.
- If user-page instant filtering is desired, choose one of two explicit strategies:
- keep server-side pagination authoritative and add debounce for keyword-triggered requests, or
- load a larger local dataset only if product requirements accept the cost.
- Do not silently mix partial client filtering with incomplete paginated datasets.
## Risks and Mitigations
- Repeated dashboard polling may spam expiry toasts.
- Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes.
- Node recovery refreshes may wipe websocket-derived metrics.
- Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly.
- Local mutation patching may desynchronize grouped, sorted, or selected views.
- Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state.
- Batch APIs may not expose progress details.
- Mitigation: implement client-side staged progress where possible and document backend gaps where not.
- User-page local updates may conflict with pagination semantics.
- Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation.
## Verification Plan
- Dashboard:
- Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload.
- Confirm hidden-tab pause and visible-tab immediate refresh behavior.
- Confirm expiry toasts do not repeat on every polling cycle.
- Nodes:
- Confirm websocket-driven online/offline transitions still work.
- Simulate websocket interruption and verify reconnect or fallback refresh behavior.
- Confirm recovery does not clear existing live metrics unexpectedly.
- Forwards, tunnels, users:
- Create, edit, delete, enable, disable, and reset flows without browser reload.
- Confirm the affected rows update immediately and other unrelated rows stay stable.
- Confirm selection state, ordering, and modal close behavior remain correct after local patching.
- Batch actions:
- Confirm visible progress or staged status feedback exists during long-running operations.
- Confirm success and failure summaries remain accurate after completion.
- Build:
- Run `cd vite-frontend && npm run build`.
## Rollout Notes
- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain.
- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly.
- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly.
## Test Record
- Command: `cd vite-frontend && npm install`
- Result: passed.
- Command: `cd vite-frontend && npm run build`
- Result: passed.
+6
View File
@@ -0,0 +1,6 @@
# Node Remarks, Tags, and Expiry Plan
- [x] Review issue #246 and inspect current node backend/frontend flow
- [x] Extend node persistence and API payloads with remark, tags, and expiry fields
- [x] Update node management UI to edit, display, and search the new metadata
- [x] Verify the backend and frontend still build successfully
@@ -0,0 +1,6 @@
# Node Expiry Highlights And Dashboard Reminders Plan
- [x] Review current node page and dashboard data flow for expiry-related hooks
- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management
- [x] Load node expiry data on the dashboard for admins and render reminder card
- [x] Verify frontend build and mark the plan complete
@@ -0,0 +1,6 @@
# Forward Page Tunnel Traffic Ratio Plan
- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support
- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test
- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes
- [x] Verify targeted backend tests and frontend build, then mark the plan complete
@@ -0,0 +1,7 @@
# Node Renewal Cycle And Schema Fix Plan
- [x] Review the node schema migration path and current expiry implementation
- [x] Backfill legacy node tables with the new metadata columns so old SQLite installs do not fail
- [x] Replace one-off node expiry UX with recurring renewal cycle fields (month/quarter/year)
- [x] Update node reminders and dashboard cards to use recurring renewal calculations
- [x] Verify backend and frontend changes, then complete the plan
+7
View File
@@ -0,0 +1,7 @@
# Node Renewal Auto-Advance Plan
- [x] Review existing background job infrastructure and decide integration points
- [x] Add Repository method to advance node renewal anchor times
- [x] Add backend background worker that runs every 6 hours to advance overdue cycles
- [x] Add unit tests for renewal cycle advancement logic
- [x] Run backend verification and update plan checklist
+2
View File
@@ -65,6 +65,8 @@ export const getUserPackageInfo = () =>
export const createNode = (data: NodeMutationPayload) =>
Network.post("/node/create", data);
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
export const getDashboardNodeExpiryList = () =>
Network.post<NodeApiItem[]>("/node/list", {});
export const updateNode = (data: NodeMutationPayload) =>
Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
+9
View File
@@ -3,6 +3,10 @@ export interface NodeApiItem {
name: string;
status: number;
inx?: number;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
syncError?: string;
[key: string]: unknown;
}
@@ -44,6 +48,7 @@ export interface ForwardApiItem {
name: string;
status: number;
tunnelName?: string;
tunnelTrafficRatio?: number;
inIp?: string;
inPort?: number;
remoteAddr?: string;
@@ -214,6 +219,10 @@ export interface NodeMutationPayload {
name?: string;
status?: number;
inx?: number;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
+172
View File
@@ -14,9 +14,15 @@ import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card";
import {
formatNodeRenewalTime,
getNodeRenewalCycleLabel,
getNodeRenewalSnapshot,
} from "@/pages/node/renewal";
import {
useDashboardData,
type DashboardForward as Forward,
type DashboardNodeExpiryItem,
type DashboardUserTunnel as UserTunnel,
} from "@/pages/dashboard/use-dashboard-data";
@@ -34,6 +40,7 @@ export default function DashboardPage() {
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
} = useDashboardData();
@@ -70,6 +77,123 @@ export default function DashboardPage() {
return value.toString();
};
const getNodeExpiryStatus = (
nextDueTime?: number,
renewalState: "unset" | "expired" | "dueSoon" | "scheduled" = "unset",
) => {
if (!nextDueTime || renewalState === "unset") {
return {
label: "未设置",
badgeClassName:
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
nextDueTime: undefined as number | undefined,
};
}
const diffDays = Math.ceil(
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
);
if (renewalState === "expired" || diffDays <= 0) {
return {
label: "已逾期",
badgeClassName:
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
nextDueTime,
};
}
if (diffDays === 1) {
return {
label: "明天到期",
badgeClassName:
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
nextDueTime,
};
}
return {
label: `${diffDays}天后到期`,
badgeClassName:
diffDays <= 7
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
nextDueTime,
};
};
const parseNodeTags = (tags?: string) => {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
};
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
const renewalSnapshot = getNodeRenewalSnapshot(
node.expiryTime,
node.renewalCycle,
);
const expiryStatus = getNodeExpiryStatus(
renewalSnapshot.nextDueTime,
renewalSnapshot.state,
);
const tags = parseNodeTags(node.tags);
return (
<div
key={node.id}
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{node.name}
</div>
<div className="mt-1 text-xs text-default-500">
节点 ID: {node.id}
</div>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
>
{expiryStatus.label}
</span>
</div>
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
{formatNodeRenewalTime(renewalSnapshot.nextDueTime)}
</div>
<div className="mt-1 text-xs text-default-500">
{getNodeRenewalCycleLabel(node.renewalCycle)}
</div>
{node.remark?.trim() && (
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
{node.remark.trim()}
</p>
)}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={`${node.id}-${tag}`}
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
>
#{tag}
</span>
))}
</div>
)}
</div>
);
};
// 处理24小时流量统计数据
const processFlowChartData = () => {
// 生成最近24小时的时间数组(从当前小时往前推24小时)
@@ -661,6 +785,54 @@ export default function DashboardPage() {
statisticsFlowsCount={statisticsFlows.length}
/>
{isAdmin && nodeExpiryReminders.length > 0 && (
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
<CardHeader className="pb-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
<svg
aria-hidden="true"
className="h-5 w-5"
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.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
fillRule="evenodd"
/>
</svg>
</div>
<div>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
节点到期提醒
</h2>
<p className="text-sm text-default-500">
展示 7
天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
</p>
</div>
</div>
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
{nodeExpiryReminders.length} 个提醒
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
</div>
{nodeExpiryReminders.length > 6 && (
<p className="mt-4 text-xs text-default-500">
还有 {nodeExpiryReminders.length - 6}{" "}
个节点未展开显示,可前往节点页面继续处理。
</p>
)}
</CardBody>
</Card>
)}
{/* 隧道权限 - 管理员不显示 */}
{!isAdmin && (
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
@@ -1,13 +1,15 @@
import type { ForwardApiItem } from "@/api/types";
import type { ForwardApiItem, NodeApiItem } from "@/api/types";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import {
getAnnouncement,
getDashboardNodeExpiryList,
getUserPackageInfo,
type AnnouncementData,
} from "@/api";
import { getNodeRenewalSnapshot } from "@/pages/node/renewal";
import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo {
@@ -52,12 +54,47 @@ export interface DashboardStatisticsFlow {
time: string;
}
export interface DashboardNodeExpiryItem {
id: number;
name: string;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
}
const normalizeDashboardRenewalCycle = (
value: unknown,
): DashboardNodeExpiryItem["renewalCycle"] => {
return value === "month" || value === "quarter" || value === "year"
? value
: "";
};
const DASHBOARD_POLL_INTERVAL_MS = 5000;
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
"dashboard:last-expiration-notification";
const buildExpirationNotificationKey = (
userInfo: DashboardUserInfo,
tunnels: DashboardUserTunnel[],
) => {
const userExpTime = userInfo.expTime ?? "permanent";
const tunnelExpirationKey = [...tunnels]
.map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`)
.sort()
.join("|");
return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;
};
interface DashboardDataState {
loading: boolean;
userInfo: DashboardUserInfo;
userTunnels: DashboardUserTunnel[];
forwardList: DashboardForward[];
statisticsFlows: DashboardStatisticsFlow[];
nodeExpiryReminders: DashboardNodeExpiryItem[];
isAdmin: boolean;
announcement: AnnouncementData | null;
}
@@ -66,8 +103,10 @@ const checkExpirationNotifications = (
userInfo: DashboardUserInfo,
tunnels: DashboardUserTunnel[],
) => {
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
const lastNotified = localStorage.getItem("lastNotified");
const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);
const lastNotified = localStorage.getItem(
EXPIRATION_NOTIFICATION_STORAGE_KEY,
);
if (lastNotified === notificationKey) {
return;
@@ -148,7 +187,7 @@ const checkExpirationNotifications = (
});
if (hasNotification) {
localStorage.setItem("lastNotified", notificationKey);
localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);
}
};
@@ -174,6 +213,43 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
}));
};
const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
const now = Date.now();
const warningWindowMs = 7 * 24 * 60 * 60 * 1000;
return (items || [])
.map((item) => ({
id: item.id,
name: item.name || "",
remark: typeof item.remark === "string" ? item.remark : "",
tags: typeof item.tags === "string" ? item.tags : "",
renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle),
expiryTime:
typeof item.expiryTime === "number" && item.expiryTime > 0
? item.expiryTime
: undefined,
}))
.filter((item) => {
if (!item.expiryTime || !item.renewalCycle) return false;
const snapshot = getNodeRenewalSnapshot(
item.expiryTime,
item.renewalCycle,
);
if (!snapshot.nextDueTime) return false;
return snapshot.nextDueTime <= now + warningWindowMs;
})
.sort((a, b) => {
const aDue =
getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0;
const bDue =
getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0;
return aDue - bDue;
});
};
export const useDashboardData = (): DashboardDataState => {
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
@@ -184,64 +260,174 @@ export const useDashboardData = (): DashboardDataState => {
const [statisticsFlows, setStatisticsFlows] = useState<
DashboardStatisticsFlow[]
>([]);
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
DashboardNodeExpiryItem[]
>([]);
const [isAdmin, setIsAdmin] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
null,
);
const isMountedRef = useRef(true);
const packageRequestInFlightRef = useRef(false);
const nodeExpiryRequestInFlightRef = useRef(false);
useEffect(() => {
const loadAnnouncement = async () => {
try {
const res = await getAnnouncement();
const applyPackageData = useCallback(
(data: {
userInfo?: DashboardUserInfo;
tunnelPermissions?: DashboardUserTunnel[];
forwards?: ForwardApiItem[];
statisticsFlows?: DashboardStatisticsFlow[];
}) => {
const normalizedTunnelPermissions = normalizeTunnelPermissions(
data.tunnelPermissions || [],
);
const normalizedForwards = normalizeForwards(data.forwards || []);
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
}
} catch {}
};
if (!isMountedRef.current) {
return;
}
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
setUserTunnels(normalizedTunnelPermissions);
setForwardList(normalizedForwards);
setStatisticsFlows(data.statisticsFlows || []);
checkExpirationNotifications(
data.userInfo || ({} as DashboardUserInfo),
normalizedTunnelPermissions,
);
},
[],
);
const loadPackageData = useCallback(
async ({ silent = false, notifyOnError = false } = {}) => {
if (packageRequestInFlightRef.current) {
return;
}
packageRequestInFlightRef.current = true;
if (!silent && isMountedRef.current) {
setLoading(true);
}
const loadPackageData = async () => {
setLoading(true);
try {
const res = await getUserPackageInfo();
if (res.code === 0) {
const data = res.data;
const normalizedTunnelPermissions = normalizeTunnelPermissions(
data.tunnelPermissions || [],
);
const normalizedForwards = normalizeForwards(data.forwards || []);
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
setUserTunnels(normalizedTunnelPermissions);
setForwardList(normalizedForwards);
setStatisticsFlows(data.statisticsFlows || []);
checkExpirationNotifications(
data.userInfo,
normalizedTunnelPermissions,
);
} else {
applyPackageData(res.data || {});
} else if (notifyOnError) {
toast.error(res.msg || "获取套餐信息失败");
}
} catch {
toast.error("获取套餐信息失败");
if (notifyOnError) {
toast.error("获取套餐信息失败");
}
} finally {
setLoading(false);
packageRequestInFlightRef.current = false;
if (!silent && isMountedRef.current) {
setLoading(false);
}
}
},
[applyPackageData],
);
const loadAnnouncement = useCallback(async () => {
try {
const res = await getAnnouncement();
if (!isMountedRef.current) {
return;
}
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
} else {
setAnnouncement(null);
}
} catch {
if (isMountedRef.current) {
setAnnouncement(null);
}
}
}, []);
const loadNodeExpiryData = useCallback(async () => {
if (nodeExpiryRequestInFlightRef.current) {
return;
}
nodeExpiryRequestInFlightRef.current = true;
try {
const res = await getDashboardNodeExpiryList();
if (!isMountedRef.current) {
return;
}
if (res.code === 0 && Array.isArray(res.data)) {
setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data));
}
} catch {
} finally {
nodeExpiryRequestInFlightRef.current = false;
}
}, []);
useEffect(() => {
isMountedRef.current = true;
const adminFlag = getAdminFlag();
setIsAdmin(adminFlag);
void loadPackageData({ notifyOnError: true });
void loadAnnouncement();
if (adminFlag) {
void loadNodeExpiryData();
}
localStorage.setItem("e", "/dashboard");
return () => {
isMountedRef.current = false;
};
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void loadPackageData({ silent: true });
if (isAdmin) {
void loadNodeExpiryData();
}
}
};
setLoading(true);
setUserInfo({} as DashboardUserInfo);
setUserTunnels([]);
setForwardList([]);
setStatisticsFlows([]);
setIsAdmin(getAdminFlag());
const interval = window.setInterval(() => {
if (document.visibilityState !== "visible") {
return;
}
loadPackageData();
loadAnnouncement();
localStorage.setItem("e", "/dashboard");
}, []);
void loadPackageData({ silent: true });
if (isAdmin) {
void loadNodeExpiryData();
}
}, DASHBOARD_POLL_INTERVAL_MS);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(interval);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
return {
loading,
@@ -249,6 +435,7 @@ export const useDashboardData = (): DashboardDataState => {
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
};
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {
toastVariant: "success" | "error";
toastMessage: string;
shouldRefresh: boolean;
progressPercent?: number;
progressLabel?: string;
closeDeleteModal?: boolean;
closeChangeTunnelModal?: boolean;
resetTargetTunnel?: boolean;
@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (
return {
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
closeDeleteModal: true,
};
} catch (error) {
@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (
: `成功停用 ${summary.successCount} 项`,
),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`,
};
} catch (error) {
return {
@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (
return {
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
};
} catch (error) {
return {
@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (
return {
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
closeChangeTunnelModal: true,
resetTargetTunnel: true,
};
+403 -15
View File
@@ -59,16 +59,28 @@ import {
getRemoteSyncErrorMessage,
} from "@/pages/node/display";
import { tryCopyInstallCommand } from "@/pages/node/install-command";
import {
formatNodeRenewalTime,
getNodeRenewalCycleLabel,
getNodeRenewalSnapshot,
type NodeRenewalCycle,
} from "@/pages/node/renewal";
import { buildNodeSystemInfo } from "@/pages/node/system-info";
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;
interface Node {
id: number;
inx?: number;
name: string;
remark?: string;
tags?: string;
expiryTime?: number;
renewalCycle?: NodeRenewalCycle;
ip: string;
serverIp: string;
serverIpV4?: string;
@@ -103,6 +115,10 @@ interface Node {
interface NodeForm {
id: number | null;
name: string;
remark: string;
tags: string;
expiryTime: number;
renewalCycle: NodeRenewalCycle;
serverHost: string;
serverIpV4: string;
serverIpV6: string;
@@ -116,6 +132,98 @@ interface NodeForm {
socks: number; // 0 关 1 开
}
const EXPIRING_SOON_DAYS = 7;
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
const getNodeReminderEnabled = (node: Node): boolean => {
return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;
};
const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {
const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);
if (renewal.state === "unset") {
return {
state: "permanent" as NodeExpiryState,
label: "未设置续费周期",
tone: "default" as const,
accentClassName: "",
bannerClassName: "",
isHighlighted: false,
sortWeight: 3,
nextDueTime: undefined,
};
}
if (renewal.state === "expired") {
return {
state: "expired" as NodeExpiryState,
label: "已过期",
tone: "danger" as const,
accentClassName:
"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20",
bannerClassName:
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
isHighlighted: true,
sortWeight: 0,
nextDueTime: renewal.nextDueTime,
};
}
if (renewal.state === "dueSoon") {
return {
state: "expiringSoon" as NodeExpiryState,
label: renewal.label,
tone: "warning" as const,
accentClassName:
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
bannerClassName:
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
isHighlighted: true,
sortWeight: 1,
nextDueTime: renewal.nextDueTime,
};
}
return {
state: "healthy" as NodeExpiryState,
label: renewal.label,
tone: "success" as const,
accentClassName: "",
bannerClassName: "",
isHighlighted: false,
sortWeight: 2,
nextDueTime: renewal.nextDueTime,
};
};
const normalizeNodeTags = (tags?: string): string[] => {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
};
const mergeNodeRealtimeState = (
incomingNode: Node,
existingNode?: Node,
): Node => {
return {
...incomingNode,
systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,
copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,
upgradeLoading:
existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,
rollbackLoading:
existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,
};
};
const SortableItem = ({
id,
children,
@@ -150,7 +258,8 @@ const SortableItem = ({
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-hidden"
className="overflow-hidden h-full"
{...listeners}
>
{children(listeners)}
</div>
@@ -165,6 +274,8 @@ export default function NodePage() {
"node-search-keyword",
"",
);
const [nodeFilterMode, setNodeFilterMode] =
useLocalStorageState<NodeFilterMode>("node-expiry-filter-mode", "all");
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
@@ -178,6 +289,10 @@ export default function NodePage() {
const [form, setForm] = useState<NodeForm>({
id: null,
name: "",
remark: "",
tags: "",
expiryTime: 0,
renewalCycle: "",
serverHost: "",
serverIpV4: "",
serverIpV6: "",
@@ -251,8 +366,13 @@ export default function NodePage() {
});
// 加载节点列表
const loadNodes = useCallback(async () => {
setLoading(true);
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) {
setLoading(true);
}
try {
const res = await getNodeList();
@@ -270,7 +390,13 @@ export default function NodePage() {
copyLoading: false,
}));
setNodeList(nodesData);
setNodeList((prev) => {
const previousById = new Map(prev.map((node) => [node.id, node]));
return nodesData.map((node) =>
mergeNodeRealtimeState(node, previousById.get(node.id)),
);
});
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
const hasDbOrdering = nodesData.some(
@@ -292,12 +418,18 @@ export default function NodePage() {
);
}
} else {
toast.error(res.msg || "加载节点列表失败");
if (!silent) {
toast.error(res.msg || "加载节点列表失败");
}
}
} catch {
toast.error("网络错误,请重试");
if (!silent) {
toast.error("网络错误,请重试");
}
} finally {
setLoading(false);
if (!silent) {
setLoading(false);
}
}
}, []);
@@ -370,7 +502,7 @@ export default function NodePage() {
}
};
const { wsConnected, wsConnecting } = useNodeRealtime({
const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({
onMessage: handleWebSocketMessage,
});
@@ -378,6 +510,22 @@ export default function NodePage() {
loadNodes();
}, [loadNodes]);
useEffect(() => {
if (!usingPollingFallback) {
return;
}
void loadNodes({ silent: true });
const interval = window.setInterval(() => {
void loadNodes({ silent: true });
}, NODE_FALLBACK_REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [loadNodes, usingPollingFallback]);
// 格式化速度
const formatSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond === 0) return "0 B/s";
@@ -527,6 +675,13 @@ export default function NodePage() {
newErrors.name = "节点名称长度不能超过50位";
}
if (
(form.renewalCycle && !form.expiryTime) ||
(!form.renewalCycle && form.expiryTime)
) {
newErrors.expiryTime = "请同时设置续费周期和续费基准时间";
}
const v4 = form.serverIpV4.trim();
const v6 = form.serverIpV6.trim();
const host = form.serverHost.trim();
@@ -586,6 +741,10 @@ export default function NodePage() {
setForm({
id: node.id,
name: node.name,
remark: node.remark || "",
tags: node.tags || "",
expiryTime: node.expiryTime || 0,
renewalCycle: node.renewalCycle || "",
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
@@ -821,6 +980,10 @@ export default function NodePage() {
const { serverHost, ...rest } = form;
const data = {
...rest,
remark: form.remark.trim(),
tags: form.tags.trim(),
expiryTime: form.expiryTime,
renewalCycle: form.renewalCycle,
extraIPs: form.extraIPs,
serverIp:
form.serverIpV4?.trim() ||
@@ -842,6 +1005,10 @@ export default function NodePage() {
? {
...n,
name: form.name,
remark: form.remark.trim(),
tags: form.tags.trim(),
expiryTime: form.expiryTime,
renewalCycle: form.renewalCycle,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
@@ -878,6 +1045,10 @@ export default function NodePage() {
setForm({
id: null,
name: "",
remark: "",
tags: "",
expiryTime: 0,
renewalCycle: "",
serverHost: "",
serverIpV4: "",
serverIpV6: "",
@@ -1010,6 +1181,23 @@ export default function NodePage() {
}),
);
const nodeExpiryStats = useMemo(() => {
return nodeList.reduce(
(acc, node) => {
const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
if (meta.state === "expired") acc.expired += 1;
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
if (getNodeReminderEnabled(node)) {
acc.withExpiry += 1;
}
return acc;
},
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
);
}, [nodeList]);
// 根据排序顺序获取节点列表
const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
@@ -1022,13 +1210,43 @@ export default function NodePage() {
filteredNodes = filteredNodes.filter(
(n) =>
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
(n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||
(n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
);
}
if (nodeFilterMode !== "all") {
filteredNodes = filteredNodes.filter((node) => {
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
);
switch (nodeFilterMode) {
case "expiringSoon":
return expiryMeta.state === "expiringSoon";
case "expired":
return expiryMeta.state === "expired";
case "withExpiry":
return getNodeReminderEnabled(node);
default:
return true;
}
});
}
const sortedByDb = [...filteredNodes].sort((a, b) => {
const expiryDiff =
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
if (expiryDiff !== 0) {
return expiryDiff;
}
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -1060,7 +1278,7 @@ export default function NodePage() {
}
return sortedByDb;
}, [nodeList, nodeOrder, searchKeyword]);
}, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);
const sortableNodeIds = useMemo(
() => sortedNodes.map((n) => n.id),
@@ -1070,15 +1288,43 @@ export default function NodePage() {
return (
<AnimatedPage className="px-3 lg:px-6 py-8">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索节点名称或IP"
placeholder="搜索名称、IP、备注或标签"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
<Select
className="w-full max-w-sm lg:w-56"
label="到期筛选"
selectedKeys={[nodeFilterMode]}
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| NodeFilterMode
| undefined;
setNodeFilterMode(selected || "all");
}}
>
<SelectItem key="all" textValue="全部节点">
全部节点
</SelectItem>
<SelectItem key="expiringSoon" textValue="7天内到期">
7天内续费 ({nodeExpiryStats.expiringSoon})
</SelectItem>
<SelectItem key="expired" textValue="已过期">
已逾期 ({nodeExpiryStats.expired})
</SelectItem>
<SelectItem key="withExpiry" textValue="已设置到期时间">
已启用续费提醒 ({nodeExpiryStats.withExpiry})
</SelectItem>
</Select>
</div>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
@@ -1162,7 +1408,11 @@ export default function NodePage() {
className="mb-4"
color="warning"
description={
wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..."
wsConnecting
? "监控连接中..."
: usingPollingFallback
? "监控连接已断开,已切换为列表自动刷新兜底模式。"
: "监控连接已断开,正在重连..."
}
variant="flat"
/>
@@ -1185,13 +1435,17 @@ export default function NodePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedNodes.map((node) => {
const isRemoteNode = node.isRemote === 1;
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
);
return (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`}
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -1253,14 +1507,67 @@ export default function NodePage() {
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3 flex-1 flex flex-col">
{isRemoteNode && node.syncError && (
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{getRemoteSyncErrorMessage(node.syncError)}
</div>
)}
{expiryMeta.isHighlighted && (
<div
className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}
>
<span>节点到期提醒</span>
<span>{expiryMeta.label}</span>
</div>
)}
{/* 基础信息 */}
<div className="space-y-2 mb-4">
{(node.remark?.trim() || node.tags?.trim()) && (
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
{node.remark?.trim() && (
<div
className="text-xs leading-5 text-default-700 line-clamp-1 break-all"
title={node.remark.trim()}
>
{node.remark.trim()}
</div>
)}
{normalizeNodeTags(node.tags).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{normalizeNodeTags(node.tags).map((tag) => (
<Chip
key={`${node.id}-${tag}`}
className="text-[11px]"
color="secondary"
size="sm"
variant="flat"
>
#{tag}
</Chip>
))}
</div>
)}
</div>
)}
{node.expiryTime &&
node.expiryTime > 0 &&
node.renewalCycle && (
<div className="flex justify-between items-center text-sm">
<span className="text-default-600">
下次续费
</span>
<Chip
className="text-[11px]"
color={expiryMeta.tone}
size="sm"
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
variant="flat"
>
{expiryMeta.label}
</Chip>
</div>
)}
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">
IP
@@ -1458,7 +1765,7 @@ export default function NodePage() {
)}
{/* 操作按钮 */}
<div className="space-y-1.5">
<div className="space-y-1.5 mt-auto">
{!isRemoteNode && (
<div className="grid grid-cols-3 gap-1.5">
<Button
@@ -1562,6 +1869,87 @@ export default function NodePage() {
}
/>
<Textarea
description="可记录供应商、用途、续费说明等补充信息"
label="备注"
maxRows={4}
minRows={3}
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
value={form.remark}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, remark: e.target.value }))
}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
description="多个标签用逗号分隔,可用于搜索过滤"
label="标签"
placeholder="例如: 年付,日本中转,测试机"
value={form.tags}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({ ...prev, tags: e.target.value }))
}
/>
<Select
label="续费周期"
placeholder="选择续费周期"
selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}
variant="bordered"
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as
| NodeRenewalCycle
| undefined;
setForm((prev) => ({
...prev,
renewalCycle: selected || "",
}));
}}
>
<SelectItem key="month" textValue="month">
月付
</SelectItem>
<SelectItem key="quarter" textValue="quarter">
季付
</SelectItem>
<SelectItem key="year" textValue="year">
年付
</SelectItem>
</Select>
</div>
<Input
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime}
label="续费基准时间"
type="datetime-local"
value={
form.expiryTime > 0
? new Date(form.expiryTime).toISOString().slice(0, 16)
: ""
}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
/>
<Alert
color="primary"
description="例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。"
variant="flat"
/>
<Input
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
errorMessage={errors.serverHost}
+118
View File
@@ -0,0 +1,118 @@
export type NodeRenewalCycle = "" | "month" | "quarter" | "year";
export interface NodeRenewalSnapshot {
cycle: NodeRenewalCycle;
anchorTime?: number;
nextDueTime?: number;
diffDays?: number;
state: "unset" | "expired" | "dueSoon" | "scheduled";
label: string;
}
const addMonths = (timestamp: number, months: number): number => {
const date = new Date(timestamp);
const next = new Date(date);
next.setMonth(next.getMonth() + months);
return next.getTime();
};
const cycleToMonths = (cycle: NodeRenewalCycle): number => {
switch (cycle) {
case "month":
return 1;
case "quarter":
return 3;
case "year":
return 12;
default:
return 0;
}
};
export const getNodeRenewalCycleLabel = (cycle?: string): string => {
switch (cycle) {
case "month":
return "月付";
case "quarter":
return "季付";
case "year":
return "年付";
default:
return "未设置";
}
};
export const getNodeRenewalSnapshot = (
anchorTime?: number,
cycle?: string,
warningDays = 7,
): NodeRenewalSnapshot => {
const normalizedCycle =
cycle === "month" || cycle === "quarter" || cycle === "year" ? cycle : "";
if (!anchorTime || anchorTime <= 0 || !normalizedCycle) {
return {
cycle: normalizedCycle,
anchorTime: anchorTime && anchorTime > 0 ? anchorTime : undefined,
state: "unset",
label: "未设置续费周期",
};
}
const intervalMonths = cycleToMonths(normalizedCycle);
let nextDueTime = anchorTime;
while (nextDueTime < Date.now()) {
const advanced = addMonths(nextDueTime, intervalMonths);
if (advanced === nextDueTime) {
break;
}
nextDueTime = advanced;
}
const diffDays = Math.ceil(
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
);
if (diffDays <= 0) {
return {
cycle: normalizedCycle,
anchorTime,
nextDueTime,
diffDays,
state: "expired",
label: "今天到期",
};
}
if (diffDays <= warningDays) {
return {
cycle: normalizedCycle,
anchorTime,
nextDueTime,
diffDays,
state: "dueSoon",
label: diffDays === 1 ? "明天续费" : `${diffDays}天后续费`,
};
}
return {
cycle: normalizedCycle,
anchorTime,
nextDueTime,
diffDays,
state: "scheduled",
label: `${diffDays}天后续费`,
};
};
export const formatNodeRenewalTime = (timestamp?: number): string => {
if (!timestamp || timestamp <= 0) {
return "未设置";
}
return new Date(timestamp).toLocaleString();
};
@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {
enabled?: boolean;
}
const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;
const STANDARD_RECONNECT_DELAY_MS = 3000;
const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;
const FALLBACK_RECONNECT_DELAY_MS = 30000;
const getRealtimeWsUrl = (): string => {
const baseUrl =
axios.defaults.baseURL ||
@@ -34,14 +39,13 @@ export const useNodeRealtime = ({
}: UseNodeRealtimeOptions) => {
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = useState(false);
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttemptsRef = useRef(0);
const onMessageRef = useRef(onMessage);
const maxReconnectAttempts = 5;
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
@@ -58,6 +62,7 @@ export const useNodeRealtime = ({
reconnectAttemptsRef.current = 0;
setWsConnected(false);
setWsConnecting(false);
setUsingPollingFallback(false);
if (!websocketRef.current) {
return;
@@ -103,6 +108,7 @@ export const useNodeRealtime = ({
reconnectAttemptsRef.current = 0;
setWsConnected(true);
setWsConnecting(false);
setUsingPollingFallback(false);
};
websocketRef.current.onmessage = (event) => {
@@ -122,15 +128,27 @@ export const useNodeRealtime = ({
setWsConnected(false);
setWsConnecting(false);
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
if (!enabled) {
return;
}
reconnectAttemptsRef.current += 1;
const exhaustedStandardRetries =
reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;
setUsingPollingFallback(exhaustedStandardRetries);
const reconnectDelay = exhaustedStandardRetries
? FALLBACK_RECONNECT_DELAY_MS
: Math.min(
STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,
MAX_STANDARD_RECONNECT_DELAY_MS,
);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
connect();
}, 3000 * reconnectAttemptsRef.current);
}, reconnectDelay);
};
} catch {
setWsConnected(false);
@@ -153,6 +171,7 @@ export const useNodeRealtime = ({
return {
wsConnected,
wsConnecting,
usingPollingFallback,
reconnectRealtime: connect,
disconnectRealtime: disconnect,
};
+168 -48
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import {
DndContext,
@@ -36,6 +36,7 @@ import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import {
createTunnel,
getTunnelList,
@@ -115,6 +116,29 @@ interface TunnelForm {
status: number;
}
interface BatchProgressState {
active: boolean;
label: string;
percent: number;
}
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
return (items || []).map((tunnel) => ({
...tunnel,
inx: tunnel.inx ?? 0,
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
inIp: tunnel.inIp || "",
flow: tunnel.flow ?? 1,
trafficRatio: tunnel.trafficRatio ?? 1,
status: typeof tunnel.status === "number" ? tunnel.status : 0,
createdTime: tunnel.createdTime || "",
}));
};
export default function TunnelPage() {
const [loading, setLoading] = useState(true);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
@@ -203,6 +227,11 @@ export default function TunnelPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
percent: 0,
});
useEffect(() => {
return () => {
@@ -211,60 +240,81 @@ export default function TunnelPage() {
};
}, []);
useEffect(() => {
loadData();
const applyTunnelList = useCallback((items: Tunnel[]) => {
setTunnels(items);
const hasDbOrdering = items.some(
(tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,
);
if (hasDbOrdering) {
const dbOrder = [...items]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((tunnel) => tunnel.id);
setTunnelOrder(dbOrder);
return;
}
setTunnelOrder(
loadStoredOrder(
TUNNEL_ORDER_KEY,
items.map((tunnel) => tunnel.id),
),
);
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
try {
const [tunnelsRes, nodesRes] = await Promise.all([
getTunnelList(),
getNodeList(),
]);
if (tunnelsRes.code === 0) {
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
...t,
inx: t.inx ?? 0,
}));
setTunnels(tunnelsData);
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
const hasDbOrdering = tunnelsData.some(
(t) => t.inx !== undefined && t.inx !== 0,
);
if (hasDbOrdering) {
const dbOrder = [...tunnelsData]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((t) => t.id);
setTunnelOrder(dbOrder);
} else {
setTunnelOrder(
loadStoredOrder(
"tunnel-order",
tunnelsData.map((t) => t.id),
),
);
}
} else {
toast.error(tunnelsRes.msg || "获取隧道列表失败");
const refreshTunnelList = useCallback(
async (withLoading = true) => {
if (withLoading) {
setLoading(true);
}
try {
const tunnelsRes = await getTunnelList();
if (tunnelsRes.code === 0) {
applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));
} else {
toast.error(tunnelsRes.msg || "获取隧道列表失败");
}
} catch {
toast.error("获取隧道列表失败");
} finally {
if (withLoading) {
setLoading(false);
}
}
},
[applyTunnelList],
);
const refreshNodes = useCallback(async () => {
try {
const nodesRes = await getNodeList();
if (nodesRes.code === 0) {
setNodes(nodesRes.data || []);
} else {
}
} catch {}
}, []);
// 加载所有数据
const loadData = useCallback(async () => {
setLoading(true);
try {
await Promise.all([refreshTunnelList(false), refreshNodes()]);
} catch {
toast.error("加载数据失败");
} finally {
setLoading(false);
}
};
}, [refreshNodes, refreshTunnelList]);
useEffect(() => {
loadData();
}, [loadData]);
// 表单验证
const validateForm = (): boolean => {
@@ -327,7 +377,23 @@ export default function TunnelPage() {
toast.success("删除成功");
setDeleteModalOpen(false);
setTunnelToDelete(null);
loadData();
setTunnels((prev) =>
prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),
);
setTunnelOrder((prev) => {
const next = prev.filter((id) => id !== tunnelToDelete.id);
saveOrder(TUNNEL_ORDER_KEY, next);
return next;
});
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(tunnelToDelete.id);
return next;
});
} else {
toast.error(response.msg || "删除失败");
}
@@ -500,7 +566,7 @@ export default function TunnelPage() {
if (response.code === 0) {
toast.success(isEdit ? "更新成功" : "创建成功");
setModalOpen(false);
loadData();
await refreshTunnelList(false);
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
@@ -725,7 +791,7 @@ export default function TunnelPage() {
setTunnelOrder(newOrder);
saveOrder("tunnel-order", newOrder);
saveOrder(TUNNEL_ORDER_KEY, newOrder);
// 持久化到数据库
try {
@@ -779,6 +845,11 @@ export default function TunnelPage() {
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在删除 ${selectedIds.size} 条隧道...`,
percent: 30,
});
try {
const res = await batchDeleteTunnels(Array.from(selectedIds));
@@ -787,21 +858,42 @@ export default function TunnelPage() {
if (result.failCount === 0) {
toast.success(`成功删除 ${result.successCount} 项`);
setBatchProgress({
active: true,
label: `删除完成:成功 ${result.successCount} 项`,
percent: 100,
});
setTunnels((prev) =>
prev.filter((tunnel) => !selectedIds.has(tunnel.id)),
);
setTunnelOrder((prev) => {
const next = prev.filter((id) => !selectedIds.has(id));
saveOrder(TUNNEL_ORDER_KEY, next);
return next;
});
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
setBatchProgress({
active: true,
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 75,
});
await refreshTunnelList(false);
}
setSelectedIds(new Set());
setSelectMode(false);
setBatchDeleteModalOpen(false);
loadData();
} else {
toast.error(res.msg || "删除失败");
}
} catch (error) {
toast.error(extractApiErrorMessage(error, "删除失败"));
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -809,6 +901,11 @@ export default function TunnelPage() {
const handleBatchRedeploy = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
setBatchProgress({
active: true,
label: `正在重新下发 ${selectedIds.size} 条隧道...`,
percent: 30,
});
try {
const res = await batchRedeployTunnels(Array.from(selectedIds));
@@ -824,13 +921,19 @@ export default function TunnelPage() {
}
setSelectedIds(new Set());
setSelectMode(false);
loadData();
setBatchProgress({
active: true,
label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,
percent: 100,
});
await refreshTunnelList(false);
} else {
toast.error(res.msg || "下发失败");
}
} catch (error) {
toast.error(extractApiErrorMessage(error, "下发失败"));
} finally {
setBatchProgress({ active: false, label: "", percent: 0 });
setBatchLoading(false);
}
};
@@ -1038,6 +1141,23 @@ export default function TunnelPage() {
</div>
</div>
{batchProgress.active && (
<div className="mb-4">
<Alert
color="primary"
description={batchProgress.label}
variant="flat"
/>
<Progress
aria-label={batchProgress.label}
className="mt-3"
color="primary"
size="sm"
value={batchProgress.percent}
/>
</div>
)}
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
+185 -46
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
@@ -66,6 +66,7 @@ import {
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { removeItemsById, replaceItemById } from "@/utils/list-state";
// 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => {
@@ -122,6 +123,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
return inFlow + outFlow;
};
const USER_SEARCH_DEBOUNCE_MS = 250;
const normalizeUserItem = (item: Partial<User>): User => {
return {
id: Number(item.id ?? 0),
name: item.name,
user: String(item.user ?? ""),
status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0),
num: Number(item.num ?? 0),
expTime: item.expTime,
flowResetTime: item.flowResetTime ?? 0,
createdTime: item.createdTime,
inFlow: Number(item.inFlow ?? 0),
outFlow: Number(item.outFlow ?? 0),
};
};
const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
return {
id: Number(item.id ?? 0),
userId: Number(item.userId ?? 0),
tunnelId: Number(item.tunnelId ?? 0),
tunnelName: String(item.tunnelName ?? ""),
status: Number(item.status ?? 0),
flow: Number(item.flow ?? 0),
num: Number(item.num ?? 0),
expTime: Number(item.expTime ?? 0),
flowResetTime: Number(item.flowResetTime ?? 0),
speedId: item.speedId ?? null,
speedLimitName: item.speedLimitName,
inFlow: Number(item.inFlow ?? 0),
outFlow: Number(item.outFlow ?? 0),
tunnelFlow: item.tunnelFlow,
};
};
export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
@@ -136,6 +174,7 @@ export default function UserPage() {
size: 10,
total: 0,
});
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 用户表单相关状态
const {
@@ -259,37 +298,38 @@ export default function UserPage() {
return !speedLimitIds.has(speedId);
};
// 生命周期
useEffect(() => {
loadUsers();
loadTunnels();
loadSpeedLimits();
loadUserGroups();
}, [pagination.current, pagination.size, searchKeyword]);
// 数据加载函数
const loadUsers = async () => {
setLoading(true);
try {
const response = await getAllUsers({
current: pagination.current,
size: pagination.size,
keyword: searchKeyword,
});
const loadUsers = useCallback(
async (keywordOverride?: string) => {
setLoading(true);
try {
const keyword = keywordOverride ?? searchKeyword;
const response = await getAllUsers({
current: pagination.current,
size: pagination.size,
keyword,
});
if (response.code === 0) {
setUsers(Array.isArray(response.data) ? response.data : []);
} else {
toast.error(response.msg || "获取用户列表失败");
if (response.code === 0) {
const nextUsers = Array.isArray(response.data)
? response.data.map((item) => normalizeUserItem(item))
: [];
setUsers(nextUsers);
setPagination((prev) => ({ ...prev, total: nextUsers.length }));
} else {
toast.error(response.msg || "获取用户列表失败");
}
} catch {
toast.error("获取用户列表失败");
} finally {
setLoading(false);
}
} catch {
toast.error("获取用户列表失败");
} finally {
setLoading(false);
}
};
},
[pagination.current, pagination.size],
);
const loadTunnels = async () => {
const loadTunnels = useCallback(async () => {
try {
const response = await getTunnelList();
@@ -297,9 +337,9 @@ export default function UserPage() {
setTunnels(Array.isArray(response.data) ? response.data : []);
}
} catch {}
};
}, []);
const loadSpeedLimits = async () => {
const loadSpeedLimits = useCallback(async () => {
try {
const response = await getSpeedLimitList();
@@ -315,9 +355,9 @@ export default function UserPage() {
setSpeedLimits(speedLimitList);
}
} catch {}
};
}, []);
const loadUserGroups = async () => {
const loadUserGroups = useCallback(async () => {
try {
const response = await getUserGroupList();
@@ -325,15 +365,19 @@ export default function UserPage() {
setUserGroups(Array.isArray(response.data) ? response.data : []);
}
} catch {}
};
}, []);
const loadUserTunnels = async (userId: number) => {
const loadUserTunnels = useCallback(async (userId: number) => {
setTunnelListLoading(true);
try {
const response = await getUserTunnelList({ userId });
if (response.code === 0) {
setUserTunnels(Array.isArray(response.data) ? response.data : []);
setUserTunnels(
Array.isArray(response.data)
? response.data.map((item) => normalizeUserTunnelItem(item))
: [],
);
} else {
toast.error(response.msg || "获取隧道权限列表失败");
}
@@ -342,12 +386,53 @@ export default function UserPage() {
} finally {
setTunnelListLoading(false);
}
};
}, []);
// 生命周期
useEffect(() => {
void loadTunnels();
void loadSpeedLimits();
void loadUserGroups();
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
searchDebounceRef.current = setTimeout(() => {
setPagination((prev) => {
if (prev.current === 1) {
void loadUsers(searchKeyword);
return prev;
}
return { ...prev, current: 1 };
});
}, USER_SEARCH_DEBOUNCE_MS);
return () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
};
}, [loadUsers, searchKeyword]);
// 用户管理操作
const handleSearch = () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
setPagination((prev) => ({ ...prev, current: 1 }));
loadUsers();
void loadUsers(searchKeyword);
};
const handleAdd = () => {
@@ -405,9 +490,17 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("删除成功");
loadUsers();
onDeleteModalClose();
setUsers((prev) => removeItemsById(prev, [userToDelete.id]));
setPagination((prev) => ({
...prev,
total: Math.max(prev.total - 1, 0),
}));
setUserToDelete(null);
if (currentUser?.id === userToDelete.id) {
setCurrentUser(null);
setUserTunnels([]);
}
} else {
toast.error(response.msg || "删除失败");
}
@@ -442,7 +535,26 @@ export default function UserPage() {
if (response.code === 0) {
toast.success(isEdit ? "更新成功" : "创建成功");
onUserModalClose();
loadUsers();
const responseUser = normalizeUserItem((response as any).data || {});
if (
isEdit &&
responseUser.id > 0 &&
pagination.current === 1 &&
!searchKeyword.trim()
) {
setUsers((prev) => replaceItemById(prev, responseUser));
} else if (
!isEdit &&
responseUser.id > 0 &&
pagination.current === 1 &&
!searchKeyword.trim()
) {
setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));
setPagination((prev) => ({ ...prev, total: prev.total + 1 }));
} else {
await loadUsers();
}
} else {
toast.error(response.msg || (isEdit ? "更新失败" : "创建失败"));
}
@@ -500,7 +612,7 @@ export default function UserPage() {
}
toast.success(response.msg || "分配成功");
setBatchTunnelSelections(new Map());
loadUserTunnels(currentUser.id);
await loadUserTunnels(currentUser.id);
} else {
toast.error(response.msg || "分配失败");
}
@@ -546,7 +658,20 @@ export default function UserPage() {
toast.success("更新成功");
onEditTunnelModalClose();
if (currentUser) {
loadUserTunnels(currentUser.id);
const nextTunnel = normalizeUserTunnelItem({
...editTunnelForm,
speedId: normalizeSpeedId(editTunnelForm.speedId),
speedLimitName:
normalizeSpeedId(editTunnelForm.speedId) !== null
? speedLimits.find(
(speedLimit) =>
speedLimit.id ===
normalizeSpeedId(editTunnelForm.speedId),
)?.name
: undefined,
});
setUserTunnels((prev) => replaceItemById(prev, nextTunnel));
}
} else {
toast.error(response.msg || "更新失败");
@@ -572,7 +697,7 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("删除成功");
if (currentUser) {
loadUserTunnels(currentUser.id);
setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id]));
}
onDeleteTunnelModalClose();
setTunnelToDelete(null);
@@ -603,8 +728,16 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("流量重置成功");
onResetFlowModalClose();
const targetUserId = userToReset.id;
setUsers((prev) =>
prev.map((user) =>
user.id === targetUserId
? { ...user, inFlow: 0, outFlow: 0 }
: user,
),
);
setUserToReset(null);
loadUsers(); // 重新加载用户列表
} else {
toast.error(response.msg || "重置失败");
}
@@ -634,10 +767,16 @@ export default function UserPage() {
if (response.code === 0) {
toast.success("隧道流量重置成功");
onResetTunnelFlowModalClose();
const targetTunnelId = tunnelToReset.id;
setUserTunnels((prev) =>
prev.map((userTunnel) =>
userTunnel.id === targetTunnelId
? { ...userTunnel, inFlow: 0, outFlow: 0 }
: userTunnel,
),
);
setTunnelToReset(null);
if (currentUser) {
loadUserTunnels(currentUser.id); // 重新加载隧道权限列表
}
} else {
toast.error(response.msg || "重置失败");
}
+33
View File
@@ -0,0 +1,33 @@
export const removeItemsById = <T extends { id: number }>(
items: T[],
ids: Iterable<number>,
): T[] => {
const idSet = new Set(ids);
return items.filter((item) => !idSet.has(item.id));
};
export const replaceItemById = <T extends { id: number }>(
items: T[],
nextItem: T,
): T[] => {
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
};
export const upsertItemById = <T extends { id: number }>(
items: T[],
nextItem: T,
options?: { prepend?: boolean },
): T[] => {
const existingIndex = items.findIndex((item) => item.id === nextItem.id);
if (existingIndex >= 0) {
return replaceItemById(items, nextItem);
}
if (options?.prepend) {
return [nextItem, ...items];
}
return [...items, nextItem];
};
+1
View File
@@ -2,6 +2,7 @@
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_APP_VERSION?: string;
readonly VITE_GITHUB_REPO?: string;
}