mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56:37 +08:00
Merge branch 'main' into pr/wss-https-fallback
This commit is contained in:
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` 开始,因为它已经有一部分本地更新模式,改起来最顺。
|
||||
@@ -90,7 +90,9 @@ export default function DashboardPage() {
|
||||
};
|
||||
}
|
||||
|
||||
const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const diffDays = Math.ceil(
|
||||
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (renewalState === "expired" || diffDays <= 0) {
|
||||
return {
|
||||
@@ -122,6 +124,7 @@ export default function DashboardPage() {
|
||||
|
||||
const parseNodeTags = (tags?: string) => {
|
||||
if (!tags) return [];
|
||||
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
@@ -150,7 +153,9 @@ export default function DashboardPage() {
|
||||
<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 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}`}
|
||||
@@ -804,7 +809,8 @@ export default function DashboardPage() {
|
||||
节点到期提醒
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||
展示 7
|
||||
天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -819,7 +825,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
{nodeExpiryReminders.length > 6 && (
|
||||
<p className="mt-4 text-xs text-default-500">
|
||||
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
|
||||
还有 {nodeExpiryReminders.length - 6}{" "}
|
||||
个节点未展开显示,可前往节点页面继续处理。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
@@ -231,14 +231,20 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (!item.expiryTime || !item.renewalCycle) return false;
|
||||
const snapshot = getNodeRenewalSnapshot(item.expiryTime, item.renewalCycle);
|
||||
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;
|
||||
const aDue =
|
||||
getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0;
|
||||
const bDue =
|
||||
getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0;
|
||||
|
||||
return aDue - bDue;
|
||||
});
|
||||
@@ -265,31 +271,34 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
const packageRequestInFlightRef = useRef(false);
|
||||
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||
|
||||
const applyPackageData = useCallback((data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
const applyPackageData = useCallback(
|
||||
(data: {
|
||||
userInfo?: DashboardUserInfo;
|
||||
tunnelPermissions?: DashboardUserTunnel[];
|
||||
forwards?: ForwardApiItem[];
|
||||
statisticsFlows?: DashboardStatisticsFlow[];
|
||||
}) => {
|
||||
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||
setUserTunnels(normalizedTunnelPermissions);
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
}, []);
|
||||
checkExpirationNotifications(
|
||||
data.userInfo || ({} as DashboardUserInfo),
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadPackageData = useCallback(
|
||||
async ({ silent = false, notifyOnError = false } = {}) => {
|
||||
|
||||
+280
-249
@@ -538,9 +538,7 @@ const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => {
|
||||
name: forward.name,
|
||||
tunnelId: forward.tunnelId ?? 0,
|
||||
tunnelName: forward.tunnelName || "",
|
||||
tunnelTrafficRatio: normalizeTunnelTrafficRatio(
|
||||
forward.tunnelTrafficRatio,
|
||||
),
|
||||
tunnelTrafficRatio: normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio),
|
||||
inIp: forward.inIp || "",
|
||||
inPort: forward.inPort ?? 0,
|
||||
remoteAddr: forward.remoteAddr || "",
|
||||
@@ -776,7 +774,10 @@ export default function ForwardPage() {
|
||||
currentTunnel.portRangeMin > 0 &&
|
||||
currentTunnel.portRangeMax > 0
|
||||
) {
|
||||
return { min: currentTunnel.portRangeMin, max: currentTunnel.portRangeMax };
|
||||
return {
|
||||
min: currentTunnel.portRangeMin,
|
||||
max: currentTunnel.portRangeMax,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -1036,183 +1037,184 @@ export default function ForwardPage() {
|
||||
return Number.isFinite(shareId) && shareId > 0 ? shareId : null;
|
||||
};
|
||||
|
||||
const mergeFederationShareFlow = useCallback(async (
|
||||
forwardsData: Forward[],
|
||||
): Promise<Forward[]> => {
|
||||
if (forwardsData.length === 0) {
|
||||
return forwardsData;
|
||||
}
|
||||
|
||||
try {
|
||||
const [usageRes, localShareRes] = await Promise.all([
|
||||
getPeerRemoteUsageList(),
|
||||
getPeerShareList(),
|
||||
]);
|
||||
|
||||
const flowByShare = new Map<number, number>();
|
||||
const shareIdsByTunnel = new Map<number, Set<number>>();
|
||||
|
||||
if (usageRes.code === 0 && Array.isArray(usageRes.data)) {
|
||||
usageRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.shareId || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
|
||||
if (Number.isFinite(shareId) && shareId > 0) {
|
||||
const bindings = Array.isArray(item.bindings)
|
||||
? (item.bindings as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
bindings.forEach((binding) => {
|
||||
const tunnelId = Number(binding.tunnelId || 0);
|
||||
const chainType = Number(binding.chainType || 0);
|
||||
|
||||
if (!Number.isFinite(tunnelId) || tunnelId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(chainType) && chainType !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shareSet = shareIdsByTunnel.get(tunnelId);
|
||||
|
||||
if (!shareSet) {
|
||||
shareSet = new Set<number>();
|
||||
shareIdsByTunnel.set(tunnelId, shareSet);
|
||||
}
|
||||
|
||||
shareSet.add(shareId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {
|
||||
localShareRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.id || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (flowByShare.size === 0) {
|
||||
const mergeFederationShareFlow = useCallback(
|
||||
async (forwardsData: Forward[]): Promise<Forward[]> => {
|
||||
if (forwardsData.length === 0) {
|
||||
return forwardsData;
|
||||
}
|
||||
|
||||
const resolveShareIdForForward = (forward: Forward): number | null => {
|
||||
const candidates = new Set<number>();
|
||||
const shareIdFromName = parseShareIdFromTunnelName(
|
||||
forward.tunnelName || "",
|
||||
);
|
||||
try {
|
||||
const [usageRes, localShareRes] = await Promise.all([
|
||||
getPeerRemoteUsageList(),
|
||||
getPeerShareList(),
|
||||
]);
|
||||
|
||||
if (shareIdFromName) {
|
||||
candidates.add(shareIdFromName);
|
||||
}
|
||||
const flowByShare = new Map<number, number>();
|
||||
const shareIdsByTunnel = new Map<number, Set<number>>();
|
||||
|
||||
const tunnelId = Number(forward.tunnelId || 0);
|
||||
const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);
|
||||
if (usageRes.code === 0 && Array.isArray(usageRes.data)) {
|
||||
usageRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.shareId || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
|
||||
if (shareSetByTunnel && shareSetByTunnel.size > 0) {
|
||||
shareSetByTunnel.forEach((shareId) => {
|
||||
if (Number.isFinite(shareId) && shareId > 0) {
|
||||
candidates.add(shareId);
|
||||
const bindings = Array.isArray(item.bindings)
|
||||
? (item.bindings as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
bindings.forEach((binding) => {
|
||||
const tunnelId = Number(binding.tunnelId || 0);
|
||||
const chainType = Number(binding.chainType || 0);
|
||||
|
||||
if (!Number.isFinite(tunnelId) || tunnelId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(chainType) && chainType !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shareSet = shareIdsByTunnel.get(tunnelId);
|
||||
|
||||
if (!shareSet) {
|
||||
shareSet = new Set<number>();
|
||||
shareIdsByTunnel.set(tunnelId, shareSet);
|
||||
}
|
||||
|
||||
shareSet.add(shareId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return null;
|
||||
if (localShareRes.code === 0 && Array.isArray(localShareRes.data)) {
|
||||
localShareRes.data.forEach((item: Record<string, unknown>) => {
|
||||
const shareId = Number(item.id || 0);
|
||||
const currentFlow = Number(item.currentFlow || 0);
|
||||
|
||||
if (
|
||||
Number.isFinite(shareId) &&
|
||||
shareId > 0 &&
|
||||
Number.isFinite(currentFlow) &&
|
||||
currentFlow > 0
|
||||
) {
|
||||
const prev = flowByShare.get(shareId) || 0;
|
||||
|
||||
flowByShare.set(shareId, Math.max(prev, currentFlow));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bestShareId: number | null = null;
|
||||
let bestFlow = 0;
|
||||
if (flowByShare.size === 0) {
|
||||
return forwardsData;
|
||||
}
|
||||
|
||||
candidates.forEach((shareId) => {
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
const resolveShareIdForForward = (forward: Forward): number | null => {
|
||||
const candidates = new Set<number>();
|
||||
const shareIdFromName = parseShareIdFromTunnelName(
|
||||
forward.tunnelName || "",
|
||||
);
|
||||
|
||||
if (shareFlow > bestFlow) {
|
||||
bestFlow = shareFlow;
|
||||
bestShareId = shareId;
|
||||
if (shareIdFromName) {
|
||||
candidates.add(shareIdFromName);
|
||||
}
|
||||
|
||||
const tunnelId = Number(forward.tunnelId || 0);
|
||||
const shareSetByTunnel = shareIdsByTunnel.get(tunnelId);
|
||||
|
||||
if (shareSetByTunnel && shareSetByTunnel.size > 0) {
|
||||
shareSetByTunnel.forEach((shareId) => {
|
||||
if (Number.isFinite(shareId) && shareId > 0) {
|
||||
candidates.add(shareId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestShareId: number | null = null;
|
||||
let bestFlow = 0;
|
||||
|
||||
candidates.forEach((shareId) => {
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
|
||||
if (shareFlow > bestFlow) {
|
||||
bestFlow = shareFlow;
|
||||
bestShareId = shareId;
|
||||
}
|
||||
});
|
||||
|
||||
return bestShareId;
|
||||
};
|
||||
|
||||
const resolvedShareByForwardId = new Map<number, number>();
|
||||
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolveShareIdForForward(forward);
|
||||
|
||||
if (shareId) {
|
||||
resolvedShareByForwardId.set(forward.id, shareId);
|
||||
}
|
||||
});
|
||||
|
||||
return bestShareId;
|
||||
};
|
||||
const forwardCountByShare = new Map<number, number>();
|
||||
|
||||
const resolvedShareByForwardId = new Map<number, number>();
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolveShareIdForForward(forward);
|
||||
if (!shareId || !flowByShare.has(shareId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
resolvedShareByForwardId.set(forward.id, shareId);
|
||||
}
|
||||
});
|
||||
forwardCountByShare.set(
|
||||
shareId,
|
||||
(forwardCountByShare.get(shareId) || 0) + 1,
|
||||
);
|
||||
});
|
||||
|
||||
const forwardCountByShare = new Map<number, number>();
|
||||
return forwardsData.map((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
|
||||
forwardsData.forEach((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
if (!shareId) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
if (!shareId || !flowByShare.has(shareId)) {
|
||||
return;
|
||||
}
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
|
||||
forwardCountByShare.set(
|
||||
shareId,
|
||||
(forwardCountByShare.get(shareId) || 0) + 1,
|
||||
);
|
||||
});
|
||||
if (shareFlow <= 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
return forwardsData.map((forward) => {
|
||||
const shareId = resolvedShareByForwardId.get(forward.id) || null;
|
||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
|
||||
if (!shareId) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
if (directFlow > 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
const shareFlow = flowByShare.get(shareId) || 0;
|
||||
const count = forwardCountByShare.get(shareId) || 1;
|
||||
const estimated = Math.max(1, Math.floor(shareFlow / count));
|
||||
|
||||
if (shareFlow <= 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
|
||||
if (directFlow > 0) {
|
||||
return { ...forward, federationShareFlow: undefined };
|
||||
}
|
||||
|
||||
const count = forwardCountByShare.get(shareId) || 1;
|
||||
const estimated = Math.max(1, Math.floor(shareFlow / count));
|
||||
|
||||
return { ...forward, federationShareFlow: estimated };
|
||||
});
|
||||
} catch {
|
||||
return forwardsData;
|
||||
}
|
||||
}, []);
|
||||
return { ...forward, federationShareFlow: estimated };
|
||||
});
|
||||
} catch {
|
||||
return forwardsData;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getForwardDisplayFlow = (forward: Forward): number => {
|
||||
const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
@@ -1283,45 +1285,48 @@ export default function ForwardPage() {
|
||||
);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = useCallback(async (lod = true) => {
|
||||
setLoading(lod);
|
||||
try {
|
||||
const [tunnelsRes, speedLimitsRes] = await Promise.all([
|
||||
userTunnel(),
|
||||
getSpeedLimitList(),
|
||||
]);
|
||||
const [allTunnelsRes, nodesRes] = await Promise.allSettled([
|
||||
getTunnelList(),
|
||||
getNodeList(),
|
||||
]);
|
||||
const loadData = useCallback(
|
||||
async (lod = true) => {
|
||||
setLoading(lod);
|
||||
try {
|
||||
const [tunnelsRes, speedLimitsRes] = await Promise.all([
|
||||
userTunnel(),
|
||||
getSpeedLimitList(),
|
||||
]);
|
||||
const [allTunnelsRes, nodesRes] = await Promise.allSettled([
|
||||
getTunnelList(),
|
||||
getNodeList(),
|
||||
]);
|
||||
|
||||
await refreshForwardList(false);
|
||||
await refreshForwardList(false);
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
}
|
||||
|
||||
if (
|
||||
allTunnelsRes.status === "fulfilled" &&
|
||||
allTunnelsRes.value.code === 0
|
||||
) {
|
||||
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
|
||||
}
|
||||
|
||||
if (nodesRes.status === "fulfilled" && nodesRes.value.code === 0) {
|
||||
setNodes((nodesRes.value.data || []) as Node[]);
|
||||
}
|
||||
|
||||
if (speedLimitsRes.code === 0) {
|
||||
setSpeedLimits(speedLimitsRes.data || []);
|
||||
}
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (
|
||||
allTunnelsRes.status === "fulfilled" &&
|
||||
allTunnelsRes.value.code === 0
|
||||
) {
|
||||
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
|
||||
}
|
||||
|
||||
if (nodesRes.status === "fulfilled" && nodesRes.value.code === 0) {
|
||||
setNodes((nodesRes.value.data || []) as Node[]);
|
||||
}
|
||||
|
||||
if (speedLimitsRes.code === 0) {
|
||||
setSpeedLimits(speedLimitsRes.data || []);
|
||||
}
|
||||
} catch {
|
||||
toast.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshForwardList]);
|
||||
},
|
||||
[refreshForwardList],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -1389,8 +1394,16 @@ export default function ForwardPage() {
|
||||
newErrors.tunnelId = "请选择关联隧道";
|
||||
}
|
||||
|
||||
if (form.inPort !== null && form.inPort !== undefined && form.inPort > 0 && currentTunnelPortRange) {
|
||||
if (form.inPort < currentTunnelPortRange.min || form.inPort > currentTunnelPortRange.max) {
|
||||
if (
|
||||
form.inPort !== null &&
|
||||
form.inPort !== undefined &&
|
||||
form.inPort > 0 &&
|
||||
currentTunnelPortRange
|
||||
) {
|
||||
if (
|
||||
form.inPort < currentTunnelPortRange.min ||
|
||||
form.inPort > currentTunnelPortRange.max
|
||||
) {
|
||||
newErrors.inPort = `端口 ${currentTunnelPortRange.min}-${currentTunnelPortRange.max} 超出允许范围`;
|
||||
}
|
||||
}
|
||||
@@ -1485,17 +1498,21 @@ export default function ForwardPage() {
|
||||
toast.success("删除成功");
|
||||
setDeleteModalOpen(false);
|
||||
setForwardToDelete(null);
|
||||
setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));
|
||||
setForwards((prev) =>
|
||||
prev.filter((forward) => forward.id !== forwardToDelete.id),
|
||||
);
|
||||
setForwardOrder((prev) => {
|
||||
const next = prev.filter((id) => id !== forwardToDelete.id);
|
||||
|
||||
saveOrder(FORWARD_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
next.delete(forwardToDelete.id);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -1518,12 +1535,14 @@ export default function ForwardPage() {
|
||||
const next = prev.filter((id) => id !== forwardToDelete.id);
|
||||
|
||||
saveOrder(FORWARD_ORDER_KEY, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
next.delete(forwardToDelete.id);
|
||||
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -1597,6 +1616,7 @@ export default function ForwardPage() {
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: normalizedSpeedId,
|
||||
};
|
||||
|
||||
res = await createForward(createData);
|
||||
}
|
||||
|
||||
@@ -2787,7 +2807,8 @@ export default function ForwardPage() {
|
||||
}
|
||||
|
||||
if (
|
||||
normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 &&
|
||||
normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) ===
|
||||
1 &&
|
||||
normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1
|
||||
) {
|
||||
existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio(
|
||||
@@ -4032,66 +4053,76 @@ export default function ForwardPage() {
|
||||
{compactMode ? (
|
||||
viewMode === "grouped" ? (
|
||||
sortedForwards.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<>
|
||||
<div className="flex items-center justify-between px-1 mb-3">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
全部规则
|
||||
</span>
|
||||
<span className="text-xs text-default-600">
|
||||
{sortedForwards.length} 条规则
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table
|
||||
aria-label="全部规则列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<TableHeader>
|
||||
{selectMode && (
|
||||
<TableColumn className="w-14">选择</TableColumn>
|
||||
)}
|
||||
<TableColumn className="w-10 pl-4" />
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>总流量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无规则配置"
|
||||
items={sortedForwards}
|
||||
<Table
|
||||
aria-label="全部规则列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
{(forward) => (
|
||||
<SortableCompactTableRow
|
||||
formatFlow={formatFlow}
|
||||
formatInAddress={formatInAddress}
|
||||
formatRemoteAddress={formatRemoteAddress}
|
||||
forward={forward}
|
||||
getStrategyDisplay={getStrategyDisplay}
|
||||
handleDelete={handleDelete}
|
||||
handleDiagnose={handleDiagnose}
|
||||
handleEdit={handleEdit}
|
||||
handleServiceToggle={handleServiceToggle}
|
||||
hasMultipleAddresses={hasMultipleAddresses}
|
||||
selectMode={selectMode}
|
||||
selectedIds={selectedIds}
|
||||
showAddressModal={showAddressModal}
|
||||
toggleSelect={toggleSelect}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
<TableHeader>
|
||||
{selectMode && (
|
||||
<TableColumn className="w-14">选择</TableColumn>
|
||||
)}
|
||||
<TableColumn className="w-10 pl-4" />
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>总流量</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
emptyContent="暂无规则配置"
|
||||
items={sortedForwards}
|
||||
>
|
||||
{(forward) => (
|
||||
<SortableCompactTableRow
|
||||
formatFlow={formatFlow}
|
||||
formatInAddress={formatInAddress}
|
||||
formatRemoteAddress={formatRemoteAddress}
|
||||
forward={forward}
|
||||
getStrategyDisplay={getStrategyDisplay}
|
||||
handleDelete={handleDelete}
|
||||
handleDiagnose={handleDiagnose}
|
||||
handleEdit={handleEdit}
|
||||
handleServiceToggle={handleServiceToggle}
|
||||
hasMultipleAddresses={hasMultipleAddresses}
|
||||
selectMode={selectMode}
|
||||
selectedIds={selectedIds}
|
||||
showAddressModal={showAddressModal}
|
||||
toggleSelect={toggleSelect}
|
||||
/>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||
|
||||
@@ -202,6 +202,7 @@ const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {
|
||||
|
||||
const normalizeNodeTags = (tags?: string): string[] => {
|
||||
if (!tags) return [];
|
||||
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
@@ -257,7 +258,8 @@ const SortableItem = ({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="overflow-hidden"
|
||||
className="overflow-hidden h-full"
|
||||
{...listeners}
|
||||
>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
@@ -272,10 +274,8 @@ export default function NodePage() {
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
|
||||
"node-expiry-filter-mode",
|
||||
"all",
|
||||
);
|
||||
const [nodeFilterMode, setNodeFilterMode] =
|
||||
useLocalStorageState<NodeFilterMode>("node-expiry-filter-mode", "all");
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
@@ -675,7 +675,10 @@ export default function NodePage() {
|
||||
newErrors.name = "节点名称长度不能超过50位";
|
||||
}
|
||||
|
||||
if ((form.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime)) {
|
||||
if (
|
||||
(form.renewalCycle && !form.expiryTime) ||
|
||||
(!form.renewalCycle && form.expiryTime)
|
||||
) {
|
||||
newErrors.expiryTime = "请同时设置续费周期和续费基准时间";
|
||||
}
|
||||
|
||||
@@ -1188,6 +1191,7 @@ export default function NodePage() {
|
||||
if (getNodeReminderEnabled(node)) {
|
||||
acc.withExpiry += 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||
@@ -1216,7 +1220,10 @@ export default function NodePage() {
|
||||
|
||||
if (nodeFilterMode !== "all") {
|
||||
filteredNodes = filteredNodes.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
@@ -1298,7 +1305,9 @@ export default function NodePage() {
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
|
||||
const selected = Array.from(keys)[0] as
|
||||
| NodeFilterMode
|
||||
| undefined;
|
||||
|
||||
setNodeFilterMode(selected || "all");
|
||||
}}
|
||||
@@ -1436,7 +1445,7 @@ export default function NodePage() {
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
|
||||
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">
|
||||
@@ -1498,7 +1507,7 @@ 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)}
|
||||
@@ -1517,7 +1526,10 @@ export default function NodePage() {
|
||||
{(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 whitespace-pre-wrap break-words">
|
||||
<div
|
||||
className="text-xs leading-5 text-default-700 line-clamp-1 break-all"
|
||||
title={node.remark.trim()}
|
||||
>
|
||||
{node.remark.trim()}
|
||||
</div>
|
||||
)}
|
||||
@@ -1538,29 +1550,24 @@ export default function NodePage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">下次续费</span>
|
||||
<div className="text-right ml-2">
|
||||
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||
{formatNodeRenewalTime(expiryMeta.nextDueTime)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Chip
|
||||
className="text-[11px]"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-default-500">
|
||||
{getNodeRenewalCycleLabel(node.renewalCycle)}
|
||||
</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>
|
||||
)}
|
||||
)}
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
@@ -1758,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
|
||||
@@ -1943,18 +1950,6 @@ export default function NodePage() {
|
||||
variant="flat"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="留空表示未设置续费提醒;只有同时设置周期和基准时间才会参与提醒与筛选"
|
||||
label="说明"
|
||||
readOnly
|
||||
value={
|
||||
form.renewalCycle && form.expiryTime > 0
|
||||
? `当前按${getNodeRenewalCycleLabel(form.renewalCycle)}循环计算`
|
||||
: "未启用循环续费提醒"
|
||||
}
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Input
|
||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||
errorMessage={errors.serverHost}
|
||||
|
||||
@@ -14,6 +14,7 @@ const addMonths = (timestamp: number, months: number): number => {
|
||||
const next = new Date(date);
|
||||
|
||||
next.setMonth(next.getMonth() + months);
|
||||
|
||||
return next.getTime();
|
||||
};
|
||||
|
||||
@@ -72,7 +73,9 @@ export const getNodeRenewalSnapshot = (
|
||||
nextDueTime = advanced;
|
||||
}
|
||||
|
||||
const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const diffDays = Math.ceil(
|
||||
(nextDueTime - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (diffDays <= 0) {
|
||||
return {
|
||||
@@ -110,5 +113,6 @@ export const formatNodeRenewalTime = (timestamp?: number): string => {
|
||||
if (!timestamp || timestamp <= 0) {
|
||||
return "未设置";
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
@@ -265,27 +265,30 @@ export default function TunnelPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
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 {
|
||||
const refreshTunnelList = useCallback(
|
||||
async (withLoading = true) => {
|
||||
if (withLoading) {
|
||||
setLoading(false);
|
||||
setLoading(true);
|
||||
}
|
||||
}
|
||||
}, [applyTunnelList]);
|
||||
|
||||
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 {
|
||||
@@ -381,12 +384,14 @@ export default function TunnelPage() {
|
||||
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 {
|
||||
@@ -858,11 +863,14 @@ export default function TunnelPage() {
|
||||
label: `删除完成:成功 ${result.successCount} 项`,
|
||||
percent: 100,
|
||||
});
|
||||
setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id)));
|
||||
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 {
|
||||
|
||||
@@ -66,10 +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";
|
||||
import { removeItemsById, replaceItemById } from "@/utils/list-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -304,30 +301,30 @@ export default function UserPage() {
|
||||
// 数据加载函数
|
||||
const loadUsers = useCallback(
|
||||
async (keywordOverride?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const keyword = keywordOverride ?? searchKeyword;
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
const keyword = keywordOverride ?? searchKeyword;
|
||||
const response = await getAllUsers({
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
keyword,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
const nextUsers = Array.isArray(response.data)
|
||||
? response.data.map((item) => normalizeUserItem(item))
|
||||
: [];
|
||||
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 || "获取用户列表失败");
|
||||
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],
|
||||
);
|
||||
@@ -411,6 +408,7 @@ export default function UserPage() {
|
||||
setPagination((prev) => {
|
||||
if (prev.current === 1) {
|
||||
void loadUsers(searchKeyword);
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
@@ -667,7 +665,8 @@ export default function UserPage() {
|
||||
normalizeSpeedId(editTunnelForm.speedId) !== null
|
||||
? speedLimits.find(
|
||||
(speedLimit) =>
|
||||
speedLimit.id === normalizeSpeedId(editTunnelForm.speedId),
|
||||
speedLimit.id ===
|
||||
normalizeSpeedId(editTunnelForm.speedId),
|
||||
)?.name
|
||||
: undefined,
|
||||
});
|
||||
@@ -733,7 +732,9 @@ export default function UserPage() {
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((user) =>
|
||||
user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user,
|
||||
user.id === targetUserId
|
||||
? { ...user, inFlow: 0, outFlow: 0 }
|
||||
: user,
|
||||
),
|
||||
);
|
||||
setUserToReset(null);
|
||||
|
||||
Reference in New Issue
Block a user