feat(user): support user-group assignment in user management (#142)

## Summary
- add backend support to bind users to one or more user groups on
create/update
- add a new `/user/groups` API endpoint and repository methods for
user-group mapping queries/mutations
- update user modal UI to fetch/select user groups and submit `groupIds`
with user create/edit requests

## Notes
- includes minor frontend formatting changes in existing pages
(`config.tsx`, `dashboard.tsx`, `tunnel.tsx`) that were part of the
working tree
This commit is contained in:
sagit
2026-02-19 14:51:44 +08:00
committed by GitHub
102 changed files with 6686 additions and 2001 deletions
@@ -89,6 +89,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/update", h.userUpdate) mux.HandleFunc("/api/v1/user/update", h.userUpdate)
mux.HandleFunc("/api/v1/user/delete", h.userDelete) mux.HandleFunc("/api/v1/user/delete", h.userDelete)
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow) mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName) mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs) mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs) mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
+53 -1
View File
@@ -60,10 +60,21 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
roleID := 1 roleID := 1
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
if err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now); err != nil { userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
groupIDs := asInt64Slice(req["groupIds"])
if len(groupIDs) > 0 {
if addErr := h.repo.AddUserToGroups(userID, groupIDs, now); addErr == nil {
for _, gid := range groupIDs {
_ = h.syncPermissionsByUserGroup(gid)
}
}
}
response.WriteJSON(w, response.OKEmpty()) response.WriteJSON(w, response.OKEmpty())
} }
@@ -133,9 +144,36 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
} }
h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime) h.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)
if groupIDsRaw, ok := req["groupIds"]; ok {
newGroupIDs := asInt64Slice(groupIDsRaw)
if affected, replaceErr := h.repo.ReplaceUserGroupsByUserID(id, newGroupIDs, now); replaceErr == nil {
for _, gid := range affected {
_ = h.syncPermissionsByUserGroup(gid)
}
}
}
response.WriteJSON(w, response.OKEmpty()) response.WriteJSON(w, response.OKEmpty())
} }
func (h *Handler) userGroups(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
return
}
ids, err := h.repo.GetUserGroupIDsByUserID(id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(ids))
}
func (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) { func (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败")) response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -3204,3 +3242,17 @@ func randomToken(n int) string {
} }
return hex.EncodeToString(buf) return hex.EncodeToString(buf)
} }
func asInt64Slice(v interface{}) []int64 {
arr := asAnySlice(v)
if len(arr) == 0 {
return nil
}
ids := make([]int64, 0, len(arr))
for _, x := range arr {
if id := asInt64(x, 0); id > 0 {
ids = append(ids, id)
}
}
return ids
}
@@ -50,8 +50,17 @@ func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][
return result, err return result, err
} }
// ListGroupPermissionPairsByTunnelGroup returns [userGroupID, tunnelGroupID] pairs func (r *Repository) GetUserGroupIDsByUserID(userID int64) ([]int64, error) {
// for all group permissions associated with a tunnel group. if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserGroupUser{}).
Where("user_id = ?", userID).
Pluck("user_group_id", &ids).Error
return ids, err
}
func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) { func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, errors.New("repository not initialized") return nil, errors.New("repository not initialized")
@@ -34,9 +34,9 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
return cnt > 0, err return cnt > 0, err
} }
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) error { func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) (int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return 0, errors.New("repository not initialized")
} }
user := model.User{ user := model.User{
User: username, User: username,
@@ -52,7 +52,10 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status, Status: status,
} }
return r.db.Create(&user).Error if err := r.db.Create(&user).Error; err != nil {
return 0, err
}
return user.ID, nil
} }
func (r *Repository) GetUserRoleID(userID int64) (int, error) { func (r *Repository) GetUserRoleID(userID int64) (int, error) {
@@ -1406,3 +1409,70 @@ func parsePortRangeSpec(input string) []int {
sort.Ints(out) sort.Ints(out)
return out return out
} }
func (r *Repository) AddUserToGroups(userID int64, groupIDs []int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if len(groupIDs) == 0 {
return nil
}
rows := make([]model.UserGroupUser, 0, len(groupIDs))
for _, gid := range groupIDs {
if gid <= 0 {
continue
}
rows = append(rows, model.UserGroupUser{UserGroupID: gid, UserID: userID, CreatedTime: now})
}
if len(rows) == 0 {
return nil
}
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error
}
func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64, now int64) (affectedGroupIDs []int64, err error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var oldGroupIDs []int64
if err = r.db.Model(&model.UserGroupUser{}).
Where("user_id = ?", userID).
Pluck("user_group_id", &oldGroupIDs).Error; err != nil {
return nil, err
}
seen := make(map[int64]struct{})
for _, id := range oldGroupIDs {
seen[id] = struct{}{}
}
for _, id := range newGroupIDs {
if id > 0 {
seen[id] = struct{}{}
}
}
for id := range seen {
affectedGroupIDs = append(affectedGroupIDs, id)
}
if err = r.db.Where("user_id = ?", userID).Delete(&model.UserGroupUser{}).Error; err != nil {
return nil, err
}
if len(newGroupIDs) == 0 {
return affectedGroupIDs, nil
}
rows := make([]model.UserGroupUser, 0, len(newGroupIDs))
for _, gid := range newGroupIDs {
if gid <= 0 {
continue
}
rows = append(rows, model.UserGroupUser{UserGroupID: gid, UserID: userID, CreatedTime: now})
}
if len(rows) > 0 {
if err = r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil {
return nil, err
}
}
return affectedGroupIDs, nil
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,222 @@
# 前端彻底重构计划(进行中)
- 分支:`frontend/refactor-audit-20260218`
- 范围:`vite-frontend/src`
- 更新时间:2026-02-19
- 当前总体进度:`99.5%`
## 1) 问题审计(已完成)
### P0(高风险,优先修复)
- [x] `src/api/network.ts` 存在 token 过期后 Promise 不 resolve 的路径(`then/catch` 里仅 `return`,调用方可能挂起)
- [x] 登录态读写逻辑分散在多个页面/布局,存在重复和不一致风险(`src/pages/index.tsx`、`src/layouts/admin.tsx`、`src/layouts/h5.tsx`、`src/pages/profile.tsx`、`src/pages/group.tsx`)
- [x] 核心页面超大文件导致可维护性差:`forward.tsx`(3263 行)、`tunnel.tsx`(2595 行)、`node.tsx`(2194 行)、`user.tsx`(1761 行)、`dashboard.tsx`(1363 行)
- [x] `any` 使用过多,类型边界不清(`src/api/index.ts`、`src/api/network.ts`、`src/pages/*`)
### P1(中风险,本次并行推进)
- [x] 多处空 `catch {}` 吞错,定位问题困难(如 `src/App.tsx`、`src/components/navbar.tsx`、`src/pages/config.tsx` 等)
- [x] 移动端/H5/WebView 判定逻辑重复(`src/App.tsx`、`src/pages/index.tsx`、`src/components/navbar.tsx`)
- [x] 菜单与权限控制在多处重复定义,布局和页面耦合偏高(`src/layouts/admin.tsx`、`src/layouts/h5.tsx`、`src/pages/profile.tsx`)
- [x] 路由保护与布局选择逻辑集中在 `src/App.tsx`,可测试性和扩展性偏弱
### P2(优化项,后续阶段)
- [x] `vite.config.ts` 生产构建配置 `minify: false`、`treeshake: false`,性能优化策略待梳理
- [x] ESLint 中 `react-hooks/exhaustive-deps` 关闭,副作用依赖约束较弱(`eslint.config.mjs`)
## 2) 彻底重构任务清单(边做边更新)
> 状态说明:`[x] 完成` / `[ ] 未开始` / `[-] 进行中`
### Phase A - 会话与权限基建
- [x] A1. 新增统一会话工具模块(token/role/name/admin 的读写与兼容逻辑)
- [x] A2. 登录页改为调用会话工具写入登录态,去重重复代码
- [x] A3. 布局与页面的管理员判断改为统一工具,移除重复逻辑
- [x] A4. `Network` 层统一 token 获取与 token 过期处理,避免悬挂 Promise
### Phase B - Hook 与状态复用
- [x] B1. 提取 `useWebViewMode`,替换 `index/navbar` 重复检测
- [x] B2. 提取 `useH5Mode`,收敛 `App` 内设备与参数判定逻辑
- [x] B3. 提取滚动复位逻辑(`H5/H5-simple`)到通用 hook
### Phase C - API 与类型边界
- [x] C1. 收紧 `src/api/network.ts` 的 `any` 边界(优先 `unknown` + 受控转换)
- [-] C2. 给高频 API 接口补全请求/响应类型(先用户、节点、隧道、转发)
- [-] C3. 统一错误消息提取策略,避免散落式字符串拼接
### Phase D - 大页面拆分(增量,不大爆炸重写)
- [-] D1. `forward.tsx` 抽离:筛选条、列表视图、批量操作、详情弹窗
- [-] D2. `tunnel.tsx` 抽离:表单区、列表区、排序区、诊断区
- [-] D3. `node.tsx` 抽离:状态区、安装命令区、排序区、WebSocket 区
- [ ] D4. `dashboard.tsx` 抽离:统计卡片、图表区、公告区、刷新逻辑
### Phase E - 体验与可访问性
- [-] E1. 统一关键交互控件的 `aria-label` / 键盘可达性
- [-] E2. 统一 loading/empty/error 三态展示组件
- [-] E3. 统一移动端断点与布局响应策略
## 15) 当前实施批次(Batch-12)
- 目标:推进 **D2/D4/E1/E2/E3** 收尾,完成表单模块接入、仪表盘增量拆分与通用能力落地
- 本批次进度:`6 / 6`
### Batch-12 明细进度
- [x] T51. `tunnel.tsx` 接入 `tunnel/form.ts`(默认值、表单校验、类型/流量展示)
- [x] T52. 修复 `limit.tsx` 构建阻断问题(移除未使用 `Spinner` 导入)
- [x] T53. `dashboard.tsx` 抽离公告区与指标卡公共组件(`dashboard/components/*`)
- [x] T54. `dashboard.tsx` 管理员判定切换为 `session` 统一工具(`getAdminFlag`)
- [x] T55. `admin.tsx` 接入 `useMobileBreakpoint` 收敛断点监听;`settings.tsx` 回填返回按钮 `aria-label`
- [x] T56. 执行 `npm run build` + `npm run lint` 校验(lint 仅剩既有 `no-console` warning)
## 3) 当前实施批次(Batch-1)
- 目标:先完成 **A1/A2/A3/A4 + B1/B2 + C1**,优先解决架构一致性和稳定性风险
- 本批次进度:`7 / 7`
### Batch-1 明细进度
- [x] T1. 新增 `session` 统一会话工具(A1)
- [x] T2. 登录页切换到会话工具(A2)
- [x] T3. `admin/h5/profile/group` 切换管理员判定工具(A3)
- [x] T4. `network` 统一 token 与过期返回(A4 + C1)
- [x] T5. 新增 `useWebViewMode` 并接入 `index/navbar`(B1)
- [x] T6. 新增 `useH5Mode` 并接入 `App`(B2)
- [x] T7. 执行 `npm run build` 与必要诊断校验
## 4) 当前实施批次(Batch-2)
- 目标:推进 **D2/D3 的排序逻辑抽离** 与 **会话 token 复用**,降低大型页面重复代码
- 本批次进度:`7 / 7`
### Batch-2 明细进度
- [x] T8. 新增通用排序存储工具(node/tunnel 共用)
- [x] T9. `tunnel.tsx` 接入排序存储工具
- [x] T10. `node.tsx` 接入排序存储工具
- [x] T11. `node.tsx` WebSocket token 改用统一会话工具
- [x] T12. 执行 `npm run build` 与必要诊断校验
- [x] T13. 新增 `useScrollTopOnPathChange` 通用 hook
- [x] T14. `h5/h5-simple` 接入滚动复位 hook 并完成构建校验
## 5) 当前实施批次(Batch-3)
- 目标:推进 **D3(node WebSocket 区域)结构化抽离**,先拆出系统信息解析逻辑
- 本批次进度:`4 / 4`
### Batch-3 明细进度
- [x] T15. 新增 `node` 系统信息解析工具模块
- [x] T16. `node.tsx` WebSocket `info` 消息处理接入解析工具
- [x] T17. 保持在线/离线状态切换与速度计算逻辑一致
- [x] T18. 执行 `npm run build` 与必要诊断校验
## 6) 外部最佳实践参考(已纳入本计划)
- React 官方:重复逻辑应抽为自定义 Hook(`reusing-logic-with-custom-hooks`)
- React 官方:共享逻辑不共享状态,跨组件共享状态应提升或集中管理
- Vite 官方:大型项目优先审计插件成本、动态导入与分块策略
- WAI-ARIA APG:导航与交互组件优先语义化与键盘可达性
## 7) 当前实施批次(Batch-4)
- 目标:继续推进 **D3(node WebSocket 区域)**,抽离连接生命周期与重连策略
- 本批次进度:`4 / 4`
### Batch-4 明细进度
- [x] T19. 新增 `useNodeRealtime` Hook(连接/重连/断开)
- [x] T20. `node.tsx` 接入 `useNodeRealtime`,移除内联连接管理代码
- [x] T21. 保留离线延迟逻辑并在页面卸载时清理离线定时器
- [x] T22. 执行 `npm run build` 与必要诊断校验
## 8) 当前实施批次(Batch-5)
- 目标:继续推进 **D3(node WebSocket 区域)**,抽离离线延迟定时器生命周期
- 本批次进度:`4 / 4`
### Batch-5 明细进度
- [x] T23. 新增 `useNodeOfflineTimers` Hook(离线延迟/清理)
- [x] T24. `node.tsx` 接入 `useNodeOfflineTimers`,移除内联定时器管理
- [x] T25. 校验状态/信息消息路径行为一致(在线切换与离线延迟)
- [x] T26. 执行 `npm run build` 与必要诊断校验
## 9) 当前实施批次(Batch-6)
- 目标:推进 **D2(tunnel 诊断区)**,抽离诊断兜底与质量评估逻辑
- 本批次进度:`4 / 4`
### Batch-6 明细进度
- [x] T27. 新增 `tunnel/diagnosis` 诊断工具模块
- [x] T28. `tunnel.tsx` 接入诊断兜底与质量评估工具
- [x] T29. 校验诊断弹窗展示逻辑与质量标签行为一致
- [x] T30. 执行 `npm run build` 与必要诊断校验
## 10) 当前实施批次(Batch-7)
- 目标:推进 **C2/C3(API 类型边界与错误消息统一)**,先覆盖高频 node/tunnel/forward 路径
- 本批次进度:`4 / 4`
### Batch-7 明细进度
- [x] T31. 新增 `api` 高频领域类型定义(node/tunnel/forward)
- [x] T32. `api/index.ts` 的高频列表接口接入类型定义
- [x] T33. 新增网络错误消息提取工具并接入 `network.ts`
- [x] T34. 执行 `npm run build` 与必要诊断校验
## 11) 当前实施批次(Batch-8)
- 目标:推进 **D1(forward 排序区)**,抽离直接模式排序初始化逻辑
- 本批次进度:`4 / 4`
### Batch-8 明细进度
- [x] T35. 新增 `forward/order` 排序工具模块
- [x] T36. `forward.tsx` 接入排序工具并复用通用存储
- [x] T37. 校验直接模式排序初始化与拖拽持久化行为一致
- [x] T38. 执行 `npm run build` 与必要诊断校验
## 12) 当前实施批次(Batch-9)
- 目标:推进 **D1(forward 诊断区)**,抽离诊断兜底与质量评估逻辑
- 本批次进度:`4 / 4`
### Batch-9 明细进度
- [x] T39. 新增 `forward/diagnosis` 诊断工具模块
- [x] T40. `forward.tsx` 接入诊断兜底与质量评估工具
- [x] T41. 校验诊断弹窗展示与质量标签行为一致
- [x] T42. 执行 `npm run build` 与必要诊断校验
## 13) 当前实施批次(Batch-10)
- 目标:推进 **D1(forward 批量操作区)**,抽离批量动作执行与反馈逻辑
- 本批次进度:`4 / 4`
### Batch-10 明细进度
- [x] T43. 新增 `forward/batch-actions` 批量操作工具模块
- [x] T44. `forward.tsx` 接入批量操作工具并移除重复处理分支
- [x] T45. 校验批量删除/启停/重下发/换隧道行为一致
- [x] T46. 执行 `npm run build` 与必要诊断校验
## 14) 当前实施批次(Batch-11)
- 目标:推进 **D1(forward 地址展示/复制区)**,抽离地址格式化与弹窗分流逻辑
- 本批次进度:`4 / 4`
### Batch-11 明细进度
- [x] T47. 新增 `forward/address` 地址工具模块
- [x] T48. `forward.tsx` 接入地址工具并移除内联格式化/分流逻辑
- [x] T49. 校验地址单项复制与多项弹窗行为一致
- [x] T50. 执行 `npm run build` 与必要诊断校验
@@ -0,0 +1,52 @@
# shadcn/ui 全量迁移计划(已完成)
- 分支:`feat/shadcn-ui-full-migration-20260219`
- 日期:`2026-02-19`
- 目标:将 `vite-frontend` 从 HeroUI 彻底迁移到 shadcn/ui(含依赖、Provider、组件实现与构建验证)
- 说明:用户提到的 `shadcu-ui` 按 `shadcn/ui` 执行
## 0. 现状基线(已完成)
- HeroUI 直接使用文件:`22` 个(`src/` 下)
- HeroUI 组件族:`button/card/input/select/modal/table/chip/spinner/switch/alert/accordion/checkbox/dropdown/tabs/radio/date-picker/progress/navbar/link/system/use-theme` 等
- 关键复杂页:`forward.tsx`、`tunnel.tsx`、`node.tsx`、`user.tsx`
- 语义色类大量依赖:`text-default-*`、`bg-primary-*`、`border-divider`、`text-foreground` 等
## 1. 执行步骤
状态标记:`[ ] 未开始` / `[-] 进行中` / `[x] 已完成`
- [x] S1. 创建迁移分支并冻结迁移范围(仅 `vite-frontend`)
- [x] S2. 完成全量使用点扫描(Grep/rg/AST + 官方文档检索)
- [x] S3. 建立 shadcn/ui 基础设施(`components.json`、`src/lib/utils.ts`、`src/components/ui/*` 基础原子组件)
- [x] S4. 建立 HeroUI -> shadcn 兼容桥接层(`src/shadcn-bridge/heroui/*`)并替换全部页面导入
- [x] S5. 迁移全局 Provider/主题能力(替换 `HeroUIProvider`、`useTheme`、`useDisclosure`)
- [x] S6. 替换 Tailwind 主题来源(移除 HeroUI 主题插件,补齐语义色 token 与兼容工具类)
- [x] S7. 移除 HeroUI 依赖并修复构建(`npm install` + `npm run build`)
- [x] S8. 回写完成记录与验收(确认无 `@heroui/*` 运行时依赖)
## 2. 组件映射策略(本次执行)
- Button -> shadcn `button` + 兼容 `isLoading/isIconOnly/startContent/endContent/onPress`
- Input/Textarea -> shadcn `input/textarea` + label/description/error 容器
- Modal -> shadcn `dialog`(兼容 `isOpen/onOpenChange` 与 Header/Body/Footer 插槽)
- Select -> shadcn `select`(单选)+ 命令式多选兼容实现(多选场景)
- Table -> shadcn `table`(兼容 `items + render function + empty/loading`)
- Dropdown/Tabs/Radio/Switch/Checkbox/Accordion/Alert/Progress/Card/Separator -> 对应 shadcn 组件封装
- DatePicker -> 基于原生日期输入 + 兼容 value/onChange 的桥接实现(保留现有业务数据结构)
## 3. 执行记录(每步完成即更新)
- [2026-02-19] 完成 S1:创建分支 `feat/shadcn-ui-full-migration-20260219`
- [2026-02-19] 完成 S2:完成 HeroUI 使用点与迁移风险扫描;确认迁移顺序
- [2026-02-19] 完成 S3:新增 `components.json`、`src/lib/utils.ts` 与 `src/components/ui/*`(button/dialog/dropdown/select/table/checkbox/switch/tabs/accordion/progress 等)
- [2026-02-19] 完成 S4:新增 `src/shadcn-bridge/heroui/*` 兼容桥接层,并将现网全部导入替换为 `@/shadcn-bridge/heroui/*`
- [2026-02-19] 完成 S5:通过桥接层接管 `HeroUIProvider`、`useTheme`、`useDisclosure`,保持页面业务逻辑不改动
- [2026-02-19] 完成 S6:移除 `@heroui/theme` Tailwind 插件,改为本地 token 体系(`tailwind.config.js` + `src/styles/globals.css`)
- [2026-02-19] 完成 S7:删除全部 HeroUI/NextUI 依赖,补齐 `@internationalized/date` 与 `@react-aria/i18n` 显式依赖
- [2026-02-19] 完成 S8:构建验收通过(`npm run build`),`package.json` 已无 `heroui/nextui` 依赖
- [2026-02-19] 验证结果:业务代码中 `@heroui/*` 导入为 `0`,已统一替换为 `@/shadcn-bridge/heroui/*`(22 文件,106 处)
- [2026-02-19] 后续修复:`src/components/ui/button.tsx` 与 `src/shadcn-bridge/heroui/button.tsx` 改为 `forwardRef`,消除 `DropdownMenuTrigger asChild` 场景 ref 警告;复构建通过
- [2026-02-19] 回归修复:定位“按钮边框/颜色丢失”根因是 Tailwind v4 下语义色未生成;新增 `src/styles/tailwind-theme.pcss`(由 `src/styles/globals.css` 引入)承载 `@theme inline` token 映射,恢复 `bg-primary`/`text-foreground`/`border-input` 等语义类输出
- [2026-02-19] 回归修复:增强 `src/shadcn-bridge/heroui/button.tsx` 的 `solid/light/flat/bordered/shadow` 颜色映射,并修正 `src/components/ui/button.tsx` 的 `outline/ghost` hover 语义类,避免依赖未定义的 `accent` 色
- [2026-02-19] 复验结果:`npm run build` 通过;编译产物已包含关键语义类;页面级视觉回归(forward/tunnel/node/user)通过(后端未启动时仅保留 `ERR_CONNECTION_REFUSED` 噪音)
+19 -31
View File
@@ -13,45 +13,31 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@heroui/accordion": "^2.2.21", "@radix-ui/react-accordion": "^1.2.0",
"@heroui/alert": "^2.2.24", "@radix-ui/react-checkbox": "^1.1.0",
"@heroui/autocomplete": "^2.3.25", "@radix-ui/react-dialog": "^1.1.0",
"@heroui/avatar": "^2.2.19", "@radix-ui/react-dropdown-menu": "^2.1.0",
"@heroui/badge": "^2.2.15", "@radix-ui/react-label": "^2.1.0",
"@heroui/button": "2.2.23", "@radix-ui/react-progress": "^1.1.0",
"@heroui/card": "^2.2.22", "@radix-ui/react-radio-group": "^1.2.0",
"@heroui/chip": "^2.2.19", "@radix-ui/react-select": "^2.1.0",
"@heroui/code": "2.2.17", "@radix-ui/react-separator": "^1.1.0",
"@heroui/date-picker": "^2.3.22", "@radix-ui/react-slot": "^1.1.0",
"@heroui/dropdown": "2.3.23", "@radix-ui/react-switch": "^1.1.0",
"@heroui/image": "^2.2.15", "@radix-ui/react-tabs": "^1.1.0",
"@heroui/input": "2.4.24",
"@heroui/kbd": "2.2.18",
"@heroui/link": "2.2.20",
"@heroui/modal": "^2.2.21",
"@heroui/navbar": "2.2.21",
"@heroui/pagination": "^2.2.21",
"@heroui/progress": "^2.2.20",
"@heroui/radio": "^2.3.24",
"@heroui/select": "^2.4.22",
"@heroui/snippet": "2.2.24",
"@heroui/spinner": "^2.2.20",
"@heroui/switch": "^2.2.21",
"@heroui/system": "2.4.19",
"@heroui/table": "^2.2.24",
"@heroui/tabs": "^2.2.27",
"@heroui/theme": "2.4.24",
"@heroui/use-theme": "2.1.10",
"@marsidev/react-turnstile": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0",
"@nextui-org/system": "^2.4.6", "@internationalized/date": "^3.10.0",
"@react-aria/i18n": "^3.12.12",
"@react-aria/visually-hidden": "3.8.25", "@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0", "@react-types/shared": "3.30.0",
"@tailwindcss/postcss": "4.1.11", "@tailwindcss/postcss": "4.1.11",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/react-beautiful-dnd": "^13.1.8", "@types/react-beautiful-dnd": "^13.1.8",
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"framer-motion": "11.18.2", "framer-motion": "11.18.2",
"lucide-react": "^0.542.0",
"react": "18.3.1", "react": "18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@@ -60,8 +46,10 @@
"react-router-dom": "6.30.3", "react-router-dom": "6.30.3",
"recharts": "^3.1.1", "recharts": "^3.1.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.11" "tailwindcss": "4.1.11",
"tw-animate-css": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "1.2.8", "@eslint/compat": "1.2.8",
+4 -54
View File
@@ -1,5 +1,5 @@
import { Route, Routes, useNavigate } from "react-router-dom"; import { Route, Routes, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import IndexPage from "@/pages/index"; import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password"; import ChangePasswordPage from "@/pages/change-password";
@@ -19,50 +19,7 @@ import H5Layout from "@/layouts/h5";
import H5SimpleLayout from "@/layouts/h5-simple"; import H5SimpleLayout from "@/layouts/h5-simple";
import { isLoggedIn } from "@/utils/auth"; import { isLoggedIn } from "@/utils/auth";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { useH5Mode } from "@/hooks/useH5Mode";
// 检测是否为H5模式
const useH5Mode = () => {
// 立即检测H5模式,避免初始渲染时的闪屏
const getInitialH5Mode = () => {
// 检测移动设备或小屏幕
const isMobile = window.innerWidth <= 768;
// 检测是否为移动端浏览器
const isMobileBrowser =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
// 检测URL参数是否包含h5模式
const urlParams = new URLSearchParams(window.location.search);
const isH5Param = urlParams.get("h5") === "true";
return isMobile || isMobileBrowser || isH5Param;
};
const [isH5, setIsH5] = useState(getInitialH5Mode);
useEffect(() => {
const checkH5Mode = () => {
// 检测移动设备或小屏幕
const isMobile = window.innerWidth <= 768;
// 检测是否为移动端浏览器
const isMobileBrowser =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
// 检测URL参数是否包含h5模式
const urlParams = new URLSearchParams(window.location.search);
const isH5Param = urlParams.get("h5") === "true";
setIsH5(isMobile || isMobileBrowser || isH5Param);
};
window.addEventListener("resize", checkH5Mode);
return () => window.removeEventListener("resize", checkH5Mode);
}, []);
return isH5;
};
// 简化的路由保护组件 - 使用 React Router 导航避免循环 // 简化的路由保护组件 - 使用 React Router 导航避免循环
const ProtectedRoute = ({ const ProtectedRoute = ({
@@ -99,15 +56,8 @@ const ProtectedRoute = ({
} }
// 根据模式和页面类型选择布局 // 根据模式和页面类型选择布局
let Layout; const Layout =
isH5 && useSimpleLayout ? H5SimpleLayout : isH5 ? H5Layout : AdminLayout;
if (isH5 && useSimpleLayout) {
Layout = H5SimpleLayout;
} else if (isH5) {
Layout = H5Layout;
} else {
Layout = AdminLayout;
}
return <Layout>{children}</Layout>; return <Layout>{children}</Layout>;
}; };
+27
View File
@@ -0,0 +1,27 @@
import axios from "axios";
interface ErrorPayload {
msg?: string;
message?: string;
}
export const isUnauthorizedError = (error: unknown): boolean => {
return axios.isAxiosError(error) && error.response?.status === 401;
};
export const extractApiErrorMessage = (
error: unknown,
fallback = "网络请求失败",
): string => {
if (axios.isAxiosError(error)) {
const payload = error.response?.data as ErrorPayload | undefined;
return payload?.msg || payload?.message || error.message || fallback;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
};
+103 -49
View File
@@ -1,3 +1,32 @@
import type {
BatchOperationResult,
ForwardDiagnosisApiData,
ForwardApiItem,
GroupPermissionApiItem,
NodeReleaseApiItem,
NodeApiItem,
SpeedLimitApiItem,
TunnelDiagnosisApiData,
TunnelGroupApiItem,
UserApiItem,
UserGroupApiItem,
UserListQuery,
UserPackageInfoApiData,
UserTunnelPermissionApiItem,
TunnelApiItem,
UserTunnelApiItem,
UserMutationPayload,
NodeMutationPayload,
TunnelMutationPayload,
UserTunnelAssignPayload,
UserTunnelListQuery,
UserTunnelRemovePayload,
ForwardMutationPayload,
SpeedLimitMutationPayload,
UpdatePasswordPayload,
BackupImportPayload,
} from "./types";
import axios from "axios"; import axios from "axios";
import Network from "./network"; import Network from "./network";
@@ -20,20 +49,25 @@ export const login = (data: LoginData) =>
Network.post<LoginResponse>("/user/login", data); Network.post<LoginResponse>("/user/login", data);
// 用户CRUD操作 - 全部使用POST请求 // 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: any) => Network.post("/user/create", data); export const createUser = (data: UserMutationPayload) =>
export const getAllUsers = (pageData: any = {}) => Network.post("/user/create", data);
Network.post("/user/list", pageData); export const getAllUsers = (pageData: UserListQuery = {}) =>
export const updateUser = (data: any) => Network.post("/user/update", data); Network.post<UserApiItem[]>("/user/list", pageData);
export const updateUser = (data: UserMutationPayload) =>
Network.post("/user/update", data);
export const deleteUser = (id: number) => Network.post("/user/delete", { id }); export const deleteUser = (id: number) => Network.post("/user/delete", { id });
export const getUserPackageInfo = () => Network.post("/user/package"); export const getUserPackageInfo = () =>
Network.post<UserPackageInfoApiData>("/user/package");
// 节点CRUD操作 - 全部使用POST请求 // 节点CRUD操作 - 全部使用POST请求
export const createNode = (data: any) => Network.post("/node/create", data); export const createNode = (data: NodeMutationPayload) =>
export const getNodeList = () => Network.post("/node/list"); Network.post("/node/create", data);
export const updateNode = (data: any) => Network.post("/node/update", data); export const getNodeList = () => 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 }); export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => export const getNodeInstallCommand = (id: number) =>
Network.post("/node/install", { id }); Network.post<string>("/node/install", { id });
export const updateNodeOrder = (data: { export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>; nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data); }) => Network.post("/node/update-order", data);
@@ -55,44 +89,50 @@ export const batchUpgradeNodes = (ids: number[], version?: string) =>
{ ids, version: version || "" }, { ids, version: version || "" },
{ timeout: 15 * 60 * 1000 }, { timeout: 15 * 60 * 1000 },
); );
export const getNodeReleases = () => Network.post("/node/releases"); export const getNodeReleases = () =>
Network.post<NodeReleaseApiItem[]>("/node/releases");
export const rollbackNode = (id: number) => export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id }); Network.post("/node/rollback", { id });
// 隧道CRUD操作 - 全部使用POST请求 // 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data); export const createTunnel = (data: TunnelMutationPayload) =>
export const getTunnelList = () => Network.post("/tunnel/list"); Network.post("/tunnel/create", data);
export const getTunnelList = () =>
Network.post<TunnelApiItem[]>("/tunnel/list");
export const getTunnelById = (id: number) => export const getTunnelById = (id: number) =>
Network.post("/tunnel/get", { id }); Network.post<TunnelApiItem>("/tunnel/get", { id });
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data); export const updateTunnel = (data: TunnelMutationPayload) =>
Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) => export const deleteTunnel = (id: number) =>
Network.post("/tunnel/delete", { id }); Network.post("/tunnel/delete", { id });
export const diagnoseTunnel = (tunnelId: number) => export const diagnoseTunnel = (tunnelId: number) =>
Network.post("/tunnel/diagnose", { tunnelId }); Network.post<TunnelDiagnosisApiData>("/tunnel/diagnose", { tunnelId });
export const updateTunnelOrder = (data: { export const updateTunnelOrder = (data: {
tunnels: Array<{ id: number; inx: number }>; tunnels: Array<{ id: number; inx: number }>;
}) => Network.post("/tunnel/update-order", data); }) => Network.post("/tunnel/update-order", data);
// 用户隧道权限管理操作 - 全部使用POST请求 // 用户隧道权限管理操作 - 全部使用POST请求
export const assignUserTunnel = (data: any) => export const assignUserTunnel = (data: UserTunnelAssignPayload) =>
Network.post("/tunnel/user/assign", data); Network.post("/tunnel/user/assign", data);
export const batchAssignUserTunnel = (data: { export const batchAssignUserTunnel = (data: {
userId: number; userId: number;
tunnels: Array<{ tunnelId: number; speedId?: number | null }>; tunnels: Array<{ tunnelId: number; speedId?: number | null }>;
}) => Network.post("/tunnel/user/batch-assign", data); }) => Network.post("/tunnel/user/batch-assign", data);
export const getUserTunnelList = (queryData: any = {}) => export const getUserTunnelList = (queryData: UserTunnelListQuery = {}) =>
Network.post("/tunnel/user/list", queryData); Network.post<UserTunnelPermissionApiItem[]>("/tunnel/user/list", queryData);
export const removeUserTunnel = (params: any) => export const removeUserTunnel = (params: UserTunnelRemovePayload) =>
Network.post("/tunnel/user/remove", params); Network.post("/tunnel/user/remove", params);
export const updateUserTunnel = (data: any) => export const updateUserTunnel = (data: UserTunnelAssignPayload) =>
Network.post("/tunnel/user/update", data); Network.post("/tunnel/user/update", data);
export const userTunnel = () => Network.post("/tunnel/user/tunnel"); export const userTunnel = () =>
Network.post<UserTunnelApiItem[]>("/tunnel/user/tunnel");
// 转发CRUD操作 - 全部使用POST请求 // 转发CRUD操作 - 全部使用POST请求
export const createForward = (data: any) => export const createForward = (data: ForwardMutationPayload) =>
Network.post("/forward/create", data); Network.post("/forward/create", data);
export const getForwardList = () => Network.post("/forward/list"); export const getForwardList = () =>
export const updateForward = (data: any) => Network.post<ForwardApiItem[]>("/forward/list");
export const updateForward = (data: ForwardMutationPayload) =>
Network.post("/forward/update", data); Network.post("/forward/update", data);
export const deleteForward = (id: number) => export const deleteForward = (id: number) =>
Network.post("/forward/delete", { id }); Network.post("/forward/delete", { id });
@@ -107,7 +147,7 @@ export const resumeForwardService = (forwardId: number) =>
// 转发诊断操作 // 转发诊断操作
export const diagnoseForward = (forwardId: number) => export const diagnoseForward = (forwardId: number) =>
Network.post("/forward/diagnose", { forwardId }); Network.post<ForwardDiagnosisApiData>("/forward/diagnose", { forwardId });
// 转发排序操作 // 转发排序操作
export const updateForwardOrder = (data: { export const updateForwardOrder = (data: {
@@ -115,34 +155,41 @@ export const updateForwardOrder = (data: {
}) => Network.post("/forward/update-order", data); }) => Network.post("/forward/update-order", data);
// 限速规则CRUD操作 - 全部使用POST请求 // 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => export const createSpeedLimit = (data: SpeedLimitMutationPayload) =>
Network.post("/speed-limit/create", data); Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list"); export const getSpeedLimitList = () =>
export const updateSpeedLimit = (data: any) => Network.post<SpeedLimitApiItem[]>("/speed-limit/list");
export const updateSpeedLimit = (data: SpeedLimitMutationPayload) =>
Network.post("/speed-limit/update", data); Network.post("/speed-limit/update", data);
export const deleteSpeedLimit = (id: number) => export const deleteSpeedLimit = (id: number) =>
Network.post("/speed-limit/delete", { id }); Network.post("/speed-limit/delete", { id });
// 修改密码接口 // 修改密码接口
export const updatePassword = (data: any) => export const updatePassword = (data: UpdatePasswordPayload) =>
Network.post("/user/updatePassword", data); Network.post("/user/updatePassword", data);
// 重置流量接口 // 重置流量接口
export const resetUserFlow = (data: { id: number; type: number }) => export const resetUserFlow = (data: { id: number; type: number }) =>
Network.post("/user/reset", data); Network.post("/user/reset", data);
export const getUserGroups = (id: number) =>
Network.post<number[]>("/user/groups", { id });
// 网站配置相关接口 // 网站配置相关接口
export const getConfigs = () => Network.post("/config/list"); export const getConfigs = () =>
Network.post<Record<string, string>>("/config/list");
export const getConfigByName = (name: string) => export const getConfigByName = (name: string) =>
Network.post("/config/get", { name }); Network.post<{ name: string; value: string }>("/config/get", { name });
export const updateConfigs = (configMap: Record<string, string>) => export const updateConfigs = (configMap: Record<string, string>) =>
Network.post("/config/update", configMap); Network.post("/config/update", configMap);
export const updateConfig = (name: string, value: string) => export const updateConfig = (name: string, value: string) =>
Network.post("/config/update-single", { name, value }); Network.post("/config/update-single", { name, value });
export const exportBackupData = () => Network.post("/backup/export"); export const exportBackupData = () => Network.post("/backup/export");
export const importBackupData = (data: any) => Network.post("/backup/import", data); export const importBackupData = (data: BackupImportPayload) =>
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data); Network.post("/backup/import", data);
export const restoreBackupData = (data: BackupImportPayload) =>
Network.post("/backup/restore", data);
// 验证码相关接口 // 验证码相关接口
export const checkCaptcha = () => Network.post("/captcha/check"); export const checkCaptcha = () => Network.post("/captcha/check");
@@ -152,26 +199,27 @@ export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
// 批量操作接口 // 批量操作接口
export const batchDeleteForwards = (ids: number[]) => export const batchDeleteForwards = (ids: number[]) =>
Network.post("/forward/batch-delete", { ids }); Network.post<BatchOperationResult>("/forward/batch-delete", { ids });
export const batchPauseForwards = (ids: number[]) => export const batchPauseForwards = (ids: number[]) =>
Network.post("/forward/batch-pause", { ids }); Network.post<BatchOperationResult>("/forward/batch-pause", { ids });
export const batchResumeForwards = (ids: number[]) => export const batchResumeForwards = (ids: number[]) =>
Network.post("/forward/batch-resume", { ids }); Network.post<BatchOperationResult>("/forward/batch-resume", { ids });
export const batchDeleteTunnels = (ids: number[]) => export const batchDeleteTunnels = (ids: number[]) =>
Network.post("/tunnel/batch-delete", { ids }); Network.post<BatchOperationResult>("/tunnel/batch-delete", { ids });
export const batchDeleteNodes = (ids: number[]) => export const batchDeleteNodes = (ids: number[]) =>
Network.post("/node/batch-delete", { ids }); Network.post<BatchOperationResult>("/node/batch-delete", { ids });
export const batchRedeployForwards = (ids: number[]) => export const batchRedeployForwards = (ids: number[]) =>
Network.post("/forward/batch-redeploy", { ids }); Network.post<BatchOperationResult>("/forward/batch-redeploy", { ids });
export const batchRedeployTunnels = (ids: number[]) => export const batchRedeployTunnels = (ids: number[]) =>
Network.post("/tunnel/batch-redeploy", { ids }); Network.post<BatchOperationResult>("/tunnel/batch-redeploy", { ids });
export const batchChangeTunnel = (data: { export const batchChangeTunnel = (data: {
forwardIds: number[]; forwardIds: number[];
targetTunnelId: number; targetTunnelId: number;
}) => Network.post("/forward/batch-change-tunnel", data); }) => Network.post<BatchOperationResult>("/forward/batch-change-tunnel", data);
// 分组与权限分配接口 // 分组与权限分配接口
export const getTunnelGroupList = () => Network.post("/group/tunnel/list"); export const getTunnelGroupList = () =>
Network.post<TunnelGroupApiItem[]>("/group/tunnel/list");
export const createTunnelGroup = (data: { name: string; status?: number }) => export const createTunnelGroup = (data: { name: string; status?: number }) =>
Network.post("/group/tunnel/create", data); Network.post("/group/tunnel/create", data);
export const updateTunnelGroup = (data: { export const updateTunnelGroup = (data: {
@@ -186,7 +234,8 @@ export const assignTunnelsToGroup = (data: {
tunnelIds: number[]; tunnelIds: number[];
}) => Network.post("/group/tunnel/assign", data); }) => Network.post("/group/tunnel/assign", data);
export const getUserGroupList = () => Network.post("/group/user/list"); export const getUserGroupList = () =>
Network.post<UserGroupApiItem[]>("/group/user/list");
export const createUserGroup = (data: { name: string; status?: number }) => export const createUserGroup = (data: { name: string; status?: number }) =>
Network.post("/group/user/create", data); Network.post("/group/user/create", data);
export const updateUserGroup = (data: { export const updateUserGroup = (data: {
@@ -202,7 +251,7 @@ export const assignUsersToGroup = (data: {
}) => Network.post("/group/user/assign", data); }) => Network.post("/group/user/assign", data);
export const getGroupPermissionList = () => export const getGroupPermissionList = () =>
Network.post("/group/permission/list"); Network.post<GroupPermissionApiItem[]>("/group/permission/list");
export const assignGroupPermission = (data: { export const assignGroupPermission = (data: {
userGroupId: number; userGroupId: number;
tunnelGroupId: number; tunnelGroupId: number;
@@ -211,7 +260,8 @@ export const removeGroupPermission = (id: number) =>
Network.post("/group/permission/remove", { id }); Network.post("/group/permission/remove", { id });
// 面板共享 (Federation) 接口 // 面板共享 (Federation) 接口
export const getPeerShareList = () => Network.post("/federation/share/list"); export const getPeerShareList = () =>
Network.post<Array<Record<string, unknown>>>("/federation/share/list");
export const createPeerShare = (data: { export const createPeerShare = (data: {
name: string; name: string;
nodeId: number; nodeId: number;
@@ -237,7 +287,9 @@ export const deletePeerShare = (id: number) =>
export const resetPeerShareFlow = (id: number) => export const resetPeerShareFlow = (id: number) =>
Network.post("/federation/share/reset-flow", { id }); Network.post("/federation/share/reset-flow", { id });
export const getPeerRemoteUsageList = () => export const getPeerRemoteUsageList = () =>
Network.post("/federation/share/remote-usage/list"); Network.post<Array<Record<string, unknown>>>(
"/federation/share/remote-usage/list",
);
export const importRemoteNode = (data: { remoteUrl: string; token: string }) => export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
Network.post("/federation/node/import", data); Network.post("/federation/node/import", data);
@@ -283,7 +335,7 @@ export const exportBackup = async (types: string[] = []) => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
export const importBackup = (data: { types: string[]; [key: string]: any }) => export const importBackup = (data: BackupImportPayload) =>
Network.post("/backup/import", data); Network.post("/backup/import", data);
export interface AnnouncementData { export interface AnnouncementData {
@@ -291,5 +343,7 @@ export interface AnnouncementData {
enabled: number; enabled: number;
} }
export const getAnnouncement = () => Network.get<AnnouncementData>("/announcement/get"); export const getAnnouncement = () =>
export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data); Network.get<AnnouncementData>("/announcement/get");
export const updateAnnouncement = (data: AnnouncementData) =>
Network.post("/announcement/update", data);
+38 -22
View File
@@ -1,6 +1,11 @@
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import {
extractApiErrorMessage,
isUnauthorizedError,
} from "@/api/error-message";
import { getPanelAddresses, isWebViewFunc } from "@/utils/panel"; import { getPanelAddresses, isWebViewFunc } from "@/utils/panel";
import { clearSession, getToken } from "@/utils/session";
interface PanelAddress { interface PanelAddress {
name: string; name: string;
@@ -37,7 +42,7 @@ export const reinitializeBaseURL = () => {
reinitializeBaseURL(); reinitializeBaseURL();
interface ApiResponse<T = any> { interface ApiResponse<T = unknown> {
code: number; code: number;
msg: string; msg: string;
data: T; data: T;
@@ -49,10 +54,7 @@ interface RequestOptions {
// 处理token失效的逻辑 // 处理token失效的逻辑
function handleTokenExpired() { function handleTokenExpired() {
// 清除localStorage中的token clearSession();
window.localStorage.removeItem("token");
window.localStorage.removeItem("role_id");
window.localStorage.removeItem("name");
// 跳转到登录页面 // 跳转到登录页面
if (window.location.pathname !== "/") { if (window.location.pathname !== "/") {
@@ -61,7 +63,7 @@ function handleTokenExpired() {
} }
// 检查响应是否为token失效 // 检查响应是否为token失效
function isTokenExpired(response: ApiResponse) { function isTokenExpired(response: ApiResponse<unknown>) {
return ( return (
response && response &&
response.code === 401 && response.code === 401 &&
@@ -72,9 +74,9 @@ function isTokenExpired(response: ApiResponse) {
} }
const Network = { const Network = {
get: function <T = any>( get: function <T = unknown>(
path: string = "", path: string = "",
data: any = {}, data: unknown = {},
options: RequestOptions = {}, options: RequestOptions = {},
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
return new Promise(function (resolve) { return new Promise(function (resolve) {
@@ -90,38 +92,45 @@ const Network = {
params: data, params: data,
timeout: options.timeout ?? 30000, timeout: options.timeout ?? 30000,
headers: { headers: {
Authorization: window.localStorage.getItem("token"), Authorization: getToken(),
}, },
}) })
.then(function (response: AxiosResponse<ApiResponse<T>>) { .then(function (response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效 // 检查是否token失效
if (isTokenExpired(response.data)) { if (isTokenExpired(response.data)) {
handleTokenExpired(); handleTokenExpired();
return;
} }
resolve(response.data); resolve(response.data);
}) })
.catch(function (error: any) { .catch(function (error: unknown) {
const errorMessage = extractApiErrorMessage(error);
// 检查是否是401错误(token失效) // 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) { if (isUnauthorizedError(error)) {
handleTokenExpired(); handleTokenExpired();
resolve({
code: 401,
msg: "未登录或token已过期",
data: null as T,
});
return; return;
} }
resolve({ resolve({
code: -1, code: -1,
msg: error.message || "网络请求失败", msg: errorMessage,
data: null as T, data: null as T,
}); });
}); });
}); });
}, },
post: function <T = any>( post: function <T = unknown>(
path: string = "", path: string = "",
data: any = {}, data: unknown = {},
options: RequestOptions = {}, options: RequestOptions = {},
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
return new Promise(function (resolve) { return new Promise(function (resolve) {
@@ -136,7 +145,7 @@ const Network = {
.post(path, data, { .post(path, data, {
timeout: options.timeout ?? 30000, timeout: options.timeout ?? 30000,
headers: { headers: {
Authorization: window.localStorage.getItem("token"), Authorization: getToken(),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) })
@@ -144,22 +153,29 @@ const Network = {
// 检查是否token失效 // 检查是否token失效
if (isTokenExpired(response.data)) { if (isTokenExpired(response.data)) {
handleTokenExpired(); handleTokenExpired();
return;
} }
resolve(response.data); resolve(response.data);
}) })
.catch(function (error: any) { .catch(function (error: unknown) {
const errorMessage = extractApiErrorMessage(error);
// 检查是否是401错误(token失效) // 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) { if (isUnauthorizedError(error)) {
handleTokenExpired(); handleTokenExpired();
resolve({
code: 401,
msg: "未登录或token已过期",
data: null as T,
});
return; return;
} }
resolve({ resolve({
code: -1, code: -1,
msg: error.message || "网络请求失败", msg: errorMessage,
data: null as T, data: null as T,
}); });
}); });
+306
View File
@@ -0,0 +1,306 @@
export interface NodeApiItem {
id: number;
name: string;
status: number;
inx?: number;
syncError?: string;
[key: string]: unknown;
}
export interface UserApiItem {
id: number;
user: string;
name?: string;
status: number;
flow: number;
num: number;
expTime?: number;
flowResetTime?: number;
inFlow?: number;
outFlow?: number;
[key: string]: unknown;
}
export interface UserListQuery {
current?: number;
size?: number;
keyword?: string;
[key: string]: unknown;
}
export interface TunnelApiItem {
id: number;
name: string;
type: number;
status: number;
entryNodeId: number;
exitNodeId: number;
inx?: number;
[key: string]: unknown;
}
export interface ForwardApiItem {
id: number;
name: string;
status: number;
tunnelName?: string;
inIp?: string;
inPort?: number;
remoteAddr?: string;
inFlow?: number;
outFlow?: number;
userId?: number;
tunnelId?: number;
inx?: number;
[key: string]: unknown;
}
export interface UserTunnelApiItem {
id: number;
name: string;
tunnelId?: number;
tunnelName?: string;
inNodePortSta?: number;
inNodePortEnd?: number;
speedId?: number | null;
[key: string]: unknown;
}
export interface UserTunnelPermissionApiItem {
id: number;
userId: number;
tunnelId: number;
tunnelName: string;
status: number;
flow: number;
num: number;
expTime: number;
flowResetTime: number;
speedId?: number | null;
speedLimitName?: string;
inFlow: number;
outFlow: number;
tunnelFlow?: number;
[key: string]: unknown;
}
export interface StatisticsFlowApiItem {
id: number;
userId: number;
flow: number;
totalFlow: number;
time: string;
[key: string]: unknown;
}
export interface SpeedLimitApiItem {
id: number;
name: string;
tunnelId: number;
speed: number;
status: number;
tunnelName: string;
createdTime: string;
updatedTime: string;
uploadSpeed?: number;
downloadSpeed?: number;
[key: string]: unknown;
}
export interface TunnelGroupApiItem {
id: number;
name: string;
status: number;
tunnelIds: number[];
tunnelNames: string[];
createdTime: number;
[key: string]: unknown;
}
export interface UserGroupApiItem {
id: number;
name: string;
status: number;
userIds: number[];
userNames: string[];
createdTime: number;
[key: string]: unknown;
}
export interface GroupPermissionApiItem {
id: number;
userGroupId: number;
userGroupName: string;
tunnelGroupId: number;
tunnelGroupName: string;
createdTime: number;
[key: string]: unknown;
}
export interface TunnelDiagnosisApiItem {
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
targetPort?: number;
message?: string;
averageTime?: number;
packetLoss?: number;
fromChainType?: number;
fromInx?: number;
toChainType?: number;
toInx?: number;
[key: string]: unknown;
}
export interface TunnelDiagnosisApiData {
tunnelName: string;
tunnelType: string;
timestamp: number;
results: TunnelDiagnosisApiItem[];
}
export interface ForwardDiagnosisApiData {
forwardName: string;
timestamp: number;
results: TunnelDiagnosisApiItem[];
}
export interface NodeReleaseApiItem {
version: string;
name: string;
publishedAt: string;
prerelease: boolean;
}
export interface UserPackageInfoApiData {
userInfo: {
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
[key: string]: unknown;
};
tunnelPermissions: UserTunnelPermissionApiItem[];
forwards: ForwardApiItem[];
statisticsFlows: StatisticsFlowApiItem[];
[key: string]: unknown;
}
export interface BatchOperationResult {
successCount: number;
failCount: number;
[key: string]: unknown;
}
export interface UserMutationPayload {
id?: number;
user?: string;
name?: string;
password?: string;
status?: number;
flow?: number;
num?: number;
expTime?: number | string;
flowResetTime?: number;
tunnelFlow?: number;
}
export interface NodeMutationPayload {
id?: number | null;
name?: string;
status?: number;
inx?: number;
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
port?: string;
tcpListenAddr?: string;
udpListenAddr?: string;
interfaceName?: string;
http?: number;
tls?: number;
socks?: number;
}
export interface TunnelChainNodePayload {
nodeId: number;
protocol?: string;
strategy?: string;
chainType?: number;
inx?: number;
}
export interface TunnelMutationPayload {
id?: number;
name?: string;
type?: number;
status?: number;
flow?: number;
trafficRatio?: number;
inIp?: string;
ipPreference?: string;
inNodeId?: TunnelChainNodePayload[];
outNodeId?: TunnelChainNodePayload[];
chainNodes?: TunnelChainNodePayload[][];
}
export interface UserTunnelAssignPayload {
userId?: number;
id?: number;
tunnelId?: number;
flow?: number;
num?: number;
expTime?: number;
flowResetTime?: number;
status?: number;
speedId?: number | null;
tunnels?: Array<{ tunnelId: number; speedId?: number | null }>;
}
export interface UserTunnelListQuery {
userId?: number;
tunnelId?: number;
current?: number;
size?: number;
}
export interface UserTunnelRemovePayload {
id?: number;
userId?: number;
tunnelId?: number;
}
export interface ForwardMutationPayload {
id?: number;
name?: string;
status?: number;
tunnelId?: number | null;
inIp?: string;
inPort?: number | null;
remoteAddr?: string;
strategy?: string;
}
export interface SpeedLimitMutationPayload {
id?: number;
name?: string;
speed?: number;
status?: number;
tunnelId?: number | null;
tunnelName?: string;
}
export interface UpdatePasswordPayload {
currentPassword: string;
newPassword: string;
newUsername?: string;
}
export interface BackupImportPayload {
types: string[];
[key: string]: unknown;
}
+5 -10
View File
@@ -1,26 +1,21 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { Link } from "@heroui/link"; import { Link } from "@/shadcn-bridge/heroui/link";
import { import {
Navbar as HeroUINavbar, Navbar as HeroUINavbar,
NavbarBrand, NavbarBrand,
NavbarContent, NavbarContent,
} from "@heroui/navbar"; } from "@/shadcn-bridge/heroui/navbar";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isWebViewFunc } from "@/utils/panel";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { siteConfig, getCachedConfig } from "@/config/site"; import { siteConfig, getCachedConfig } from "@/config/site";
import { useWebViewMode } from "@/hooks/useWebViewMode";
export const Navbar = () => { export const Navbar = () => {
const navigate = useNavigate(); const navigate = useNavigate();
// 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁 // 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁
const [appName, setAppName] = useState(siteConfig.name); const [appName, setAppName] = useState(siteConfig.name);
const [isWebView, setIsWebView] = useState(false); const isWebView = useWebViewMode();
// 检测是否在WebView中运行
useEffect(() => {
setIsWebView(isWebViewFunc());
}, []);
useEffect(() => { useEffect(() => {
// 异步检查是否有更新的配置 // 异步检查是否有更新的配置
@@ -0,0 +1,42 @@
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
interface BaseStateProps {
message: string;
className?: string;
}
export const PageLoadingState = ({
message,
className = "h-64",
}: BaseStateProps) => {
return (
<div className={`flex items-center justify-center ${className}`}>
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">{message}</span>
</div>
</div>
);
};
export const PageEmptyState = ({
message,
className = "h-48",
}: BaseStateProps) => {
return (
<div className={`flex items-center justify-center ${className}`}>
<span className="text-default-500">{message}</span>
</div>
);
};
export const PageErrorState = ({
message,
className = "h-48",
}: BaseStateProps) => {
return (
<div className={`flex items-center justify-center ${className}`}>
<span className="text-danger">{message}</span>
</div>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTheme } from "@heroui/use-theme"; import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
interface ThemeProviderProps { interface ThemeProviderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -0,0 +1,59 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
className={cn("border-b", className)}
data-slot="accordion-item"
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex" data-slot="accordion-header">
<AccordionPrimitive.Trigger
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
data-slot="accordion-trigger"
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-default-500 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
data-slot="accordion-content"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm",
{
variants: {
variant: {
default: "border-default-200 bg-default-50/70 text-foreground",
destructive: "border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
success: "border-success-200 bg-success-50 text-success-700 dark:text-success-300",
warning: "border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
className={cn(alertVariants({ className, variant }))}
data-slot="alert"
role="alert"
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
return (
<h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
data-slot="alert-title"
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("text-sm opacity-90", className)} data-slot="alert-description" {...props} />
);
}
export { Alert, AlertDescription, AlertTitle };
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-danger text-white",
outline: "text-foreground",
success: "border-transparent bg-success text-white",
warning: "border-transparent bg-warning text-white",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return (
<span
className={cn(badgeVariants({ className, variant }))}
data-slot="badge"
{...props}
/>
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-danger text-white hover:bg-danger/90",
outline: "border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
link: "text-primary underline-offset-4 hover:underline",
light: "bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, className, size, variant, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ className, size, variant }))}
data-slot="button"
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"rounded-xl border border-default-200 bg-white text-card-foreground shadow-sm dark:bg-default-50/20",
className,
)}
data-slot="card"
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-1.5 p-6", className)}
data-slot="card-header"
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
return (
<h3
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
data-slot="card-title"
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn("text-sm text-default-500", className)}
data-slot="card-description"
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("p-6 pt-0", className)} data-slot="card-content" {...props} />
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex items-center p-6 pt-0", className)}
data-slot="card-footer"
{...props}
/>
);
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -0,0 +1,27 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current"
data-slot="checkbox-indicator"
>
<CheckIcon className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+123
View File
@@ -0,0 +1,123 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
data-slot="dialog-overlay"
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg dark:bg-default-50",
className,
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
data-slot="dialog-header"
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
data-slot="dialog-footer"
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
data-slot="dialog-title"
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-sm text-default-500", className)}
data-slot="dialog-description"
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,220 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-default-100 data-[state=open]:bg-default-100",
inset && "pl-8",
className,
)}
data-slot="dropdown-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
className,
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
className,
)}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
data-slot="dropdown-menu-item"
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
data-slot="dropdown-menu-label"
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-divider", className)}
data-slot="dropdown-menu-separator"
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-default-500", className)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-default-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
data-slot="input"
type={type}
{...props}
/>
);
}
export { Input };
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
data-slot="label"
{...props}
/>
);
}
export { Label };
@@ -0,0 +1,30 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
indicatorClassName,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}) {
return (
<ProgressPrimitive.Root
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-default-200", className)}
data-slot="progress"
value={value}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
data-slot="progress-indicator"
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
@@ -0,0 +1,37 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
data-slot="radio-group"
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
data-slot="radio-group-item"
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center" data-slot="radio-group-indicator">
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
+162
View File
@@ -0,0 +1,162 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-default-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
data-slot="select-trigger"
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
data-slot="select-scroll-up-button"
{...props}
>
<ChevronUpIcon className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
data-slot="select-scroll-down-button"
{...props}
>
<ChevronDownIcon className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
data-slot="select-content"
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
data-slot="select-label"
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-default-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
data-slot="select-item"
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-divider", className)}
data-slot="select-separator"
{...props}
/>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,27 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
decorative = true,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
className={cn(
"shrink-0 bg-divider",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
data-slot="separator"
decorative={decorative}
orientation={orientation}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,26 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-default-200",
className,
)}
data-slot="switch"
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
data-slot="switch-thumb"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-auto" data-slot="table-wrapper">
<table className={cn("w-full caption-bottom text-sm", className)} data-slot="table" {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
className={cn("[&_tr]:border-b", className)}
data-slot="table-header"
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
className={cn("[&_tr:last-child]:border-0", className)}
data-slot="table-body"
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn("border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0", className)}
data-slot="table-footer"
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn("border-b transition-colors hover:bg-default-50/50", className)}
data-slot="table-row"
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
className={cn(
"h-10 px-2 text-left align-middle font-medium text-default-600 [&:has([role=checkbox])]:pr-0",
className,
)}
data-slot="table-head"
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
data-slot="table-cell"
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption className={cn("mt-4 text-sm text-default-500", className)} data-slot="table-caption" {...props} />
);
}
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} data-slot="tabs" {...props} />
);
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-default-100 p-1 text-default-500",
className,
)}
data-slot="tabs-list"
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:bg-default-50",
className,
)}
data-slot="tabs-trigger"
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn("outline-none", className)}
data-slot="tabs-content"
{...props}
/>
);
}
export { Tabs, TabsContent, TabsList, TabsTrigger };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
className={cn(
"flex min-h-[84px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-default-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
data-slot="textarea"
{...props}
/>
);
}
export { Textarea };
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { DEFAULT_MOBILE_BREAKPOINT } from "@/hooks/useMobileBreakpoint";
const detectH5Mode = (): boolean => {
const isMobile = window.innerWidth <= DEFAULT_MOBILE_BREAKPOINT;
const isMobileBrowser =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
const urlParams = new URLSearchParams(window.location.search);
const isH5Param = urlParams.get("h5") === "true";
return isMobile || isMobileBrowser || isH5Param;
};
export const useH5Mode = (): boolean => {
const [isH5, setIsH5] = useState(detectH5Mode);
useEffect(() => {
const checkH5Mode = () => {
setIsH5(detectH5Mode());
};
window.addEventListener("resize", checkH5Mode);
return () => window.removeEventListener("resize", checkH5Mode);
}, []);
return isH5;
};
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
export const DEFAULT_MOBILE_BREAKPOINT = 768;
export const useMobileBreakpoint = (
breakpoint = DEFAULT_MOBILE_BREAKPOINT,
): boolean => {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.innerWidth < breakpoint;
});
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const onResize = () => {
setIsMobile(window.innerWidth < breakpoint);
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [breakpoint]);
return isMobile;
};
@@ -0,0 +1,21 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
export const useScrollTopOnPathChange = (): void => {
const { pathname } = useLocation();
useEffect(() => {
if (!pathname) {
return;
}
try {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch {
window.scrollTo(0, 0);
}
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}, [pathname]);
};
+13
View File
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
import { isWebViewFunc } from "@/utils/panel";
export const useWebViewMode = (): boolean => {
const [isWebView, setIsWebView] = useState(false);
useEffect(() => {
setIsWebView(isWebViewFunc());
}, []);
return isWebView;
};
+15 -33
View File
@@ -1,12 +1,12 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { import {
Dropdown, Dropdown,
DropdownTrigger, DropdownTrigger,
DropdownMenu, DropdownMenu,
DropdownItem, DropdownItem,
} from "@heroui/dropdown"; } from "@/shadcn-bridge/heroui/dropdown";
import { import {
Modal, Modal,
ModalContent, ModalContent,
@@ -14,14 +14,16 @@ import {
ModalBody, ModalBody,
ModalFooter, ModalFooter,
useDisclosure, useDisclosure,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { updatePassword } from "@/api"; import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout"; import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
import { getAdminFlag, getSessionName } from "@/utils/session";
interface MenuItem { interface MenuItem {
path: string; path: string;
@@ -46,7 +48,6 @@ export default function AdminLayout({
const location = useLocation(); const location = useLocation();
const { isOpen, onOpen, onOpenChange } = useDisclosure(); const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [isMobile, setIsMobile] = useState(false);
const [mobileMenuVisible, setMobileMenuVisible] = useState(false); const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
@@ -57,6 +58,7 @@ export default function AdminLayout({
newPassword: "", newPassword: "",
confirmPassword: "", confirmPassword: "",
}); });
const isMobile = useMobileBreakpoint();
// 菜单项配置 // 菜单项配置
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
@@ -170,41 +172,21 @@ export default function AdminLayout({
}, },
]; ];
// 检查移动端
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
if (window.innerWidth > 768) {
setMobileMenuVisible(false);
}
};
useEffect(() => { useEffect(() => {
// 获取用户信息 // 获取用户信息
const name = localStorage.getItem("name") || "Admin"; const name = getSessionName() || "Admin";
const adminFlag = getAdminFlag();
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断
localStorage.setItem("admin", adminFlag.toString());
}
setUsername(name); setUsername(name);
setIsAdmin(adminFlag); setIsAdmin(adminFlag);
// 响应式检查
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []); }, []);
useEffect(() => {
if (!isMobile) {
setMobileMenuVisible(false);
}
}, [isMobile]);
// 退出登录 // 退出登录
const handleLogout = () => { const handleLogout = () => {
safeLogout(); safeLogout();
+4 -13
View File
@@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
export default function H5SimpleLayout({ export default function H5SimpleLayout({
children, children,
@@ -11,18 +12,8 @@ export default function H5SimpleLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
// 路由切换时回到顶部,避免上一页滚动位置保留 useScrollTopOnPathChange();
React.useEffect(() => {
try {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch {
window.scrollTo(0, 0);
}
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}, [location.pathname]);
const handleBack = () => { const handleBack = () => {
navigate("/profile"); navigate("/profile");
+5 -23
View File
@@ -3,6 +3,8 @@ import { useNavigate, useLocation } from "react-router-dom";
import { Logo } from "@/components/icons"; import { Logo } from "@/components/icons";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { getAdminFlag } from "@/utils/session";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
interface TabItem { interface TabItem {
path: string; path: string;
@@ -16,6 +18,8 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
const location = useLocation(); const location = useLocation();
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
useScrollTopOnPathChange();
// Tabbar配置 // Tabbar配置
const tabItems: TabItem[] = [ const tabItems: TabItem[] = [
{ {
@@ -80,18 +84,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
]; ];
useEffect(() => { useEffect(() => {
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) setIsAdmin(getAdminFlag());
let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断
localStorage.setItem("admin", adminFlag.toString());
}
setIsAdmin(adminFlag);
}, []); }, []);
// Tab点击处理 // Tab点击处理
@@ -104,17 +97,6 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
(item) => !item.adminOnly || isAdmin, (item) => !item.adminOnly || isAdmin,
); );
// 路由切换时回到页面顶部,避免上一页的滚动位置遗留
useEffect(() => {
try {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
} catch {
window.scrollTo(0, 0);
}
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}, [location.pathname]);
return ( return (
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black"> <div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
{/* 顶部导航栏 */} {/* 顶部导航栏 */}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+5 -4
View File
@@ -1,6 +1,6 @@
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -115,6 +115,7 @@ export default function ChangePasswordPage() {
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center"> <CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3"> <div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
<svg <svg
aria-hidden="true"
className="w-6 h-6 text-warning-600" className="w-6 h-6 text-warning-600"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -200,7 +201,7 @@ export default function ChangePasswordPage() {
disabled={loading} disabled={loading}
isLoading={loading} isLoading={loading}
size="lg" size="lg"
onClick={handleSubmit} onPress={handleSubmit}
> >
{loading ? "修改中..." : "立即修改账号密码"} {loading ? "修改中..." : "立即修改账号密码"}
</Button> </Button>
+33 -17
View File
@@ -1,24 +1,31 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@heroui/input"; import { Textarea } from "@/shadcn-bridge/heroui/input";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@heroui/divider"; import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Switch } from "@heroui/switch"; import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { import {
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api"; import {
updateConfigs,
exportBackup,
importBackup,
getAnnouncement,
updateAnnouncement,
type AnnouncementData,
} from "@/api";
import { SettingsIcon } from "@/components/icons"; import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth"; import { isAdmin } from "@/utils/auth";
import { import {
@@ -501,7 +508,9 @@ export default function ConfigPage() {
<Button <Button
size="sm" size="sm"
variant="flat" variant="flat"
onPress={() => setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])} onPress={() =>
setTypes(allSelected ? [] : [...BACKUP_TYPE_VALUES])
}
> >
{allSelected ? "取消全选" : "全选"} {allSelected ? "取消全选" : "全选"}
</Button> </Button>
@@ -590,7 +599,7 @@ export default function ConfigPage() {
disabled={!hasChanges} disabled={!hasChanges}
isLoading={saving} isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />} startContent={<SaveIcon className="w-4 h-4" />}
onClick={handleSave} onPress={handleSave}
> >
{saving ? "保存中..." : "保存配置"} {saving ? "保存中..." : "保存配置"}
</Button> </Button>
@@ -675,7 +684,10 @@ export default function ConfigPage() {
<Switch <Switch
isSelected={announcement.enabled === 1} isSelected={announcement.enabled === 1}
onValueChange={(checked) => onValueChange={(checked) =>
setAnnouncement({ ...announcement, enabled: checked ? 1 : 0 }) setAnnouncement({
...announcement,
enabled: checked ? 1 : 0,
})
} }
> >
<span className="text-sm text-gray-700 dark:text-gray-300"> <span className="text-sm text-gray-700 dark:text-gray-300">
@@ -689,10 +701,10 @@ export default function ConfigPage() {
<Textarea <Textarea
label="公告内容" label="公告内容"
minRows={4}
placeholder="请输入公告内容" placeholder="请输入公告内容"
value={announcement.content} value={announcement.content}
variant="bordered" variant="bordered"
minRows={4}
onChange={(e) => onChange={(e) =>
setAnnouncement({ ...announcement, content: e.target.value }) setAnnouncement({ ...announcement, content: e.target.value })
} }
@@ -703,7 +715,7 @@ export default function ConfigPage() {
color="primary" color="primary"
isLoading={announcementSaving} isLoading={announcementSaving}
startContent={<SaveIcon className="w-4 h-4" />} startContent={<SaveIcon className="w-4 h-4" />}
onClick={saveAnnouncement} onPress={saveAnnouncement}
> >
保存公告 保存公告
</Button> </Button>
@@ -801,7 +813,11 @@ export default function ConfigPage() {
<Button variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
取消 取消
</Button> </Button>
<Button color="primary" isLoading={exporting} onPress={handleExport}> <Button
color="primary"
isLoading={exporting}
onPress={handleExport}
>
{exporting ? "导出中..." : "确认导出"} {exporting ? "导出中..." : "确认导出"}
</Button> </Button>
</ModalFooter> </ModalFooter>
+159 -538
View File
@@ -1,53 +1,18 @@
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal"; import { Modal, ModalContent, ModalHeader, ModalBody } from "@/shadcn-bridge/heroui/modal";
import { useState, useEffect } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
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 { import {
LineChart, useDashboardData,
Line, type DashboardForward as Forward,
XAxis, type DashboardUserTunnel as UserTunnel,
YAxis, } from "@/pages/dashboard/use-dashboard-data";
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { getUserPackageInfo, getAnnouncement, type AnnouncementData } from "@/api";
interface UserInfo {
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
}
interface UserTunnel {
id: number;
tunnelId: number;
tunnelName: string;
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
tunnelFlow: number;
}
interface Forward {
id: number;
name: string;
tunnelId: number;
tunnelName: string;
inIp: string;
inPort: number;
remoteAddr: string;
inFlow: number;
outFlow: number;
}
interface AddressItem { interface AddressItem {
id: number; id: number;
@@ -56,176 +21,21 @@ interface AddressItem {
copying: boolean; copying: boolean;
} }
interface StatisticsFlow {
id: number;
userId: number;
flow: number;
totalFlow: number;
time: string;
}
export default function DashboardPage() { export default function DashboardPage() {
const [loading, setLoading] = useState(true); const {
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo); loading,
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]); userInfo,
const [forwardList, setForwardList] = useState<Forward[]>([]); userTunnels,
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]); forwardList,
const [isAdmin, setIsAdmin] = useState(false); statisticsFlows,
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null); isAdmin,
announcement,
} = useDashboardData();
const [addressModalOpen, setAddressModalOpen] = useState(false); const [addressModalOpen, setAddressModalOpen] = useState(false);
const [addressModalTitle, setAddressModalTitle] = useState(""); const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<AddressItem[]>([]); const [addressList, setAddressList] = useState<AddressItem[]>([]);
// 检查有效期通知
const checkExpirationNotifications = (
userInfo: UserInfo,
tunnels: UserTunnel[],
) => {
// 避免重复通知,检查是否已经显示过
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
const lastNotified = localStorage.getItem("lastNotified");
if (lastNotified === notificationKey) {
return; // 已经通知过,不重复显示
}
let hasNotification = false;
// 检查主账户有效期
if (userInfo.expTime) {
const expDate = new Date(userInfo.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast("账户将于明天过期,请及时续费", {
icon: "⚠️",
duration: 6000,
style: { background: "#f59e0b", color: "#fff" },
});
} else {
toast(`账户将于${diffDays}天后过期,请及时续费`, {
icon: "⚠️",
duration: 6000,
style: { background: "#f59e0b", color: "#fff" },
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast("账户已过期,请立即续费", {
icon: "⚠️",
duration: 8000,
style: { background: "#ef4444", color: "#fff" },
});
}
}
}
// 检查隧道有效期
tunnels.forEach((tunnel) => {
if (tunnel.expTime) {
const expDate = new Date(tunnel.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
icon: "⚠️",
duration: 5000,
style: { background: "#f59e0b", color: "#fff" },
});
} else {
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
icon: "⚠️",
duration: 5000,
style: { background: "#f59e0b", color: "#fff" },
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast(`隧道"${tunnel.tunnelName}"已过期`, {
icon: "⚠️",
duration: 6000,
style: { background: "#ef4444", color: "#fff" },
});
}
}
}
});
// 如果显示了通知,记录防止重复
if (hasNotification) {
localStorage.setItem("lastNotified", notificationKey);
}
};
useEffect(() => {
setLoading(true);
setUserInfo({} as UserInfo);
setUserTunnels([]);
setForwardList([]);
setStatisticsFlows([]);
const adminStatus = localStorage.getItem("admin");
setIsAdmin(adminStatus === "true");
loadPackageData();
loadAnnouncement();
localStorage.setItem("e", "/dashboard");
}, []);
const loadAnnouncement = async () => {
try {
const res = await getAnnouncement();
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
}
} catch (error) {
console.error("Failed to load announcement:", error);
}
};
const loadPackageData = async () => {
setLoading(true);
try {
const res = await getUserPackageInfo();
if (res.code === 0) {
const data = res.data;
setUserInfo(data.userInfo || {});
setUserTunnels(data.tunnelPermissions || []);
setForwardList(data.forwards || []);
setStatisticsFlows(data.statisticsFlows || []);
// 检查有效期并显示通知
checkExpirationNotifications(
data.userInfo,
data.tunnelPermissions || [],
);
} else {
toast.error(res.msg || "获取套餐信息失败");
}
} catch {
toast.error("获取套餐信息失败");
} finally {
setLoading(false);
}
};
const formatFlow = (value: number, unit: string = "bytes"): string => { const formatFlow = (value: number, unit: string = "bytes"): string => {
// 99999 表示无限制 // 99999 表示无限制
if (value === 99999) { if (value === 99999) {
@@ -283,7 +93,7 @@ export default function DashboardPage() {
})); }));
}; };
const getExpStatus = (expTime?: string) => { const getExpStatus = (expTime?: string | number) => {
if (!expTime) if (!expTime)
return { return {
color: "text-green-600 dark:text-green-400", color: "text-green-600 dark:text-green-400",
@@ -444,7 +254,7 @@ export default function DashboardPage() {
const now = new Date(); const now = new Date();
const currentDay = now.getDate(); const currentDay = now.getDate();
let daysUntilReset; let daysUntilReset: number;
if (resetDay > currentDay) { if (resetDay > currentDay) {
daysUntilReset = resetDay - currentDay; daysUntilReset = resetDay - currentDay;
@@ -527,7 +337,7 @@ export default function DashboardPage() {
} }
const firstIp = items[0]; const firstIp = items[0];
let formattedFirstIp; let formattedFirstIp: string;
if (firstIp.includes(":") && !firstIp.startsWith("[")) { if (firstIp.includes(":") && !firstIp.startsWith("[")) {
formattedFirstIp = `[${firstIp}]`; formattedFirstIp = `[${firstIp}]`;
@@ -592,7 +402,7 @@ export default function DashboardPage() {
// 检查是否已经包含端口 // 检查是否已经包含端口
const hasPort = /:\d+$/.test(items[0]); const hasPort = /:\d+$/.test(items[0]);
let formattedList; let formattedList: AddressItem[];
if (hasPort) { if (hasPort) {
// 已经包含完整的 IP:Port 组合,直接使用 // 已经包含完整的 IP:Port 组合,直接使用
@@ -605,7 +415,7 @@ export default function DashboardPage() {
} else { } else {
// 只包含IP,需要添加端口 // 只包含IP,需要添加端口
formattedList = items.map((ip, index) => { formattedList = items.map((ip, index) => {
let formattedAddress; let formattedAddress: string;
if (ip.includes(":") && !ip.startsWith("[")) { if (ip.includes(":") && !ip.startsWith("[")) {
formattedAddress = `[${ip}]:${port}`; formattedAddress = `[${ip}]:${port}`;
@@ -703,324 +513,147 @@ export default function DashboardPage() {
if (loading) { if (loading) {
return ( return (
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4"> <div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
<div className="flex items-center justify-center h-64"> <PageLoadingState message="正在加载数据..." />
<div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full" />
<span className="text-default-600">正在加载数据...</span>
</div>
</div>
</div> </div>
); );
} }
return ( return (
<div className="px-3 lg:px-6 py-2 lg:py-4"> <div className="px-3 lg:px-6 py-2 lg:py-4">
{announcement && announcement.content && ( {announcement && <AnnouncementBanner announcement={announcement} />}
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
<CardBody className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<svg
className="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
fillRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
公告
</h3>
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
{announcement.content}
</p>
</div>
</div>
</CardBody>
</Card>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow"> <MetricCard
<CardBody className="p-3 lg:p-4"> icon={
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">
总流量
</p>
<div className="p-1.5 lg:p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<svg
className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">
{formatFlow(userInfo.flow, "gb")}
</p>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">
已用流量
</p>
<div className="p-1.5 lg:p-2 bg-green-100 dark:bg-green-500/20 rounded-lg flex-shrink-0">
<svg
className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
fillRule="evenodd"
/>
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">
{formatFlow(calculateUserTotalUsedFlow())}
</p>
<div className="mt-1">
{renderProgressBar(
calculateUsagePercentage("flow"),
"sm",
userInfo.flow === 99999,
)}
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-default-500 truncate">
{userInfo.flow === 99999
? "无限制"
: `${calculateUsagePercentage("flow").toFixed(1)}%`}
</p>
{userInfo.flowResetTime !== undefined &&
userInfo.flowResetTime !== null && (
<div className="text-xs text-default-500 flex items-center gap-1">
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
fillRule="evenodd"
/>
</svg>
<span className="truncate">
{formatResetTime(userInfo.flowResetTime)}
</span>
</div>
)}
</div>
</div>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">
转发配额
</p>
<div className="p-1.5 lg:p-2 bg-purple-100 dark:bg-purple-500/20 rounded-lg flex-shrink-0">
<svg
className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
fillRule="evenodd"
/>
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">
{formatNumber(userInfo.num || 0)}
</p>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">
已用转发
</p>
<div className="p-1.5 lg:p-2 bg-orange-100 dark:bg-orange-500/20 rounded-lg flex-shrink-0">
<svg
className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
fillRule="evenodd"
/>
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">
{forwardList.length}
</p>
<div className="mt-1">
{renderProgressBar(
calculateUsagePercentage("forwards"),
"sm",
userInfo.num === 99999,
)}
<p className="text-xs text-default-500 mt-1 truncate">
{userInfo.num === 99999
? "无限制"
: `${calculateUsagePercentage("forwards").toFixed(1)}%`}
</p>
</div>
</div>
</CardBody>
</Card>
</div>
{/* 24小时流量统计图表 */}
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg <svg
className="w-5 h-5 text-primary" aria-hidden="true"
className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
> >
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" /> <path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
</svg> </svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground"> }
24小时流量统计 iconClassName="bg-blue-100 dark:bg-blue-500/20"
</h2> title="总流量"
</div> value={formatFlow(userInfo.flow, "gb")}
</CardHeader> />
<CardBody className="pt-0">
{statisticsFlows.length === 0 ? (
<div className="text-center py-12">
<svg
className="w-12 h-12 text-default-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
<p className="text-default-500">暂无流量统计数据</p>
</div>
) : (
<div className="space-y-4">
{/* 流量趋势图 */}
<div className="h-64 lg:h-80 w-full">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={processFlowChartData()}>
<CartesianGrid
className="opacity-30"
strokeDasharray="3 3"
/>
<XAxis
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
dataKey="time"
tick={{ fontSize: 12 }}
tickLine={false}
/>
<YAxis
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
tick={{ fontSize: 12 }}
tickFormatter={(value: number | string) => {
const v =
typeof value === "number" ? value : Number(value);
if (!Number.isFinite(v)) return String(value); <MetricCard
if (v === 0) return "0"; bottomContent={
if (v < 1024) return `${v}B`; <div className="mt-1">
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`; {renderProgressBar(
if (v < 1024 * 1024 * 1024) calculateUsagePercentage("flow"),
return `${(v / (1024 * 1024)).toFixed(1)}M`; "sm",
userInfo.flow === 99999,
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`; )}
}} <div className="flex items-center justify-between mt-1">
tickLine={false} <p className="text-xs text-default-500 truncate">
/> {userInfo.flow === 99999
<Tooltip ? "无限制"
content={({ : `${calculateUsagePercentage("flow").toFixed(1)}%`}
active, </p>
payload, {userInfo.flowResetTime !== undefined &&
label, userInfo.flowResetTime !== null && (
}: { <div className="text-xs text-default-500 flex items-center gap-1">
active?: boolean; <svg
payload?: Array<{ value?: number | string }>; aria-hidden="true"
label?: string | number; className="w-3 h-3"
}) => { fill="currentColor"
if (active && payload && payload.length) { viewBox="0 0 20 20"
const firstValue = payload[0]?.value; >
const numericValue = <path
typeof firstValue === "number" clipRule="evenodd"
? firstValue d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
: Number(firstValue); fillRule="evenodd"
const flowValue = Number.isFinite(numericValue) />
? numericValue </svg>
: 0; <span className="truncate">
{formatResetTime(userInfo.flowResetTime)}
return ( </span>
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3"> </div>
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p> )}
<p className="text-primary">
{`流量: ${formatFlow(flowValue)}`}
</p>
</div>
);
}
return null;
}}
/>
<Line
activeDot={{
r: 4,
stroke: "#8b5cf6",
strokeWidth: 2,
fill: "#fff",
}}
dataKey="flow"
dot={false}
stroke="#8b5cf6"
strokeWidth={3}
type="monotone"
/>
</LineChart>
</ResponsiveContainer>
</div> </div>
</div> </div>
)} }
</CardBody> icon={
</Card> <svg
aria-hidden="true"
className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
fillRule="evenodd"
/>
</svg>
}
iconClassName="bg-green-100 dark:bg-green-500/20"
title="已用流量"
value={formatFlow(calculateUserTotalUsedFlow())}
/>
<MetricCard
icon={
<svg
aria-hidden="true"
className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
fillRule="evenodd"
/>
</svg>
}
iconClassName="bg-purple-100 dark:bg-purple-500/20"
title="转发配额"
value={formatNumber(userInfo.num || 0)}
/>
<MetricCard
bottomContent={
<div className="mt-1">
{renderProgressBar(
calculateUsagePercentage("forwards"),
"sm",
userInfo.num === 99999,
)}
<p className="text-xs text-default-500 mt-1 truncate">
{userInfo.num === 99999
? "无限制"
: `${calculateUsagePercentage("forwards").toFixed(1)}%`}
</p>
</div>
}
icon={
<svg
aria-hidden="true"
className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
fillRule="evenodd"
/>
</svg>
}
iconClassName="bg-orange-100 dark:bg-orange-500/20"
title="已用转发"
value={forwardList.length}
/>
</div>
<FlowChartCard
chartData={processFlowChartData()}
formatFlow={formatFlow}
statisticsFlowsCount={statisticsFlows.length}
/>
{/* 隧道权限 - 管理员不显示 */} {/* 隧道权限 - 管理员不显示 */}
{!isAdmin && ( {!isAdmin && (
@@ -1028,6 +661,7 @@ export default function DashboardPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
aria-hidden="true"
className="w-5 h-5 text-primary" className="w-5 h-5 text-primary"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1048,22 +682,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardBody className="pt-0"> <CardBody className="pt-0">
{userTunnels.length === 0 ? ( {userTunnels.length === 0 ? (
<div className="text-center py-12"> <PageEmptyState className="h-48" message="暂无隧道权限" />
<svg
className="w-12 h-12 text-default-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
<p className="text-default-500">暂无隧道权限</p>
</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{userTunnels.map((tunnel) => { {userTunnels.map((tunnel) => {
@@ -1164,6 +783,7 @@ export default function DashboardPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg
aria-hidden="true"
className="w-5 h-5 text-primary" className="w-5 h-5 text-primary"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1186,6 +806,7 @@ export default function DashboardPage() {
{groupedForwards().length === 0 ? ( {groupedForwards().length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<svg <svg
aria-hidden="true"
className="w-12 h-12 text-default-400 mx-auto mb-4" className="w-12 h-12 text-default-400 mx-auto mb-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1324,7 +945,7 @@ export default function DashboardPage() {
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader> <ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6"> <ModalBody className="pb-6">
<div className="mb-4 text-right"> <div className="mb-4 text-right">
<Button size="sm" onClick={copyAllAddresses}> <Button size="sm" onPress={copyAllAddresses}>
复制全部 复制全部
</Button> </Button>
</div> </div>
@@ -1342,7 +963,7 @@ export default function DashboardPage() {
isLoading={item.copying} isLoading={item.copying}
size="sm" size="sm"
variant="light" variant="light"
onClick={() => copyAddress(item)} onPress={() => copyAddress(item)}
> >
复制 复制
</Button> </Button>
@@ -0,0 +1,46 @@
import type { AnnouncementData } from "@/api";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
interface AnnouncementBannerProps {
announcement: AnnouncementData;
}
export const AnnouncementBanner = ({
announcement,
}: AnnouncementBannerProps) => {
if (!announcement.content) {
return null;
}
return (
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
<CardBody className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<svg
aria-hidden="true"
className="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
fillRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
公告
</h3>
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
{announcement.content}
</p>
</div>
</div>
</CardBody>
</Card>
);
};
@@ -0,0 +1,130 @@
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { PageEmptyState } from "@/components/page-state";
interface FlowChartPoint {
time: string;
flow: number;
}
interface FlowChartCardProps {
statisticsFlowsCount: number;
chartData: FlowChartPoint[];
formatFlow: (value: number, unit?: string) => string;
}
export const FlowChartCard = ({
statisticsFlowsCount,
chartData,
formatFlow,
}: FlowChartCardProps) => {
return (
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg
aria-hidden="true"
className="w-5 h-5 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
24小时流量统计
</h2>
</div>
</CardHeader>
<CardBody className="pt-0">
{statisticsFlowsCount === 0 ? (
<PageEmptyState className="h-48" message="暂无流量统计数据" />
) : (
<div className="h-64 lg:h-80 w-full">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={chartData}>
<CartesianGrid className="opacity-30" strokeDasharray="3 3" />
<XAxis
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
dataKey="time"
tick={{ fontSize: 12 }}
tickLine={false}
/>
<YAxis
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
tick={{ fontSize: 12 }}
tickFormatter={(value: number | string) => {
const v = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(v)) return String(value);
if (v === 0) return "0";
if (v < 1024) return `${v}B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
if (v < 1024 * 1024 * 1024)
return `${(v / (1024 * 1024)).toFixed(1)}M`;
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
}}
tickLine={false}
/>
<Tooltip
content={({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{ value?: number | string }>;
label?: string | number;
}) => {
if (active && payload && payload.length) {
const firstValue = payload[0]?.value;
const numericValue =
typeof firstValue === "number"
? firstValue
: Number(firstValue);
const flowValue = Number.isFinite(numericValue)
? numericValue
: 0;
return (
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
<p className="text-primary">{`流量: ${formatFlow(flowValue)}`}</p>
</div>
);
}
return null;
}}
/>
<Line
activeDot={{
r: 4,
stroke: "#8b5cf6",
strokeWidth: 2,
fill: "#fff",
}}
dataKey="flow"
dot={false}
stroke="#8b5cf6"
strokeWidth={3}
type="monotone"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardBody>
</Card>
);
};
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
interface MetricCardProps {
title: string;
value: string | number;
iconClassName: string;
icon: ReactNode;
bottomContent?: ReactNode;
}
export const MetricCard = ({
title,
value,
iconClassName,
icon,
bottomContent,
}: MetricCardProps) => {
return (
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">
{title}
</p>
<div
className={`p-1.5 lg:p-2 rounded-lg flex-shrink-0 ${iconClassName}`}
>
{icon}
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">
{value}
</p>
{bottomContent}
</div>
</CardBody>
</Card>
);
};
@@ -0,0 +1,249 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import {
getAnnouncement,
getUserPackageInfo,
type AnnouncementData,
} from "@/api";
import type { ForwardApiItem } from "@/api/types";
import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo {
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string | number;
flowResetTime?: number;
}
export interface DashboardUserTunnel {
id: number;
tunnelId: number;
tunnelName: string;
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: number;
flowResetTime?: number;
tunnelFlow?: number;
}
export interface DashboardForward {
id: number;
name: string;
tunnelId: number;
tunnelName: string;
inIp: string;
inPort: number;
remoteAddr: string;
inFlow: number;
outFlow: number;
}
export interface DashboardStatisticsFlow {
id: number;
userId: number;
flow: number;
totalFlow: number;
time: string;
}
interface DashboardDataState {
loading: boolean;
userInfo: DashboardUserInfo;
userTunnels: DashboardUserTunnel[];
forwardList: DashboardForward[];
statisticsFlows: DashboardStatisticsFlow[];
isAdmin: boolean;
announcement: AnnouncementData | null;
}
const checkExpirationNotifications = (
userInfo: DashboardUserInfo,
tunnels: DashboardUserTunnel[],
) => {
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
const lastNotified = localStorage.getItem("lastNotified");
if (lastNotified === notificationKey) {
return;
}
let hasNotification = false;
if (userInfo.expTime) {
const expDate = new Date(userInfo.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast("账户将于明天过期,请及时续费", {
icon: "⚠️",
duration: 6000,
style: { background: "#f59e0b", color: "#fff" },
});
} else {
toast(`账户将于${diffDays}天后过期,请及时续费`, {
icon: "⚠️",
duration: 6000,
style: { background: "#f59e0b", color: "#fff" },
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast("账户已过期,请立即续费", {
icon: "⚠️",
duration: 8000,
style: { background: "#ef4444", color: "#fff" },
});
}
}
}
tunnels.forEach((tunnel) => {
if (!tunnel.expTime) {
return;
}
const expDate = new Date(tunnel.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
icon: "⚠️",
duration: 5000,
style: { background: "#f59e0b", color: "#fff" },
});
} else {
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
icon: "⚠️",
duration: 5000,
style: { background: "#f59e0b", color: "#fff" },
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast(`隧道"${tunnel.tunnelName}"已过期`, {
icon: "⚠️",
duration: 6000,
style: { background: "#ef4444", color: "#fff" },
});
}
}
});
if (hasNotification) {
localStorage.setItem("lastNotified", notificationKey);
}
};
const normalizeForwards = (items: ForwardApiItem[]) => {
return (items || []).map((item) => ({
...item,
name: item.name || "",
tunnelId: item.tunnelId ?? 0,
tunnelName: item.tunnelName || "",
inIp: item.inIp || "",
inPort: item.inPort || 0,
remoteAddr: item.remoteAddr || "",
inFlow: item.inFlow || 0,
outFlow: item.outFlow || 0,
}));
};
const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
return (items || []).map((item) => ({
...item,
inFlow: item.inFlow ?? 0,
outFlow: item.outFlow ?? 0,
}));
};
export const useDashboardData = (): DashboardDataState => {
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
{} as DashboardUserInfo,
);
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]);
const [isAdmin, setIsAdmin] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
useEffect(() => {
const loadAnnouncement = async () => {
try {
const res = await getAnnouncement();
if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data);
}
} catch (error) {
console.error("Failed to load announcement:", error);
}
};
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 {
toast.error(res.msg || "获取套餐信息失败");
}
} catch {
toast.error("获取套餐信息失败");
} finally {
setLoading(false);
}
};
setLoading(true);
setUserInfo({} as DashboardUserInfo);
setUserTunnels([]);
setForwardList([]);
setStatisticsFlows([]);
setIsAdmin(getAdminFlag());
loadPackageData();
loadAnnouncement();
localStorage.setItem("e", "/dashboard");
}, []);
return {
loading,
userInfo,
userTunnels,
forwardList,
statisticsFlows,
isAdmin,
announcement,
};
};
+170 -422
View File
@@ -1,22 +1,22 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@heroui/input"; import { Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { import {
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Switch } from "@heroui/switch"; import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@heroui/alert"; import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Accordion, AccordionItem } from "@heroui/accordion"; import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Checkbox } from "@heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
DndContext, DndContext,
@@ -48,12 +48,29 @@ import {
resumeForwardService, resumeForwardService,
diagnoseForward, diagnoseForward,
updateForwardOrder, updateForwardOrder,
batchDeleteForwards,
batchPauseForwards,
batchResumeForwards,
batchRedeployForwards,
batchChangeTunnel,
} from "@/api"; } from "@/api";
import {
type ForwardAddressItem,
formatInAddress,
formatRemoteAddress,
hasMultipleAddresses,
resolveForwardAddressAction,
} from "@/pages/forward/address";
import {
buildForwardDiagnosisFallbackResult,
getForwardDiagnosisQualityDisplay,
type ForwardDiagnosisResult,
} from "@/pages/forward/diagnosis";
import {
executeForwardBatchChangeTunnel,
executeForwardBatchDelete,
executeForwardBatchRedeploy,
executeForwardBatchToggleService,
} from "@/pages/forward/batch-actions";
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
import { PageLoadingState } from "@/components/page-state";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
import { saveOrder } from "@/utils/order-storage";
import { JwtUtil } from "@/utils/jwt"; import { JwtUtil } from "@/utils/jwt";
interface Forward { interface Forward {
@@ -94,32 +111,6 @@ interface ForwardForm {
strategy: string; strategy: string;
} }
interface AddressItem {
id: number;
address: string;
copying: boolean;
}
interface DiagnosisResult {
forwardName: string;
timestamp: number;
results: Array<{
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
targetPort?: number;
message?: string;
averageTime?: number;
packetLoss?: number;
fromChainType?: number; // 1: 入口, 2: 链, 3: 出口
fromInx?: number;
toChainType?: number;
toInx?: number;
}>;
}
// 添加分组接口 // 添加分组接口
interface UserGroup { interface UserGroup {
userId: number | null; userId: number | null;
@@ -137,20 +128,7 @@ export default function ForwardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]); const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]); const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const isMobile = useMobileBreakpoint();
// 检测是否为移动端
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// 显示模式状态 - 从localStorage读取,默认为平铺显示 // 显示模式状态 - 从localStorage读取,默认为平铺显示
const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => { const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => {
@@ -179,9 +157,9 @@ export default function ForwardPage() {
const [currentDiagnosisForward, setCurrentDiagnosisForward] = const [currentDiagnosisForward, setCurrentDiagnosisForward] =
useState<Forward | null>(null); useState<Forward | null>(null);
const [diagnosisResult, setDiagnosisResult] = const [diagnosisResult, setDiagnosisResult] =
useState<DiagnosisResult | null>(null); useState<ForwardDiagnosisResult | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState(""); const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<AddressItem[]>([]); const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);
// 导出相关状态 // 导出相关状态
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
@@ -245,56 +223,16 @@ export default function ForwardPage() {
// 切换到直接显示模式时,初始化拖拽排序顺序 // 切换到直接显示模式时,初始化拖拽排序顺序
if (newMode === "direct") { if (newMode === "direct") {
// 在平铺模式下,只对当前用户的转发进行排序
const currentUserId = JwtUtil.getUserIdFromToken(); const currentUserId = JwtUtil.getUserIdFromToken();
let userForwards = forwards; const { order, fromDatabase } = buildForwardOrder(
forwards,
if (currentUserId !== null) { currentUserId,
userForwards = forwards.filter(
(f: Forward) => f.userId === currentUserId,
);
}
// 检查数据库中是否有排序信息
const hasDbOrdering = userForwards.some(
(f: Forward) => f.inx !== undefined && f.inx !== 0,
); );
if (hasDbOrdering) { setForwardOrder(order);
// 使用数据库中的排序信息
const dbOrder = userForwards
.sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0))
.map((f: Forward) => f.id);
setForwardOrder(dbOrder); if (fromDatabase) {
saveOrder(FORWARD_ORDER_KEY, order);
// 同步到localStorage
try {
localStorage.setItem("forward-order", JSON.stringify(dbOrder));
} catch {}
} else {
// 使用本地存储的顺序
const savedOrder = localStorage.getItem("forward-order");
if (savedOrder) {
try {
const orderIds = JSON.parse(savedOrder);
const validOrder = orderIds.filter((id: number) =>
userForwards.some((f: Forward) => f.id === id),
);
userForwards.forEach((forward: Forward) => {
if (!validOrder.includes(forward.id)) {
validOrder.push(forward.id);
}
});
setForwardOrder(validOrder);
} catch {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
} else {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
} }
} }
} catch {} } catch {}
@@ -320,58 +258,16 @@ export default function ForwardPage() {
// 初始化拖拽排序顺序 // 初始化拖拽排序顺序
if (viewMode === "direct") { if (viewMode === "direct") {
// 在平铺模式下,只对当前用户的转发进行排序
const currentUserId = JwtUtil.getUserIdFromToken(); const currentUserId = JwtUtil.getUserIdFromToken();
let userForwards = forwardsData; const { order, fromDatabase } = buildForwardOrder(
forwardsData,
if (currentUserId !== null) { currentUserId,
userForwards = forwardsData.filter(
(f: Forward) => f.userId === currentUserId,
);
}
// 检查数据库中是否有排序信息
const hasDbOrdering = userForwards.some(
(f: Forward) => f.inx !== undefined && f.inx !== 0,
); );
if (hasDbOrdering) { setForwardOrder(order);
// 使用数据库中的排序信息
const dbOrder = userForwards
.sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0))
.map((f: Forward) => f.id);
setForwardOrder(dbOrder); if (fromDatabase) {
saveOrder(FORWARD_ORDER_KEY, order);
// 同步到localStorage
try {
localStorage.setItem("forward-order", JSON.stringify(dbOrder));
} catch {}
} else {
// 使用本地存储的顺序
const savedOrder = localStorage.getItem("forward-order");
if (savedOrder) {
try {
const orderIds = JSON.parse(savedOrder);
// 验证保存的顺序是否仍然有效(只包含当前用户的转发)
const validOrder = orderIds.filter((id: number) =>
userForwards.some((f: Forward) => f.id === id),
);
// 添加新的转发ID(如果存在)
userForwards.forEach((forward: Forward) => {
if (!validOrder.includes(forward.id)) {
validOrder.push(forward.id);
}
});
setForwardOrder(validOrder);
} catch {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
} else {
setForwardOrder(userForwards.map((f: Forward) => f.id));
}
} }
} }
} else { } else {
@@ -587,7 +483,7 @@ export default function ForwardPage() {
const addressCount = processedRemoteAddr.split(",").length; const addressCount = processedRemoteAddr.split(",").length;
let res; let res: { code: number; msg: string };
if (isEdit) { if (isEdit) {
// 更新时确保包含必要字段 // 更新时确保包含必要字段
@@ -647,7 +543,7 @@ export default function ForwardPage() {
), ),
); );
let res; let res: { code: number; msg: string };
if (targetState) { if (targetState) {
res = await resumeForwardService(forward.id); res = await resumeForwardService(forward.id);
@@ -694,63 +590,33 @@ export default function ForwardPage() {
const response = await diagnoseForward(forward.id); const response = await diagnoseForward(forward.id);
if (response.code === 0) { if (response.code === 0) {
setDiagnosisResult(response.data); setDiagnosisResult(response.data as ForwardDiagnosisResult);
} else { } else {
toast.error(response.msg || "诊断失败"); toast.error(response.msg || "诊断失败");
setDiagnosisResult({ setDiagnosisResult(
forwardName: forward.name, buildForwardDiagnosisFallbackResult({
timestamp: Date.now(), forwardName: forward.name,
results: [ remoteAddr: forward.remoteAddr,
{ description: "诊断失败",
success: false, message: response.msg || "诊断过程中发生错误",
description: "诊断失败", }),
nodeName: "-", );
nodeId: "-",
targetIp: forward.remoteAddr.split(",")[0] || "-",
message: response.msg || "诊断过程中发生错误",
},
],
});
} }
} catch { } catch {
toast.error("网络错误,请重试"); toast.error("网络错误,请重试");
setDiagnosisResult({ setDiagnosisResult(
forwardName: forward.name, buildForwardDiagnosisFallbackResult({
timestamp: Date.now(), forwardName: forward.name,
results: [ remoteAddr: forward.remoteAddr,
{ description: "网络错误",
success: false, message: "无法连接到服务器",
description: "网络错误", }),
nodeName: "-", );
nodeId: "-",
targetIp: forward.remoteAddr.split(",")[0] || "-",
message: "无法连接到服务器",
},
],
});
} finally { } finally {
setDiagnosisLoading(false); setDiagnosisLoading(false);
} }
}; };
// 获取连接质量
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
if (averageTime === undefined || packetLoss === undefined) return null;
if (averageTime < 30 && packetLoss === 0)
return { text: "🚀 优秀", color: "success" };
if (averageTime < 50 && packetLoss === 0)
return { text: "✨ 很好", color: "success" };
if (averageTime < 100 && packetLoss < 1)
return { text: "👍 良好", color: "primary" };
if (averageTime < 150 && packetLoss < 2)
return { text: "😐 一般", color: "warning" };
if (averageTime < 200 && packetLoss < 5)
return { text: "😟 较差", color: "warning" };
return { text: "😵 很差", color: "danger" };
};
// 格式化流量 // 格式化流量
const formatFlow = (value: number): string => { const formatFlow = (value: number): string => {
if (value === 0) return "0 B"; if (value === 0) return "0 B";
@@ -762,141 +628,26 @@ export default function ForwardPage() {
return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB"; return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}; };
// 格式化入口地址
const formatInAddress = (ipString: string, port: number): string => {
if (!ipString) return "";
const items = ipString
.split(",")
.map((item) => item.trim())
.filter((item) => item);
if (items.length === 0) return "";
// 检查第一项是否已经包含端口(格式:IP:端口)
const firstItem = items[0];
const hasPort = /:\d+$/.test(firstItem);
if (hasPort) {
// inIp 已经包含完整的 IP:Port 组合
if (items.length === 1) {
return items[0];
}
return `${items[0]} (+${items.length - 1}个)`;
}
// inIp 只包含IP,需要添加端口(兼容旧数据)
if (!port) return "";
if (items.length === 1) {
const ip = items[0];
if (ip.includes(":") && !ip.startsWith("[")) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
const firstIp = items[0];
let formattedFirstIp;
if (firstIp.includes(":") && !firstIp.startsWith("[")) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
return `${formattedFirstIp}:${port} (+${items.length - 1}个)`;
};
// 格式化远程地址
const formatRemoteAddress = (addressString: string): string => {
if (!addressString) return "";
const addresses = addressString
.split(",")
.map((addr) => addr.trim())
.filter((addr) => addr);
if (addresses.length === 0) return "";
if (addresses.length === 1) return addresses[0];
return `${addresses[0]} (+${addresses.length - 1})`;
};
// 检查是否有多个地址
const hasMultipleAddresses = (addressString: string): boolean => {
if (!addressString) return false;
const addresses = addressString
.split(",")
.map((addr) => addr.trim())
.filter((addr) => addr);
return addresses.length > 1;
};
// 显示地址列表弹窗 // 显示地址列表弹窗
const showAddressModal = ( const showAddressModal = (
addressString: string, addressString: string,
port: number | null, port: number | null,
title: string, title: string,
) => { ) => {
if (!addressString) return; const action = resolveForwardAddressAction(addressString, port, title);
let addresses: string[]; if (action.type === "none") {
return;
if (port !== null) {
// 入口地址处理
const items = addressString
.split(",")
.map((item) => item.trim())
.filter((item) => item);
if (items.length <= 1) {
copyToClipboard(formatInAddress(addressString, port), title);
return;
}
// 检查是否已经包含端口
const hasPort = /:\d+$/.test(items[0]);
if (hasPort) {
// 已经包含完整的 IP:Port 组合,直接使用
addresses = items;
} else {
// 只包含IP,需要添加端口
addresses = items.map((ip) => {
if (ip.includes(":") && !ip.startsWith("[")) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
});
}
} else {
// 远程地址处理
addresses = addressString
.split(",")
.map((addr) => addr.trim())
.filter((addr) => addr);
if (addresses.length <= 1) {
copyToClipboard(addressString, title);
return;
}
} }
setAddressList( if (action.type === "copy") {
addresses.map((address, index) => ({ copyToClipboard(action.text, action.label);
id: index,
address, return;
copying: false, }
})),
); setAddressList(action.items);
setAddressModalTitle(`${title} (${addresses.length}个)`); setAddressModalTitle(action.title);
setAddressModalOpen(true); setAddressModalOpen(true);
}; };
@@ -911,7 +662,7 @@ export default function ForwardPage() {
}; };
// 复制地址 // 复制地址
const copyAddress = async (addressItem: AddressItem) => { const copyAddress = async (addressItem: ForwardAddressItem) => {
try { try {
setAddressList((prev) => setAddressList((prev) =>
prev.map((item) => prev.map((item) =>
@@ -1220,10 +971,7 @@ export default function ForwardPage() {
setForwardOrder(newOrder); setForwardOrder(newOrder);
// 保存到localStorage saveOrder(FORWARD_ORDER_KEY, newOrder);
try {
localStorage.setItem("forward-order", JSON.stringify(newOrder));
} catch {}
// 持久化到数据库 // 持久化到数据库
try { try {
@@ -1292,27 +1040,22 @@ export default function ForwardPage() {
if (selectedIds.size === 0) return; if (selectedIds.size === 0) return;
setBatchLoading(true); setBatchLoading(true);
try { try {
const res = await batchDeleteForwards(Array.from(selectedIds)); const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
if (res.code === 0) { if (outcome.toastVariant === "success") {
const result = res.data; toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
if (result.failCount === 0) { if (outcome.shouldRefresh) {
toast.success(`成功删除 ${result.successCount} 项`);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
}
setSelectedIds(new Set()); setSelectedIds(new Set());
setSelectMode(false); setSelectMode(false);
setBatchDeleteModalOpen(false); if (outcome.closeDeleteModal) {
setBatchDeleteModalOpen(false);
}
loadData(false); loadData(false);
} else {
toast.error(res.msg || "删除失败");
} }
} catch (e: any) {
toast.error(e.message || "删除失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1322,33 +1065,22 @@ export default function ForwardPage() {
if (selectedIds.size === 0) return; if (selectedIds.size === 0) return;
setBatchLoading(true); setBatchLoading(true);
try { try {
const ids = Array.from(selectedIds); const outcome = await executeForwardBatchToggleService(
const res = enable Array.from(selectedIds),
? await batchResumeForwards(ids) enable,
: await batchPauseForwards(ids); );
if (res.code === 0) { if (outcome.toastVariant === "success") {
const result = res.data; toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
if (result.failCount === 0) { if (outcome.shouldRefresh) {
toast.success(
enable
? `成功启用 ${result.successCount} 项`
: `成功停用 ${result.successCount} 项`,
);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
}
setSelectedIds(new Set()); setSelectedIds(new Set());
setSelectMode(false); setSelectMode(false);
loadData(false); loadData(false);
} else {
toast.error(res.msg || (enable ? "启用失败" : "停用失败"));
} }
} catch (e: any) {
toast.error(e.message || (enable ? "启用失败" : "停用失败"));
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1358,26 +1090,21 @@ export default function ForwardPage() {
if (selectedIds.size === 0) return; if (selectedIds.size === 0) return;
setBatchLoading(true); setBatchLoading(true);
try { try {
const res = await batchRedeployForwards(Array.from(selectedIds)); const outcome = await executeForwardBatchRedeploy(
Array.from(selectedIds),
);
if (res.code === 0) { if (outcome.toastVariant === "success") {
const result = res.data; toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
if (result.failCount === 0) { if (outcome.shouldRefresh) {
toast.success(`成功重新下发 ${result.successCount} 项`);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
}
setSelectedIds(new Set()); setSelectedIds(new Set());
setSelectMode(false); setSelectMode(false);
loadData(false); loadData(false);
} else {
toast.error(res.msg || "下发失败");
} }
} catch (e: any) {
toast.error(e.message || "下发失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1387,31 +1114,28 @@ export default function ForwardPage() {
if (selectedIds.size === 0 || !batchTargetTunnelId) return; if (selectedIds.size === 0 || !batchTargetTunnelId) return;
setBatchLoading(true); setBatchLoading(true);
try { try {
const res = await batchChangeTunnel({ const outcome = await executeForwardBatchChangeTunnel(
forwardIds: Array.from(selectedIds), Array.from(selectedIds),
targetTunnelId: batchTargetTunnelId, batchTargetTunnelId,
}); );
if (res.code === 0) { if (outcome.toastVariant === "success") {
const result = res.data; toast.success(outcome.toastMessage);
} else {
toast.error(outcome.toastMessage);
}
if (result.failCount === 0) { if (outcome.shouldRefresh) {
toast.success(`成功换隧道 ${result.successCount} 项`);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
}
setSelectedIds(new Set()); setSelectedIds(new Set());
setSelectMode(false); setSelectMode(false);
setBatchChangeTunnelModalOpen(false); if (outcome.closeChangeTunnelModal) {
setBatchTargetTunnelId(null); setBatchChangeTunnelModalOpen(false);
}
if (outcome.resetTargetTunnel) {
setBatchTargetTunnelId(null);
}
loadData(false); loadData(false);
} else {
toast.error(res.msg || "隧道失败");
} }
} catch (e: any) {
toast.error(e.message || "隧道失败");
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -1568,6 +1292,7 @@ export default function ForwardPage() {
title={isMobile ? "长按拖拽排序" : "拖拽排序"} title={isMobile ? "长按拖拽排序" : "拖拽排序"}
> >
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1621,6 +1346,7 @@ export default function ForwardPage() {
</div> </div>
{hasMultipleAddresses(forward.inIp) && ( {hasMultipleAddresses(forward.inIp) && (
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-default-400 flex-shrink-0" className="w-3 h-3 text-default-400 flex-shrink-0"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1660,6 +1386,7 @@ export default function ForwardPage() {
</div> </div>
{hasMultipleAddresses(forward.remoteAddr) && ( {hasMultipleAddresses(forward.remoteAddr) && (
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-default-400 flex-shrink-0" className="w-3 h-3 text-default-400 flex-shrink-0"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1715,6 +1442,7 @@ export default function ForwardPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1733,6 +1461,7 @@ export default function ForwardPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1755,6 +1484,7 @@ export default function ForwardPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1783,14 +1513,7 @@ export default function ForwardPage() {
}; };
if (loading) { if (loading) {
return ( return <PageLoadingState message="正在加载..." />;
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
);
} }
const userGroups = groupForwardsByUserAndTunnel(); const userGroups = groupForwardsByUserAndTunnel();
@@ -1804,6 +1527,9 @@ export default function ForwardPage() {
{/* 显示模式切换按钮 */} {/* 显示模式切换按钮 */}
<Button <Button
isIconOnly isIconOnly
aria-label={
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
}
className="text-sm" className="text-sm"
color="default" color="default"
size="sm" size="sm"
@@ -1812,7 +1538,12 @@ export default function ForwardPage() {
onPress={handleViewModeChange} onPress={handleViewModeChange}
> >
{viewMode === "grouped" ? ( {viewMode === "grouped" ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path <path
clipRule="evenodd" clipRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
@@ -1820,7 +1551,12 @@ export default function ForwardPage() {
/> />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" /> <path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg> </svg>
)} )}
@@ -1936,6 +1672,7 @@ export default function ForwardPage() {
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0">
<svg <svg
aria-hidden="true"
className="w-5 h-5 text-primary" className="w-5 h-5 text-primary"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1984,6 +1721,7 @@ export default function ForwardPage() {
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<svg <svg
aria-hidden="true"
className="w-4 h-4 text-success" className="w-4 h-4 text-success"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -2039,6 +1777,7 @@ export default function ForwardPage() {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg <svg
aria-hidden="true"
className="w-8 h-8 text-default-400" className="w-8 h-8 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -2092,6 +1831,7 @@ export default function ForwardPage() {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg <svg
aria-hidden="true"
className="w-8 h-8 text-default-400" className="w-8 h-8 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -2309,7 +2049,7 @@ export default function ForwardPage() {
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader> <ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6"> <ModalBody className="pb-6">
<div className="mb-4 text-right"> <div className="mb-4 text-right">
<Button size="sm" onClick={copyAllAddresses}> <Button size="sm" onPress={copyAllAddresses}>
复制 复制
</Button> </Button>
</div> </div>
@@ -2327,7 +2067,7 @@ export default function ForwardPage() {
isLoading={item.copying} isLoading={item.copying}
size="sm" size="sm"
variant="light" variant="light"
onClick={() => copyAddress(item)} onPress={() => copyAddress(item)}
> >
复制 复制
</Button> </Button>
@@ -2401,6 +2141,7 @@ export default function ForwardPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -2421,6 +2162,7 @@ export default function ForwardPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -2446,6 +2188,7 @@ export default function ForwardPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -2591,6 +2334,7 @@ export default function ForwardPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{result.success ? ( {result.success ? (
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-success-600 flex-shrink-0" className="w-3 h-3 text-success-600 flex-shrink-0"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -2603,6 +2347,7 @@ export default function ForwardPage() {
</svg> </svg>
) : ( ) : (
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-danger-600 flex-shrink-0" className="w-3 h-3 text-danger-600 flex-shrink-0"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -2811,10 +2556,11 @@ export default function ForwardPage() {
</thead> </thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800"> <tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => { {results.map((result, index) => {
const quality = getQualityDisplay( const quality =
result.averageTime, getForwardDiagnosisQualityDisplay(
result.packetLoss, result.averageTime,
); result.packetLoss,
);
return ( return (
<tr <tr
@@ -2984,10 +2730,11 @@ export default function ForwardPage() {
</h3> </h3>
</div> </div>
{results.map((result, index) => { {results.map((result, index) => {
const quality = getQualityDisplay( const quality =
result.averageTime, getForwardDiagnosisQualityDisplay(
result.packetLoss, result.averageTime,
); result.packetLoss,
);
return ( return (
<div <div
@@ -3140,6 +2887,7 @@ export default function ForwardPage() {
<div className="text-center py-16"> <div className="text-center py-16">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg <svg
aria-hidden="true"
className="w-8 h-8 text-default-400" className="w-8 h-8 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
+133
View File
@@ -0,0 +1,133 @@
export interface ForwardAddressItem {
id: number;
address: string;
copying: boolean;
}
export type ForwardAddressAction =
| { type: "none" }
| { type: "copy"; text: string; label: string }
| { type: "modal"; title: string; items: ForwardAddressItem[] };
const splitAddressEntries = (value: string): string[] => {
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item);
};
const formatAddressWithPort = (ip: string, port: number): string => {
if (ip.includes(":") && !ip.startsWith("[")) {
return `[${ip}]:${port}`;
}
return `${ip}:${port}`;
};
export const formatInAddress = (ipString: string, port: number): string => {
if (!ipString) {
return "";
}
const items = splitAddressEntries(ipString);
if (items.length === 0) {
return "";
}
const hasPort = /:\d+$/.test(items[0]);
if (hasPort) {
if (items.length === 1) {
return items[0];
}
return `${items[0]} (+${items.length - 1}个)`;
}
if (!port) {
return "";
}
if (items.length === 1) {
return formatAddressWithPort(items[0], port);
}
return `${formatAddressWithPort(items[0], port)} (+${items.length - 1}个)`;
};
export const formatRemoteAddress = (addressString: string): string => {
if (!addressString) {
return "";
}
const addresses = splitAddressEntries(addressString);
if (addresses.length === 0) {
return "";
}
if (addresses.length === 1) {
return addresses[0];
}
return `${addresses[0]} (+${addresses.length - 1})`;
};
export const hasMultipleAddresses = (addressString: string): boolean => {
if (!addressString) {
return false;
}
return splitAddressEntries(addressString).length > 1;
};
export const resolveForwardAddressAction = (
addressString: string,
port: number | null,
title: string,
): ForwardAddressAction => {
if (!addressString) {
return { type: "none" };
}
let addresses: string[];
if (port !== null) {
const items = splitAddressEntries(addressString);
if (items.length <= 1) {
return {
type: "copy",
text: formatInAddress(addressString, port),
label: title,
};
}
const hasPort = /:\d+$/.test(items[0]);
addresses = hasPort
? items
: items.map((ip) => formatAddressWithPort(ip, port));
} else {
addresses = splitAddressEntries(addressString);
if (addresses.length <= 1) {
return {
type: "copy",
text: addressString,
label: title,
};
}
}
return {
type: "modal",
title: `${title} (${addresses.length}个)`,
items: addresses.map((address, index) => ({
id: index,
address,
copying: false,
})),
};
};
@@ -0,0 +1,178 @@
import type { BatchOperationResult } from "@/api/types";
import {
batchChangeTunnel,
batchDeleteForwards,
batchPauseForwards,
batchRedeployForwards,
batchResumeForwards,
} from "@/api";
import { extractApiErrorMessage } from "@/api/error-message";
export interface ForwardBatchActionOutcome {
toastVariant: "success" | "error";
toastMessage: string;
shouldRefresh: boolean;
closeDeleteModal?: boolean;
closeChangeTunnelModal?: boolean;
resetTargetTunnel?: boolean;
}
const normalizeBatchResult = (value: unknown): BatchOperationResult => {
const raw = (value ?? {}) as Partial<BatchOperationResult>;
return {
successCount: Number(raw.successCount ?? 0),
failCount: Number(raw.failCount ?? 0),
};
};
const buildBatchToast = (
result: BatchOperationResult,
successText: string,
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
if (result.failCount === 0) {
return {
toastVariant: "success",
toastMessage: successText,
};
}
return {
toastVariant: "error",
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
};
};
export const executeForwardBatchDelete = async (
ids: number[],
): Promise<ForwardBatchActionOutcome> => {
try {
const response = await batchDeleteForwards(ids);
if (response.code !== 0) {
return {
toastVariant: "error",
toastMessage: response.msg || "删除失败",
shouldRefresh: false,
};
}
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
shouldRefresh: true,
closeDeleteModal: true,
};
} catch (error) {
return {
toastVariant: "error",
toastMessage: extractApiErrorMessage(error, "删除失败"),
shouldRefresh: false,
};
}
};
export const executeForwardBatchToggleService = async (
ids: number[],
enable: boolean,
): Promise<ForwardBatchActionOutcome> => {
const fallback = enable ? "启用失败" : "停用失败";
try {
const response = enable
? await batchResumeForwards(ids)
: await batchPauseForwards(ids);
if (response.code !== 0) {
return {
toastVariant: "error",
toastMessage: response.msg || fallback,
shouldRefresh: false,
};
}
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(
summary,
enable
? `成功启用 ${summary.successCount} 项`
: `成功停用 ${summary.successCount} 项`,
),
shouldRefresh: true,
};
} catch (error) {
return {
toastVariant: "error",
toastMessage: extractApiErrorMessage(error, fallback),
shouldRefresh: false,
};
}
};
export const executeForwardBatchRedeploy = async (
ids: number[],
): Promise<ForwardBatchActionOutcome> => {
try {
const response = await batchRedeployForwards(ids);
if (response.code !== 0) {
return {
toastVariant: "error",
toastMessage: response.msg || "下发失败",
shouldRefresh: false,
};
}
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
shouldRefresh: true,
};
} catch (error) {
return {
toastVariant: "error",
toastMessage: extractApiErrorMessage(error, "下发失败"),
shouldRefresh: false,
};
}
};
export const executeForwardBatchChangeTunnel = async (
ids: number[],
targetTunnelId: number,
): Promise<ForwardBatchActionOutcome> => {
try {
const response = await batchChangeTunnel({
forwardIds: ids,
targetTunnelId,
});
if (response.code !== 0) {
return {
toastVariant: "error",
toastMessage: response.msg || "隧道失败",
shouldRefresh: false,
};
}
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
shouldRefresh: true,
closeChangeTunnelModal: true,
resetTargetTunnel: true,
};
} catch (error) {
return {
toastVariant: "error",
toastMessage: extractApiErrorMessage(error, "隧道失败"),
shouldRefresh: false,
};
}
};
@@ -0,0 +1,88 @@
export interface ForwardDiagnosisEntry {
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
targetPort?: number;
message?: string;
averageTime?: number;
packetLoss?: number;
fromChainType?: number;
fromInx?: number;
toChainType?: number;
toInx?: number;
}
export interface ForwardDiagnosisResult {
forwardName: string;
timestamp: number;
results: ForwardDiagnosisEntry[];
}
export interface ForwardDiagnosisFallbackInput {
forwardName: string;
remoteAddr: string;
description: string;
message: string;
}
const pickPrimaryTargetIp = (remoteAddr: string): string => {
return remoteAddr.split(",")[0] || "-";
};
export const buildForwardDiagnosisFallbackResult = ({
forwardName,
remoteAddr,
description,
message,
}: ForwardDiagnosisFallbackInput): ForwardDiagnosisResult => {
return {
forwardName,
timestamp: Date.now(),
results: [
{
success: false,
description,
nodeName: "-",
nodeId: "-",
targetIp: pickPrimaryTargetIp(remoteAddr),
message,
},
],
};
};
export const getForwardDiagnosisQualityDisplay = (
averageTime?: number,
packetLoss?: number,
): {
text: string;
color: "success" | "primary" | "warning" | "danger";
} | null => {
if (averageTime === undefined || packetLoss === undefined) {
return null;
}
if (averageTime < 30 && packetLoss === 0) {
return { text: "🚀 优秀", color: "success" };
}
if (averageTime < 50 && packetLoss === 0) {
return { text: "✨ 很好", color: "success" };
}
if (averageTime < 100 && packetLoss < 1) {
return { text: "👍 良好", color: "primary" };
}
if (averageTime < 150 && packetLoss < 2) {
return { text: "😐 一般", color: "warning" };
}
if (averageTime < 200 && packetLoss < 5) {
return { text: "😟 较差", color: "warning" };
}
return { text: "😵 很差", color: "danger" };
};
+47
View File
@@ -0,0 +1,47 @@
import { loadStoredOrder } from "@/utils/order-storage";
export interface ForwardOrderItem {
id: number;
userId?: number;
inx?: number;
}
export const FORWARD_ORDER_KEY = "forward-order";
export const getUserScopedForwards = <T extends ForwardOrderItem>(
forwards: T[],
currentUserId: number | null,
): T[] => {
if (currentUserId === null) {
return forwards;
}
return forwards.filter((item) => item.userId === currentUserId);
};
export const buildForwardOrder = <T extends ForwardOrderItem>(
forwards: T[],
currentUserId: number | null,
): { order: number[]; fromDatabase: boolean } => {
const userForwards = getUserScopedForwards(forwards, currentUserId);
const hasDbOrdering = userForwards.some(
(item) => item.inx !== undefined && item.inx !== 0,
);
if (hasDbOrdering) {
const dbOrder = [...userForwards]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((item) => item.id);
return { order: dbOrder, fromDatabase: true };
}
return {
order: loadStoredOrder(
FORWARD_ORDER_KEY,
userForwards.map((item) => item.id),
),
fromDatabase: false,
};
};
+21 -27
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { import {
Modal, Modal,
ModalBody, ModalBody,
@@ -9,8 +9,8 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
useDisclosure, useDisclosure,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -18,9 +18,9 @@ import {
TableColumn, TableColumn,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@heroui/table"; } from "@/shadcn-bridge/heroui/table";
import { Chip } from "@heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
@@ -40,6 +40,7 @@ import {
updateTunnelGroup, updateTunnelGroup,
updateUserGroup, updateUserGroup,
} from "@/api"; } from "@/api";
import { getAdminFlag } from "@/utils/session";
interface TunnelItem { interface TunnelItem {
id: number; id: number;
@@ -86,22 +87,9 @@ const formatDate = (timestamp?: number): string => {
return new Date(timestamp).toLocaleString(); return new Date(timestamp).toLocaleString();
}; };
const isAdminUser = () => {
let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0;
localStorage.setItem("admin", adminFlag.toString());
}
return adminFlag;
};
export default function GroupPage() { export default function GroupPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin] = useState(isAdminUser()); const [isAdmin] = useState(getAdminFlag());
const [tunnelGroups, setTunnelGroups] = useState<TunnelGroup[]>([]); const [tunnelGroups, setTunnelGroups] = useState<TunnelGroup[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]); const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
@@ -198,19 +186,25 @@ export default function GroupPage() {
]); ]);
if (tunnelGroupRes.code === 0) { if (tunnelGroupRes.code === 0) {
setTunnelGroups(tunnelGroupRes.data || []); setTunnelGroups(
Array.isArray(tunnelGroupRes.data) ? tunnelGroupRes.data : [],
);
} }
if (userGroupRes.code === 0) { if (userGroupRes.code === 0) {
setUserGroups(userGroupRes.data || []); setUserGroups(
Array.isArray(userGroupRes.data) ? userGroupRes.data : [],
);
} }
if (permissionRes.code === 0) { if (permissionRes.code === 0) {
setPermissions(permissionRes.data || []); setPermissions(
Array.isArray(permissionRes.data) ? permissionRes.data : [],
);
} }
if (tunnelRes.code === 0) { if (tunnelRes.code === 0) {
setTunnels(tunnelRes.data || []); setTunnels(Array.isArray(tunnelRes.data) ? tunnelRes.data : []);
} }
if (userRes.code === 0) { if (userRes.code === 0) {
setUsers(userRes.data || []); setUsers(Array.isArray(userRes.data) ? userRes.data : []);
} }
if ( if (
+12 -23
View File
@@ -1,16 +1,17 @@
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { useState, useEffect } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile"; import { Turnstile } from "@marsidev/react-turnstile";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives"; import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default"; import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api"; import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
import { useWebViewMode } from "@/hooks/useWebViewMode";
interface LoginForm { interface LoginForm {
username: string; username: string;
@@ -29,12 +30,7 @@ export default function IndexPage() {
const [showCaptcha, setShowCaptcha] = useState(false); const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState(""); const [siteKey, setSiteKey] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const [isWebView, setIsWebView] = useState(false); const isWebView = useWebViewMode();
// 检测是否在WebView中运行
useEffect(() => {
setIsWebView(isWebViewFunc());
}, []);
// 验证表单 // 验证表单
const validateForm = (): boolean => { const validateForm = (): boolean => {
@@ -91,10 +87,7 @@ export default function IndexPage() {
// 检查是否需要强制修改密码 // 检查是否需要强制修改密码
if (response.data.requirePasswordChange) { if (response.data.requirePasswordChange) {
localStorage.setItem("token", response.data.token); writeLoginSession(response.data);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
localStorage.setItem("admin", (response.data.role_id === 0).toString());
toast.success("检测到默认密码,即将跳转到修改密码页面"); toast.success("检测到默认密码,即将跳转到修改密码页面");
navigate("/change-password"); navigate("/change-password");
@@ -102,10 +95,7 @@ export default function IndexPage() {
} }
// 保存登录信息 // 保存登录信息
localStorage.setItem("token", response.data.token); writeLoginSession(response.data);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
localStorage.setItem("admin", (response.data.role_id === 0).toString());
// 登录成功 // 登录成功
toast.success("登录成功"); toast.success("登录成功");
@@ -206,7 +196,7 @@ export default function IndexPage() {
disabled={loading} disabled={loading}
isLoading={loading} isLoading={loading}
size="lg" size="lg"
onClick={handleLogin} onPress={handleLogin}
> >
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"} {loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
</Button> </Button>
@@ -238,10 +228,9 @@ export default function IndexPage() {
{showCaptcha && siteKey && ( {showCaptcha && siteKey && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */} {/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
<div <button
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
role="button" type="button"
tabIndex={0}
onClick={() => { onClick={() => {
setShowCaptcha(false); setShowCaptcha(false);
setLoading(false); setLoading(false);
+12 -16
View File
@@ -1,17 +1,16 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { import {
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@heroui/spinner";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
@@ -21,6 +20,7 @@ import {
deleteSpeedLimit, deleteSpeedLimit,
getTunnelList, getTunnelList,
} from "@/api"; } from "@/api";
import { PageLoadingState } from "@/components/page-state";
interface SpeedLimitRule { interface SpeedLimitRule {
id: number; id: number;
@@ -188,7 +188,7 @@ export default function LimitPage() {
setSubmitLoading(true); setSubmitLoading(true);
try { try {
let res; let res: { code: number; msg: string };
if (isEdit) { if (isEdit) {
res = await updateSpeedLimit(form); res = await updateSpeedLimit(form);
@@ -215,14 +215,7 @@ export default function LimitPage() {
}; };
if (loading) { if (loading) {
return ( return <PageLoadingState message="正在加载..." />;
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
);
} }
return ( return (
@@ -293,6 +286,7 @@ export default function LimitPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -311,6 +305,7 @@ export default function LimitPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -344,6 +339,7 @@ export default function LimitPage() {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg <svg
aria-hidden="true"
className="w-8 h-8 text-default-400" className="w-8 h-8 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
+96 -306
View File
@@ -1,25 +1,24 @@
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@heroui/input"; import { Textarea } from "@/shadcn-bridge/heroui/input";
import { import {
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Switch } from "@heroui/switch"; import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Alert } from "@heroui/alert"; import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@heroui/progress"; import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@heroui/accordion"; import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import axios from "axios";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -51,6 +50,16 @@ import {
getNodeReleases, getNodeReleases,
rollbackNode, rollbackNode,
} from "@/api"; } from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import {
getConnectionStatusMeta,
getRemoteSyncErrorMessage,
} from "@/pages/node/display";
import { tryCopyInstallCommand } from "@/pages/node/install-command";
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 { loadStoredOrder, saveOrder } from "@/utils/order-storage";
interface Node { interface Node {
id: number; id: number;
@@ -140,8 +149,6 @@ export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]); const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]); const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState(""); const [dialogTitle, setDialogTitle] = useState("");
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
@@ -200,53 +207,26 @@ export default function NodePage() {
Record<number, { stage: string; percent: number; message: string }> Record<number, { stage: string; percent: number; message: string }>
>({}); >({});
const websocketRef = useRef<WebSocket | null>(null); const handleNodeOffline = useCallback((nodeId: number) => {
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null); setNodeList((prev) =>
const reconnectAttemptsRef = useRef(0); prev.map((node) => {
const maxReconnectAttempts = 5; if (node.id !== nodeId) return node;
const offlineTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>( if (node.connectionStatus === "offline" && node.systemInfo === null) {
new Map(), return node;
); }
const offlineDelayMs = 3000;
const clearOfflineTimer = (nodeId: number) => { return { ...node, connectionStatus: "offline", systemInfo: null };
const timer = offlineTimersRef.current.get(nodeId); }),
);
if (timer) {
clearTimeout(timer);
offlineTimersRef.current.delete(nodeId);
}
};
const scheduleNodeOffline = (nodeId: number) => {
if (offlineTimersRef.current.has(nodeId)) return;
const timer = setTimeout(() => {
offlineTimersRef.current.delete(nodeId);
setNodeList((prev) =>
prev.map((node) => {
if (node.id !== nodeId) return node;
if (node.connectionStatus === "offline" && node.systemInfo === null)
return node;
return { ...node, connectionStatus: "offline", systemInfo: null };
}),
);
}, offlineDelayMs);
offlineTimersRef.current.set(nodeId, timer);
};
useEffect(() => {
loadNodes();
initWebSocket();
return () => {
closeWebSocket();
};
}, []); }, []);
const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({
delayMs: 3000,
onNodeOffline: handleNodeOffline,
});
// 加载节点列表 // 加载节点列表
const loadNodes = async () => { const loadNodes = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getNodeList(); const res = await getNodeList();
@@ -279,32 +259,12 @@ export default function NodePage() {
setNodeOrder(dbOrder); setNodeOrder(dbOrder);
} else { } else {
try { setNodeOrder(
const stored = localStorage.getItem("node-order"); loadStoredOrder(
"node-order",
if (stored) { nodesData.map((n) => n.id),
const parsed = JSON.parse(stored); ),
);
if (Array.isArray(parsed)) {
const existingIds = new Set(nodesData.map((n) => n.id));
const validOrder = parsed
.map((id: any) => Number(id))
.filter((id: number) => existingIds.has(id));
if (validOrder.length > 0) {
setNodeOrder(validOrder);
} else {
setNodeOrder(nodesData.map((n) => n.id));
}
} else {
setNodeOrder(nodesData.map((n) => n.id));
}
} else {
setNodeOrder(nodesData.map((n) => n.id));
}
} catch {
setNodeOrder(nodesData.map((n) => n.id));
}
} }
} else { } else {
toast.error(res.msg || "加载节点列表失败"); toast.error(res.msg || "加载节点列表失败");
@@ -314,68 +274,7 @@ export default function NodePage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
// 初始化WebSocket连接
const initWebSocket = () => {
if (
websocketRef.current &&
(websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING)
) {
return;
}
if (websocketRef.current) {
closeWebSocket();
}
// 构建WebSocket URL,使用axios的baseURL
const baseUrl =
axios.defaults.baseURL ||
(import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/");
const wsUrl =
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
`/system-info?type=0&secret=${localStorage.getItem("token")}`;
try {
setWsConnecting(true);
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.onopen = () => {
reconnectAttemptsRef.current = 0;
setWsConnected(true);
setWsConnecting(false);
};
websocketRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch {
// 解析失败时不输出错误信息
}
};
websocketRef.current.onerror = () => {
// WebSocket错误时不输出错误信息
};
websocketRef.current.onclose = () => {
websocketRef.current = null;
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
};
} catch {
setWsConnected(false);
setWsConnecting(false);
attemptReconnect();
}
};
// 处理WebSocket消息 // 处理WebSocket消息
const handleWebSocketMessage = (data: any) => { const handleWebSocketMessage = (data: any) => {
@@ -404,61 +303,20 @@ export default function NodePage() {
setNodeList((prev) => setNodeList((prev) =>
prev.map((node) => { prev.map((node) => {
if (node.id === nodeId) { if (node.id === nodeId) {
try { const systemInfo = buildNodeSystemInfo(
let systemInfo; messageData,
node.systemInfo,
);
if (typeof messageData === "string") { if (!systemInfo) {
systemInfo = JSON.parse(messageData);
} else {
systemInfo = messageData;
}
const currentUpload = parseInt(systemInfo.bytes_transmitted) || 0;
const currentDownload = parseInt(systemInfo.bytes_received) || 0;
const currentUptime = parseInt(systemInfo.uptime) || 0;
let uploadSpeed = 0;
let downloadSpeed = 0;
if (node.systemInfo && node.systemInfo.uptime) {
const timeDiff = currentUptime - node.systemInfo.uptime;
if (timeDiff > 0 && timeDiff <= 10) {
const lastUpload = node.systemInfo.uploadTraffic || 0;
const lastDownload = node.systemInfo.downloadTraffic || 0;
const uploadDiff = currentUpload - lastUpload;
const downloadDiff = currentDownload - lastDownload;
const uploadReset = currentUpload < lastUpload;
const downloadReset = currentDownload < lastDownload;
if (!uploadReset && uploadDiff >= 0) {
uploadSpeed = uploadDiff / timeDiff;
}
if (!downloadReset && downloadDiff >= 0) {
downloadSpeed = downloadDiff / timeDiff;
}
}
}
return {
...node,
connectionStatus: "online",
systemInfo: {
cpuUsage: parseFloat(systemInfo.cpu_usage) || 0,
memoryUsage: parseFloat(systemInfo.memory_usage) || 0,
uploadTraffic: currentUpload,
downloadTraffic: currentDownload,
uploadSpeed: uploadSpeed,
downloadSpeed: downloadSpeed,
uptime: currentUptime,
},
};
} catch {
return node; return node;
} }
return {
...node,
connectionStatus: "online",
systemInfo,
};
} }
return node; return node;
@@ -487,49 +345,13 @@ export default function NodePage() {
} }
}; };
// 尝试重新连接 const { wsConnected, wsConnecting } = useNodeRealtime({
const attemptReconnect = () => { onMessage: handleWebSocketMessage,
if (reconnectTimerRef.current) return; });
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
reconnectTimerRef.current = setTimeout(() => { useEffect(() => {
reconnectTimerRef.current = null; loadNodes();
initWebSocket(); }, [loadNodes]);
}, 3000 * reconnectAttemptsRef.current);
}
};
// 关闭WebSocket连接
const closeWebSocket = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
offlineTimersRef.current.forEach((timer) => clearTimeout(timer));
offlineTimersRef.current.clear();
reconnectAttemptsRef.current = 0;
setWsConnected(false);
setWsConnecting(false);
if (websocketRef.current) {
websocketRef.current.onopen = null;
websocketRef.current.onmessage = null;
websocketRef.current.onerror = null;
websocketRef.current.onclose = null;
if (
websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING
) {
websocketRef.current.close();
}
websocketRef.current = null;
}
};
// 格式化速度 // 格式化速度
const formatSpeed = (bytesPerSecond: number): string => { const formatSpeed = (bytesPerSecond: number): string => {
@@ -797,11 +619,11 @@ export default function NodePage() {
const res = await getNodeInstallCommand(node.id); const res = await getNodeInstallCommand(node.id);
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
try { const copied = await tryCopyInstallCommand(res.data);
await navigator.clipboard.writeText(res.data);
if (copied) {
toast.success("安装命令已复制到剪贴板"); toast.success("安装命令已复制到剪贴板");
} catch { } else {
// 复制失败,显示安装命令模态框
setInstallCommand(res.data); setInstallCommand(res.data);
setCurrentNodeName(node.name); setCurrentNodeName(node.name);
setInstallCommandModal(true); setInstallCommandModal(true);
@@ -1031,10 +853,7 @@ export default function NodePage() {
setNodeOrder(newOrder); setNodeOrder(newOrder);
// 保存到 localStorage saveOrder("node-order", newOrder);
try {
localStorage.setItem("node-order", JSON.stringify(newOrder));
} catch {}
// 持久化到数据库 // 持久化到数据库
try { try {
@@ -1241,42 +1060,12 @@ export default function NodePage() {
{/* 节点列表 */} {/* 节点列表 */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-64"> <PageLoadingState message="正在加载..." />
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
) : nodeList.length === 0 ? ( ) : nodeList.length === 0 ? (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700"> <PageEmptyState
<CardBody className="text-center py-16"> className="h-64"
<div className="flex flex-col items-center gap-4"> message="暂无节点配置,点击上方按钮开始创建"
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center"> />
<svg
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M5 12h14M5 12l4-4m-4 4l4 4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无节点配置
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何节点配置,点击上方按钮开始创建
</p>
</div>
</div>
</CardBody>
</Card>
) : ( ) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext <SortableContext
@@ -1315,6 +1104,7 @@ export default function NodePage() {
title="拖拽排序" title="拖拽排序"
> >
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1332,20 +1122,23 @@ export default function NodePage() {
远程 远程
</Chip> </Chip>
)} )}
<Chip {(() => {
className="text-xs" const connectionStatusMeta =
color={ getConnectionStatusMeta(
node.connectionStatus === "online" node.connectionStatus,
? "success" );
: "danger"
} return (
size="sm" <Chip
variant="flat" className="text-xs"
> color={connectionStatusMeta.color}
{node.connectionStatus === "online" size="sm"
? "在线" variant="flat"
: "离线"} >
</Chip> {connectionStatusMeta.text}
</Chip>
);
})()}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -1353,13 +1146,7 @@ export default function NodePage() {
<CardBody className="pt-0 pb-3"> <CardBody className="pt-0 pb-3">
{isRemoteNode && node.syncError && ( {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"> <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">
{node.syncError === "provider_share_deleted" {getRemoteSyncErrorMessage(node.syncError)}
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div> </div>
)} )}
{/* 基础信息 */} {/* 基础信息 */}
@@ -1816,6 +1603,7 @@ export default function NodePage() {
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors"> <div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<svg <svg
aria-hidden="true"
className="w-4 h-4 text-default-500" className="w-4 h-4 text-default-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1856,6 +1644,7 @@ export default function NodePage() {
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors"> <div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<svg <svg
aria-hidden="true"
className="w-4 h-4 text-default-500" className="w-4 h-4 text-default-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1899,6 +1688,7 @@ export default function NodePage() {
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors"> <div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<svg <svg
aria-hidden="true"
className="w-4 h-4 text-default-500" className="w-4 h-4 text-default-500"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
+25
View File
@@ -0,0 +1,25 @@
export const getConnectionStatusMeta = (
status: "online" | "offline",
): { color: "success" | "danger"; text: string } => {
if (status === "online") {
return { color: "success", text: "在线" };
}
return { color: "danger", text: "离线" };
};
export const getRemoteSyncErrorMessage = (syncError: string): string => {
if (syncError === "provider_share_deleted") {
return "提供方已删除该分享";
}
if (syncError === "provider_share_disabled") {
return "提供方已禁用该分享";
}
if (syncError === "provider_share_expired") {
return "提供方分享已过期";
}
return `远程同步失败: ${syncError}`;
};
@@ -0,0 +1,11 @@
export const tryCopyInstallCommand = async (
command: string,
): Promise<boolean> => {
try {
await navigator.clipboard.writeText(command);
return true;
} catch {
return false;
}
};
@@ -0,0 +1,86 @@
export interface NodeSystemInfo {
cpuUsage: number;
memoryUsage: number;
uploadTraffic: number;
downloadTraffic: number;
uploadSpeed: number;
downloadSpeed: number;
uptime: number;
}
type RawSystemInfo = Record<string, string | number | undefined>;
const toInteger = (value: string | number | undefined): number => {
return Number.parseInt(String(value ?? 0), 10) || 0;
};
const toFloat = (value: string | number | undefined): number => {
return Number.parseFloat(String(value ?? 0)) || 0;
};
const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
if (typeof messageData === "string") {
try {
const parsed = JSON.parse(messageData);
if (parsed && typeof parsed === "object") {
return parsed as RawSystemInfo;
}
return null;
} catch {
return null;
}
}
if (messageData && typeof messageData === "object") {
return messageData as RawSystemInfo;
}
return null;
};
export const buildNodeSystemInfo = (
messageData: unknown,
previous: NodeSystemInfo | null | undefined,
): NodeSystemInfo | null => {
const raw = parseRawSystemInfo(messageData);
if (!raw) {
return null;
}
const uploadTraffic = toInteger(raw.bytes_transmitted);
const downloadTraffic = toInteger(raw.bytes_received);
const uptime = toInteger(raw.uptime);
let uploadSpeed = 0;
let downloadSpeed = 0;
if (previous && previous.uptime) {
const timeDiff = uptime - previous.uptime;
if (timeDiff > 0 && timeDiff <= 10) {
const uploadDiff = uploadTraffic - previous.uploadTraffic;
const downloadDiff = downloadTraffic - previous.downloadTraffic;
if (uploadTraffic >= previous.uploadTraffic && uploadDiff >= 0) {
uploadSpeed = uploadDiff / timeDiff;
}
if (downloadTraffic >= previous.downloadTraffic && downloadDiff >= 0) {
downloadSpeed = downloadDiff / timeDiff;
}
}
}
return {
cpuUsage: toFloat(raw.cpu_usage),
memoryUsage: toFloat(raw.memory_usage),
uploadTraffic,
downloadTraffic,
uploadSpeed,
downloadSpeed,
uptime,
};
};
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef } from "react";
interface UseNodeOfflineTimersOptions {
delayMs?: number;
onNodeOffline: (nodeId: number) => void;
}
export const useNodeOfflineTimers = ({
delayMs = 3000,
onNodeOffline,
}: UseNodeOfflineTimersOptions) => {
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
new Map(),
);
const clearOfflineTimer = useCallback((nodeId: number) => {
const timer = timersRef.current.get(nodeId);
if (!timer) {
return;
}
clearTimeout(timer);
timersRef.current.delete(nodeId);
}, []);
const clearAllOfflineTimers = useCallback(() => {
timersRef.current.forEach((timer) => {
clearTimeout(timer);
});
timersRef.current.clear();
}, []);
const scheduleNodeOffline = useCallback(
(nodeId: number) => {
if (timersRef.current.has(nodeId)) {
return;
}
const timer = setTimeout(() => {
timersRef.current.delete(nodeId);
onNodeOffline(nodeId);
}, delayMs);
timersRef.current.set(nodeId, timer);
},
[delayMs, onNodeOffline],
);
useEffect(() => {
return () => {
clearAllOfflineTimers();
};
}, [clearAllOfflineTimers]);
return {
clearOfflineTimer,
scheduleNodeOffline,
clearAllOfflineTimers,
};
};
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from "react";
import axios from "axios";
import { getToken } from "@/utils/session";
interface NodeRealtimeMessage {
id?: string | number;
type?: string;
data?: unknown;
message?: string;
}
interface UseNodeRealtimeOptions {
onMessage: (message: NodeRealtimeMessage) => void;
enabled?: boolean;
}
const getRealtimeWsUrl = (): string => {
const baseUrl =
axios.defaults.baseURL ||
(import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/");
return (
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
`/system-info?type=0&secret=${getToken() || ""}`
);
};
export const useNodeRealtime = ({
onMessage,
enabled = true,
}: UseNodeRealtimeOptions) => {
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = 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]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const disconnect = useCallback(() => {
clearReconnectTimer();
reconnectAttemptsRef.current = 0;
setWsConnected(false);
setWsConnecting(false);
if (!websocketRef.current) {
return;
}
websocketRef.current.onopen = null;
websocketRef.current.onmessage = null;
websocketRef.current.onerror = null;
websocketRef.current.onclose = null;
if (
websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING
) {
websocketRef.current.close();
}
websocketRef.current = null;
}, [clearReconnectTimer]);
const connect = useCallback(() => {
if (!enabled) {
return;
}
if (
websocketRef.current &&
(websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING)
) {
return;
}
if (websocketRef.current) {
disconnect();
}
try {
setWsConnecting(true);
websocketRef.current = new WebSocket(getRealtimeWsUrl());
websocketRef.current.onopen = () => {
reconnectAttemptsRef.current = 0;
setWsConnected(true);
setWsConnecting(false);
};
websocketRef.current.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
if (parsed && typeof parsed === "object") {
onMessageRef.current(parsed as NodeRealtimeMessage);
}
} catch {}
};
websocketRef.current.onerror = () => {};
websocketRef.current.onclose = () => {
websocketRef.current = null;
setWsConnected(false);
setWsConnecting(false);
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
return;
}
reconnectAttemptsRef.current += 1;
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
connect();
}, 3000 * reconnectAttemptsRef.current);
};
} catch {
setWsConnected(false);
setWsConnecting(false);
}
}, [disconnect, enabled]);
useEffect(() => {
if (!enabled) {
return;
}
connect();
return () => {
disconnect();
};
}, [connect, disconnect, enabled]);
return {
wsConnected,
wsConnecting,
reconnectRealtime: connect,
disconnectRealtime: disconnect,
};
};
+17 -11
View File
@@ -1,16 +1,16 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Tabs, Tab } from "@heroui/tabs"; import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { import {
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { import {
@@ -131,7 +131,9 @@ export default function PanelSharingPage() {
const res = await getPeerShareList(); const res = await getPeerShareList();
if (res.code === 0) { if (res.code === 0) {
setShares(res.data || []); setShares(
Array.isArray(res.data) ? (res.data as unknown as PeerShare[]) : [],
);
} else { } else {
toast.error(res.msg || "加载分享列表失败"); toast.error(res.msg || "加载分享列表失败");
} }
@@ -145,9 +147,9 @@ export default function PanelSharingPage() {
const res = await getNodeList(); const res = await getNodeList();
if (res.code === 0) { if (res.code === 0) {
const localNodes: Node[] = (res.data || []).filter( const localNodes: Node[] = (
(node: Node) => (node?.isRemote ?? 0) !== 1, Array.isArray(res.data) ? (res.data as Node[]) : []
); ).filter((node: Node) => (node?.isRemote ?? 0) !== 1);
setNodes(localNodes); setNodes(localNodes);
setShareForm((prev) => { setShareForm((prev) => {
@@ -172,7 +174,11 @@ export default function PanelSharingPage() {
const res = await getPeerRemoteUsageList(); const res = await getPeerRemoteUsageList();
if (res.code === 0) { if (res.code === 0) {
setRemoteUsageNodes(res.data || []); setRemoteUsageNodes(
Array.isArray(res.data)
? (res.data as unknown as RemoteUsageNode[])
: [],
);
} else { } else {
toast.error(res.msg || "加载远程占用端口失败"); toast.error(res.msg || "加载远程占用端口失败");
} }
+7 -19
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardBody } from "@heroui/card"; import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { import {
Modal, Modal,
ModalContent, ModalContent,
@@ -8,8 +8,8 @@ import {
ModalBody, ModalBody,
ModalFooter, ModalFooter,
useDisclosure, useDisclosure,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -17,6 +17,7 @@ import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { updatePassword } from "@/api"; import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout"; import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
interface PasswordForm { interface PasswordForm {
newUsername: string; newUsername: string;
currentPassword: string; currentPassword: string;
@@ -47,21 +48,8 @@ export default function ProfilePage() {
useEffect(() => { useEffect(() => {
// 获取用户信息 // 获取用户信息
const name = localStorage.getItem("name") || "Admin"; setUsername(getSessionName() || "Admin");
setIsAdmin(getAdminFlag());
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem("admin") === "true";
if (localStorage.getItem("admin") === null) {
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断
localStorage.setItem("admin", adminFlag.toString());
}
setUsername(name);
setIsAdmin(adminFlag);
}, []); }, []);
// 管理员菜单项 // 管理员菜单项
+9 -7
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@heroui/card"; import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -87,11 +87,13 @@ export const SettingsPage = () => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
isIconOnly isIconOnly
aria-label="返回上一页"
className="text-gray-600 dark:text-gray-300" className="text-gray-600 dark:text-gray-300"
variant="light" variant="light"
onClick={() => navigate(-1)} onPress={() => navigate(-1)}
> >
<svg <svg
aria-hidden="true"
className="w-5 h-5" className="w-5 h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -136,7 +138,7 @@ export const SettingsPage = () => {
onChange={(e) => setNewAddress(e.target.value)} onChange={(e) => setNewAddress(e.target.value)}
/> />
</div> </div>
<Button color="primary" onClick={addPanelAddress}> <Button color="primary" onPress={addPanelAddress}>
添加 添加
</Button> </Button>
</div> </div>
@@ -182,7 +184,7 @@ export const SettingsPage = () => {
color="primary" color="primary"
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => setCurrentPanel(panel.name)} onPress={() => setCurrentPanel(panel.name)}
> >
设为当前 设为当前
</Button> </Button>
@@ -191,7 +193,7 @@ export const SettingsPage = () => {
color="danger" color="danger"
size="sm" size="sm"
variant="light" variant="light"
onClick={() => handleDeletePanelAddress(panel.name)} onPress={() => handleDeletePanelAddress(panel.name)}
> >
删除 删除
</Button> </Button>
+106 -257
View File
@@ -1,20 +1,20 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@heroui/input"; import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { import {
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@heroui/divider"; import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@heroui/alert"; import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
DndContext, DndContext,
@@ -45,6 +45,20 @@ import {
batchDeleteTunnels, batchDeleteTunnels,
batchRedeployTunnels, batchRedeployTunnels,
} from "@/api"; } from "@/api";
import { PageLoadingState } from "@/components/page-state";
import {
buildDiagnosisFallbackResult,
getDiagnosisQualityDisplay,
type DiagnosisResult,
} from "@/pages/tunnel/diagnosis";
import {
createTunnelFormDefaults,
getTunnelFlowDisplay,
getTunnelTypeDisplay,
validateTunnelForm,
} from "@/pages/tunnel/form";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import { extractApiErrorMessage } from "@/api/error-message";
interface ChainTunnel { interface ChainTunnel {
nodeId: number; nodeId: number;
@@ -92,27 +106,6 @@ interface TunnelForm {
status: number; status: number;
} }
interface DiagnosisResult {
tunnelName: string;
tunnelType: string;
timestamp: number;
results: Array<{
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
targetPort?: number;
message?: string;
averageTime?: number;
packetLoss?: number;
fromChainType?: number; // 1: 入口, 2: 链, 3: 出口
fromInx?: number;
toChainType?: number;
toInx?: number;
}>;
}
export default function TunnelPage() { export default function TunnelPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tunnels, setTunnels] = useState<Tunnel[]>([]); const [tunnels, setTunnels] = useState<Tunnel[]>([]);
@@ -134,18 +127,7 @@ export default function TunnelPage() {
useState<DiagnosisResult | null>(null); useState<DiagnosisResult | null>(null);
// 表单状态 // 表单状态
const [form, setForm] = useState<TunnelForm>({ const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
name: "",
type: 1,
inNodeId: [],
outNodeId: [],
chainNodes: [],
flow: 1,
trafficRatio: 1.0,
inIp: "",
ipPreference: "",
status: 1,
});
// 表单验证错误 // 表单验证错误
const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [errors, setErrors] = useState<{ [key: string]: string }>({});
@@ -189,32 +171,12 @@ export default function TunnelPage() {
setTunnelOrder(dbOrder); setTunnelOrder(dbOrder);
} else { } else {
try { setTunnelOrder(
const stored = localStorage.getItem("tunnel-order"); loadStoredOrder(
"tunnel-order",
if (stored) { tunnelsData.map((t) => t.id),
const parsed = JSON.parse(stored); ),
);
if (Array.isArray(parsed)) {
const existingIds = new Set(tunnelsData.map((t) => t.id));
const validOrder = parsed
.map((id: any) => Number(id))
.filter((id: number) => existingIds.has(id));
if (validOrder.length > 0) {
setTunnelOrder(validOrder);
} else {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} else {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} else {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} catch {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} }
} else { } else {
toast.error(tunnelsRes.msg || "获取隧道列表失败"); toast.error(tunnelsRes.msg || "获取隧道列表失败");
@@ -233,59 +195,7 @@ export default function TunnelPage() {
// 表单验证 // 表单验证
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: { [key: string]: string } = {}; const newErrors = validateTunnelForm(form, nodes);
if (!form.name.trim()) {
newErrors.name = "请输入隧道名称";
} else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = "隧道名称长度应在2-50个字符之间";
}
if (!form.inNodeId || form.inNodeId.length === 0) {
newErrors.inNodeId = "请至少选择一个入口节点";
} else {
// 验证所有选择的节点都在线
const offlineNodes = form.inNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
if (offlineNodes.length > 0) {
newErrors.inNodeId = "所有入口节点必须在线";
}
}
if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) {
newErrors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
}
// 隧道转发时的验证
if (form.type === 2) {
if (!form.outNodeId || form.outNodeId.length === 0) {
newErrors.outNodeId = "请至少选择一个出口节点";
} else {
// 验证所有选择的节点都在线
const offlineNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
if (offlineNodes.length > 0) {
newErrors.outNodeId = "所有出口节点必须在线";
}
// 检查是否有重复节点
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
const overlap = inNodeIds.filter((id) => outNodeIds.includes(id));
if (overlap.length > 0) {
newErrors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点";
}
}
}
setErrors(newErrors); setErrors(newErrors);
@@ -295,18 +205,7 @@ export default function TunnelPage() {
// 新增隧道 // 新增隧道
const handleAdd = () => { const handleAdd = () => {
setIsEdit(false); setIsEdit(false);
setForm({ setForm(createTunnelFormDefaults());
name: "",
type: 1,
inNodeId: [],
outNodeId: [],
chainNodes: [],
flow: 1,
trafficRatio: 1.0,
inIp: "",
ipPreference: "",
status: 1,
});
setErrors({}); setErrors({});
setModalOpen(true); setModalOpen(true);
}; };
@@ -317,22 +216,22 @@ export default function TunnelPage() {
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息 // 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
setForm({ setForm({
id: tunnel.id, id: tunnel.id,
name: tunnel.name, name: tunnel.name,
type: tunnel.type, type: tunnel.type,
inNodeId: tunnel.inNodeId || [], inNodeId: tunnel.inNodeId || [],
outNodeId: tunnel.outNodeId || [], outNodeId: tunnel.outNodeId || [],
chainNodes: tunnel.chainNodes || [], chainNodes: tunnel.chainNodes || [],
flow: tunnel.flow, flow: tunnel.flow,
trafficRatio: tunnel.trafficRatio, trafficRatio: tunnel.trafficRatio,
inIp: tunnel.inIp inIp: tunnel.inIp
? tunnel.inIp ? tunnel.inIp
.split(",") .split(",")
.map((ip: string) => ip.trim()) .map((ip: string) => ip.trim())
.join("\n") .join("\n")
: "", : "",
ipPreference: tunnel.ipPreference || "", ipPreference: tunnel.ipPreference || "",
status: tunnel.status, status: tunnel.status,
}); });
setErrors({}); setErrors({});
setModalOpen(true); setModalOpen(true);
@@ -518,91 +417,33 @@ export default function TunnelPage() {
const response = await diagnoseTunnel(tunnel.id); const response = await diagnoseTunnel(tunnel.id);
if (response.code === 0) { if (response.code === 0) {
setDiagnosisResult(response.data); setDiagnosisResult(response.data as DiagnosisResult);
} else { } else {
toast.error(response.msg || "诊断失败"); toast.error(response.msg || "诊断失败");
setDiagnosisResult({ setDiagnosisResult(
tunnelName: tunnel.name, buildDiagnosisFallbackResult({
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发", tunnelName: tunnel.name,
timestamp: Date.now(), tunnelType: tunnel.type,
results: [ description: "诊断失败",
{ message: response.msg || "诊断过程中发生错误",
success: false, }),
description: "诊断失败", );
nodeName: "-",
nodeId: "-",
targetIp: "-",
targetPort: 443,
message: response.msg || "诊断过程中发生错误",
},
],
});
} }
} catch { } catch {
toast.error("网络错误,请重试"); toast.error("网络错误,请重试");
setDiagnosisResult({ setDiagnosisResult(
tunnelName: tunnel.name, buildDiagnosisFallbackResult({
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发", tunnelName: tunnel.name,
timestamp: Date.now(), tunnelType: tunnel.type,
results: [ description: "网络错误",
{ message: "无法连接到服务器",
success: false, }),
description: "网络错误", );
nodeName: "-",
nodeId: "-",
targetIp: "-",
targetPort: 443,
message: "无法连接到服务器",
},
],
});
} finally { } finally {
setDiagnosisLoading(false); setDiagnosisLoading(false);
} }
}; };
// 获取类型显示
const getTypeDisplay = (type: number) => {
switch (type) {
case 1:
return { text: "端口转发", color: "primary" };
case 2:
return { text: "隧道转发", color: "secondary" };
default:
return { text: "未知", color: "default" };
}
};
// 获取流量计算显示
const getFlowDisplay = (flow: number) => {
switch (flow) {
case 1:
return "单向计算";
case 2:
return "双向计算";
default:
return "未知";
}
};
// 获取连接质量
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
if (averageTime === undefined || packetLoss === undefined) return null;
if (averageTime < 30 && packetLoss === 0)
return { text: "🚀 优秀", color: "success" };
if (averageTime < 50 && packetLoss === 0)
return { text: "✨ 很好", color: "success" };
if (averageTime < 100 && packetLoss < 1)
return { text: "👍 良好", color: "primary" };
if (averageTime < 150 && packetLoss < 2)
return { text: "😐 一般", color: "warning" };
if (averageTime < 200 && packetLoss < 5)
return { text: "😟 较差", color: "warning" };
return { text: "😵 很差", color: "danger" };
};
// 处理拖拽结束 // 处理拖拽结束
const handleDragEnd = async (event: DragEndEvent) => { const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -624,10 +465,7 @@ export default function TunnelPage() {
setTunnelOrder(newOrder); setTunnelOrder(newOrder);
// 保存到 localStorage saveOrder("tunnel-order", newOrder);
try {
localStorage.setItem("tunnel-order", JSON.stringify(newOrder));
} catch {}
// 持久化到数据库 // 持久化到数据库
try { try {
@@ -701,8 +539,8 @@ export default function TunnelPage() {
} else { } else {
toast.error(res.msg || "删除失败"); toast.error(res.msg || "删除失败");
} }
} catch (e: any) { } catch (error) {
toast.error(e.message || "删除失败"); toast.error(extractApiErrorMessage(error, "删除失败"));
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -730,8 +568,8 @@ export default function TunnelPage() {
} else { } else {
toast.error(res.msg || "下发失败"); toast.error(res.msg || "下发失败");
} }
} catch (e: any) { } catch (error) {
toast.error(e.message || "下发失败"); toast.error(extractApiErrorMessage(error, "下发失败"));
} finally { } finally {
setBatchLoading(false); setBatchLoading(false);
} }
@@ -829,14 +667,7 @@ export default function TunnelPage() {
}; };
if (loading) { if (loading) {
return ( return <PageLoadingState message="正在加载..." />;
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
);
} }
return ( return (
@@ -902,7 +733,7 @@ export default function TunnelPage() {
> >
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedTunnels.map((tunnel) => { {sortedTunnels.map((tunnel) => {
const typeDisplay = getTypeDisplay(tunnel.type); const typeDisplay = getTunnelTypeDisplay(tunnel.type);
return ( return (
<SortableItem key={tunnel.id} id={tunnel.id}> <SortableItem key={tunnel.id} id={tunnel.id}>
@@ -942,6 +773,7 @@ export default function TunnelPage() {
title="拖拽排序" title="拖拽排序"
> >
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -960,6 +792,7 @@ export default function TunnelPage() {
{/* 入口节点 */} {/* 入口节点 */}
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20"> <div className="flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-primary-600" className="w-3 h-3 text-primary-600"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -977,6 +810,7 @@ export default function TunnelPage() {
{/* 箭头 */} {/* 箭头 */}
<svg <svg
aria-hidden="true"
className="w-4 h-4 text-default-400" className="w-4 h-4 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -993,6 +827,7 @@ export default function TunnelPage() {
{/* 转发链 */} {/* 转发链 */}
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50 dark:bg-secondary-100/20 rounded border border-secondary-200 dark:border-secondary-300/20"> <div className="flex items-center gap-1 px-2 py-1 bg-secondary-50 dark:bg-secondary-100/20 rounded border border-secondary-200 dark:border-secondary-300/20">
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-secondary-600" className="w-3 h-3 text-secondary-600"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1013,6 +848,7 @@ export default function TunnelPage() {
{/* 箭头 */} {/* 箭头 */}
<svg <svg
aria-hidden="true"
className="w-4 h-4 text-default-400" className="w-4 h-4 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1029,6 +865,7 @@ export default function TunnelPage() {
{/* 出口节点 */} {/* 出口节点 */}
<div className="flex items-center gap-1 px-2 py-1 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20"> <div className="flex items-center gap-1 px-2 py-1 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<svg <svg
aria-hidden="true"
className="w-3 h-3 text-success-600" className="w-3 h-3 text-success-600"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1050,13 +887,15 @@ export default function TunnelPage() {
</div> </div>
{/* 流量配置 */} {/* 流量配置 */}
<div className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}> <div
className={`grid gap-2 ${tunnel.type === 2 && tunnel.ipPreference ? "grid-cols-3" : "grid-cols-2"}`}
>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded"> <div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
<div className="text-xs text-default-500"> <div className="text-xs text-default-500">
流量计算 流量计算
</div> </div>
<div className="text-sm font-semibold text-foreground mt-0.5"> <div className="text-sm font-semibold text-foreground mt-0.5">
{getFlowDisplay(tunnel.flow)} {getTunnelFlowDisplay(tunnel.flow)}
</div> </div>
</div> </div>
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded"> <div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
@@ -1073,7 +912,9 @@ export default function TunnelPage() {
连接偏好 连接偏好
</div> </div>
<div className="text-sm font-semibold text-foreground mt-0.5"> <div className="text-sm font-semibold text-foreground mt-0.5">
{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"} {tunnel.ipPreference === "v4"
? "IPv4"
: "IPv6"}
</div> </div>
</div> </div>
)} )}
@@ -1087,6 +928,7 @@ export default function TunnelPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1105,6 +947,7 @@ export default function TunnelPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1127,6 +970,7 @@ export default function TunnelPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1165,6 +1009,7 @@ export default function TunnelPage() {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg <svg
aria-hidden="true"
className="w-8 h-8 text-default-400" className="w-8 h-8 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1280,11 +1125,11 @@ export default function TunnelPage() {
errorMessage={errors.trafficRatio} errorMessage={errors.trafficRatio}
isInvalid={!!errors.trafficRatio} isInvalid={!!errors.trafficRatio}
label="流量倍率" label="流量倍率"
placeholder="例如:0.5 或 1 或 2"
type="number"
step="any"
min={0.01}
max={100} max={100}
min={0.01}
placeholder="例如:0.5 或 1 或 2"
step="any"
type="number"
value={form.trafficRatio.toString()} value={form.trafficRatio.toString()}
variant="bordered" variant="bordered"
onChange={(e) => onChange={(e) =>
@@ -1418,6 +1263,7 @@ export default function TunnelPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1477,12 +1323,14 @@ export default function TunnelPage() {
</span> </span>
<Button <Button
isIconOnly isIconOnly
aria-label={`删除第${groupIndex + 1}跳`}
color="danger" color="danger"
size="sm" size="sm"
variant="light" variant="light"
onPress={() => removeChainNode(groupIndex)} onPress={() => removeChainNode(groupIndex)}
> >
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -1555,17 +1403,17 @@ export default function TunnelPage() {
); );
// 添加新节点 // 添加新节点
addedIds.forEach((nodeId) => addedIds.forEach((nodeId) => {
addNodeToChain(groupIndex, nodeId), addNodeToChain(groupIndex, nodeId);
); });
// 删除取消选择的节点 // 删除取消选择的节点
removedIds.forEach((nodeId) => removedIds.forEach((nodeId) => {
removeNodeFromChain( removeNodeFromChain(
groupIndex, groupIndex,
nodeId, nodeId,
), );
); });
}} }}
> >
{nodes.map((node) => ( {nodes.map((node) => (
@@ -2189,7 +2037,7 @@ export default function TunnelPage() {
</thead> </thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800"> <tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => { {results.map((result, index) => {
const quality = getQualityDisplay( const quality = getDiagnosisQualityDisplay(
result.averageTime, result.averageTime,
result.packetLoss, result.packetLoss,
); );
@@ -2362,7 +2210,7 @@ export default function TunnelPage() {
</h3> </h3>
</div> </div>
{results.map((result, index) => { {results.map((result, index) => {
const quality = getQualityDisplay( const quality = getDiagnosisQualityDisplay(
result.averageTime, result.averageTime,
result.packetLoss, result.packetLoss,
); );
@@ -2518,6 +2366,7 @@ export default function TunnelPage() {
<div className="text-center py-16"> <div className="text-center py-16">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg <svg
aria-hidden="true"
className="w-8 h-8 text-default-400" className="w-8 h-8 text-default-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -0,0 +1,87 @@
export interface DiagnosisEntry {
success: boolean;
description: string;
nodeName: string;
nodeId: string;
targetIp: string;
targetPort?: number;
message?: string;
averageTime?: number;
packetLoss?: number;
fromChainType?: number;
fromInx?: number;
toChainType?: number;
toInx?: number;
}
export interface DiagnosisResult {
tunnelName: string;
tunnelType: string;
timestamp: number;
results: DiagnosisEntry[];
}
export interface DiagnosisFallbackInput {
tunnelName: string;
tunnelType: number;
description: string;
message: string;
}
export const buildDiagnosisFallbackResult = ({
tunnelName,
tunnelType,
description,
message,
}: DiagnosisFallbackInput): DiagnosisResult => {
return {
tunnelName,
tunnelType: tunnelType === 1 ? "端口转发" : "隧道转发",
timestamp: Date.now(),
results: [
{
success: false,
description,
nodeName: "-",
nodeId: "-",
targetIp: "-",
targetPort: 443,
message,
},
],
};
};
export const getDiagnosisQualityDisplay = (
averageTime?: number,
packetLoss?: number,
): {
text: string;
color: "success" | "primary" | "warning" | "danger";
} | null => {
if (averageTime === undefined || packetLoss === undefined) {
return null;
}
if (averageTime < 30 && packetLoss === 0) {
return { text: "🚀 优秀", color: "success" };
}
if (averageTime < 50 && packetLoss === 0) {
return { text: "✨ 很好", color: "success" };
}
if (averageTime < 100 && packetLoss < 1) {
return { text: "👍 良好", color: "primary" };
}
if (averageTime < 150 && packetLoss < 2) {
return { text: "😐 一般", color: "warning" };
}
if (averageTime < 200 && packetLoss < 5) {
return { text: "😟 较差", color: "warning" };
}
return { text: "😵 很差", color: "danger" };
};
+110
View File
@@ -0,0 +1,110 @@
interface TunnelChainNode {
nodeId: number;
}
interface TunnelFormInput {
name: string;
type: number;
inNodeId: TunnelChainNode[];
outNodeId?: TunnelChainNode[];
trafficRatio: number;
}
interface TunnelNodeInput {
id: number;
status: number;
}
export const createTunnelFormDefaults = () => {
return {
name: "",
type: 1,
inNodeId: [],
outNodeId: [],
chainNodes: [],
flow: 1,
trafficRatio: 1.0,
inIp: "",
ipPreference: "",
status: 1,
};
};
export const validateTunnelForm = (
form: TunnelFormInput,
nodes: TunnelNodeInput[],
): Record<string, string> => {
const errors: Record<string, string> = {};
if (!form.name.trim()) {
errors.name = "请输入隧道名称";
} else if (form.name.length < 2 || form.name.length > 50) {
errors.name = "隧道名称长度应在2-50个字符之间";
}
if (!form.inNodeId || form.inNodeId.length === 0) {
errors.inNodeId = "请至少选择一个入口节点";
} else {
const offlineInNodes = form.inNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
if (offlineInNodes.length > 0) {
errors.inNodeId = "所有入口节点必须在线";
}
}
if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) {
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
}
if (form.type === 2) {
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";
} else {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
}
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
const overlap = inNodeIds.filter((id) => outNodeIds.includes(id));
if (overlap.length > 0) {
errors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点";
}
}
}
return errors;
};
export const getTunnelTypeDisplay = (type: number) => {
switch (type) {
case 1:
return { text: "端口转发", color: "primary" };
case 2:
return { text: "隧道转发", color: "secondary" };
default:
return { text: "未知", color: "default" };
}
};
export const getTunnelFlowDisplay = (flow: number) => {
switch (flow) {
case 1:
return "单向计算";
case 2:
return "双向计算";
default:
return "未知";
}
};
+88 -30
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "@heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
import { import {
Table, Table,
TableHeader, TableHeader,
@@ -9,7 +9,7 @@ import {
TableBody, TableBody,
TableRow, TableRow,
TableCell, TableCell,
} from "@heroui/table"; } from "@/shadcn-bridge/heroui/table";
import { import {
Modal, Modal,
ModalContent, ModalContent,
@@ -17,20 +17,21 @@ import {
ModalBody, ModalBody,
ModalFooter, ModalFooter,
useDisclosure, useDisclosure,
} from "@heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { RadioGroup, Radio } from "@heroui/radio"; import { RadioGroup, Radio } from "@/shadcn-bridge/heroui/radio";
import { Checkbox } from "@heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { DatePicker } from "@heroui/date-picker"; import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import { Spinner } from "@heroui/spinner"; import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Progress } from "@heroui/progress"; import { Progress } from "@/shadcn-bridge/heroui/progress";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
import { import {
User, User,
UserForm, UserForm,
UserGroup,
UserTunnel, UserTunnel,
TunnelAssignItem, TunnelAssignItem,
Tunnel, Tunnel,
@@ -49,6 +50,8 @@ import {
updateUserTunnel, updateUserTunnel,
getSpeedLimitList, getSpeedLimitList,
resetUserFlow, resetUserFlow,
getUserGroupList,
getUserGroups,
} from "@/api"; } from "@/api";
import { import {
SearchIcon, SearchIcon,
@@ -57,6 +60,7 @@ import {
UserIcon, UserIcon,
SettingsIcon, SettingsIcon,
} from "@/components/icons"; } from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
// 工具函数 // 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => { const formatFlow = (value: number, unit: string = "bytes"): string => {
@@ -204,12 +208,14 @@ export default function UserPage() {
// 其他数据 // 其他数据
const [tunnels, setTunnels] = useState<Tunnel[]>([]); const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]); const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
// 生命周期 // 生命周期
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
loadTunnels(); loadTunnels();
loadSpeedLimits(); loadSpeedLimits();
loadUserGroups();
}, [pagination.current, pagination.size, searchKeyword]); }, [pagination.current, pagination.size, searchKeyword]);
// 数据加载函数 // 数据加载函数
@@ -223,9 +229,7 @@ export default function UserPage() {
}); });
if (response.code === 0) { if (response.code === 0) {
const data = response.data || {}; setUsers(Array.isArray(response.data) ? response.data : []);
setUsers(data || []);
} else { } else {
toast.error(response.msg || "获取用户列表失败"); toast.error(response.msg || "获取用户列表失败");
} }
@@ -241,7 +245,7 @@ export default function UserPage() {
const response = await getTunnelList(); const response = await getTunnelList();
if (response.code === 0) { if (response.code === 0) {
setTunnels(response.data || []); setTunnels(Array.isArray(response.data) ? response.data : []);
} }
} catch {} } catch {}
}; };
@@ -251,7 +255,25 @@ export default function UserPage() {
const response = await getSpeedLimitList(); const response = await getSpeedLimitList();
if (response.code === 0) { if (response.code === 0) {
setSpeedLimits(response.data || []); const speedLimitList = Array.isArray(response.data)
? response.data.map((item) => ({
...item,
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
}))
: [];
setSpeedLimits(speedLimitList);
}
} catch {}
};
const loadUserGroups = async () => {
try {
const response = await getUserGroupList();
if (response.code === 0) {
setUserGroups(Array.isArray(response.data) ? response.data : []);
} }
} catch {} } catch {}
}; };
@@ -262,7 +284,7 @@ export default function UserPage() {
const response = await getUserTunnelList({ userId }); const response = await getUserTunnelList({ userId });
if (response.code === 0) { if (response.code === 0) {
setUserTunnels(response.data || []); setUserTunnels(Array.isArray(response.data) ? response.data : []);
} else { } else {
toast.error(response.msg || "获取隧道权限列表失败"); toast.error(response.msg || "获取隧道权限列表失败");
} }
@@ -289,12 +311,23 @@ export default function UserPage() {
num: 10, num: 10,
expTime: null, expTime: null,
flowResetTime: 0, flowResetTime: 0,
groupIds: [],
}); });
onUserModalOpen(); onUserModalOpen();
}; };
const handleEdit = (user: User) => { const handleEdit = async (user: User) => {
setIsEdit(true); setIsEdit(true);
let currentGroupIds: number[] = [];
try {
const groupRes = await getUserGroups(user.id);
if (groupRes.code === 0) {
currentGroupIds = groupRes.data || [];
}
} catch {}
setUserForm({ setUserForm({
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -305,6 +338,7 @@ export default function UserPage() {
num: user.num, num: user.num,
expTime: user.expTime ? new Date(user.expTime) : null, expTime: user.expTime ? new Date(user.expTime) : null,
flowResetTime: user.flowResetTime ?? 0, flowResetTime: user.flowResetTime ?? 0,
groupIds: currentGroupIds,
}); });
onUserModalOpen(); onUserModalOpen();
}; };
@@ -345,6 +379,7 @@ export default function UserPage() {
const submitData: any = { const submitData: any = {
...userForm, ...userForm,
expTime: userForm.expTime.getTime(), expTime: userForm.expTime.getTime(),
groupIds: userForm.groupIds ?? [],
}; };
if (isEdit && !submitData.pwd) { if (isEdit && !submitData.pwd) {
@@ -588,7 +623,7 @@ export default function UserPage() {
base: "bg-default-100", base: "bg-default-100",
input: "bg-transparent", input: "bg-transparent",
inputWrapper: inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 focus-within:border-primary data-[hover=true]:border-default-300", "bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}} }}
placeholder="搜索用户名" placeholder="搜索用户名"
startContent={<SearchIcon className="w-4 h-4 text-default-400" />} startContent={<SearchIcon className="w-4 h-4 text-default-400" />}
@@ -598,10 +633,11 @@ export default function UserPage() {
/> />
<Button <Button
isIconOnly isIconOnly
aria-label="搜索用户"
className="min-h-10 w-10" className="min-h-10 w-10"
color="primary" color="primary"
variant="solid" variant="solid"
onClick={handleSearch} onPress={handleSearch}
> >
<SearchIcon className="w-4 h-4" /> <SearchIcon className="w-4 h-4" />
</Button> </Button>
@@ -615,12 +651,7 @@ export default function UserPage() {
{/* 用户列表 */} {/* 用户列表 */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-64"> <PageLoadingState message="正在加载..." />
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700"> <Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16"> <CardBody className="text-center py-16">
@@ -771,6 +802,7 @@ export default function UserPage() {
size="sm" size="sm"
startContent={ startContent={
<svg <svg
aria-hidden="true"
className="w-3 h-3" className="w-3 h-3"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -951,6 +983,26 @@ export default function UserPage() {
<Radio value="1">正常</Radio> <Radio value="1">正常</Radio>
<Radio value="0">禁用</Radio> <Radio value="0">禁用</Radio>
</RadioGroup> </RadioGroup>
{userGroups.length > 0 && (
<Select
label="用户分组(可选)"
placeholder="选择要加入的分组"
selectedKeys={new Set((userForm.groupIds ?? []).map(String))}
selectionMode="multiple"
onSelectionChange={(keys) => {
const selected = Array.from(keys as Set<string>).map(Number);
setUserForm((prev) => ({ ...prev, groupIds: selected }));
}}
>
{userGroups.map((g) => (
<SelectItem key={g.id.toString()} textValue={g.name}>
{g.name}
</SelectItem>
))}
</Select>
)}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onPress={onUserModalClose}>取消</Button> <Button onPress={onUserModalClose}>取消</Button>
@@ -1211,22 +1263,25 @@ export default function UserPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
isIconOnly isIconOnly
aria-label="编辑隧道权限"
color="primary" color="primary"
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => handleEditTunnel(userTunnel)} onPress={() => handleEditTunnel(userTunnel)}
> >
<EditIcon className="w-4 h-4" /> <EditIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
aria-label="重置隧道流量"
color="warning" color="warning"
size="sm" size="sm"
title="重置流量" title="重置流量"
variant="flat" variant="flat"
onClick={() => handleResetTunnelFlow(userTunnel)} onPress={() => handleResetTunnelFlow(userTunnel)}
> >
<svg <svg
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1240,10 +1295,11 @@ export default function UserPage() {
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
aria-label="删除隧道权限"
color="danger" color="danger"
size="sm" size="sm"
variant="flat" variant="flat"
onClick={() => handleRemoveTunnel(userTunnel)} onPress={() => handleRemoveTunnel(userTunnel)}
> >
<DeleteIcon className="w-4 h-4" /> <DeleteIcon className="w-4 h-4" />
</Button> </Button>
@@ -1548,6 +1604,7 @@ export default function UserPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center">
<svg <svg
aria-hidden="true"
className="w-6 h-6 text-warning" className="w-6 h-6 text-warning"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -1636,6 +1693,7 @@ export default function UserPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center">
<svg <svg
aria-hidden="true"
className="w-6 h-6 text-warning" className="w-6 h-6 text-warning"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
+1 -9
View File
@@ -1,19 +1,11 @@
import type { NavigateOptions } from "react-router-dom";
import * as React from "react"; import * as React from "react";
import { HeroUIProvider } from "@heroui/system"; import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
import { useHref, useNavigate } from "react-router-dom"; import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { I18nProvider } from "@react-aria/i18n"; import { I18nProvider } from "@react-aria/i18n";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
declare module "@react-types/shared" {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}
export interface ProvidersProps { export interface ProvidersProps {
children: React.ReactNode; children: React.ReactNode;
} }
@@ -0,0 +1,47 @@
import * as React from "react";
import {
Accordion as BaseAccordion,
AccordionContent,
AccordionItem as BaseAccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
export interface AccordionProps extends Omit<React.ComponentProps<"div">, "children"> {
children: React.ReactNode;
variant?: "bordered" | "light" | "splitted";
}
export interface AccordionItemProps {
"aria-label"?: string;
children: React.ReactNode;
className?: string;
title: React.ReactNode;
value?: string;
}
export function Accordion({ children, className }: AccordionProps) {
return (
<BaseAccordion className={cn("w-full", className)} type="multiple">
{children}
</BaseAccordion>
);
}
export function AccordionItem({
"aria-label": ariaLabel,
children,
className,
title,
value,
}: AccordionItemProps) {
const generatedValue = React.useId();
return (
<BaseAccordionItem className={className} value={value ?? generatedValue}>
<AccordionTrigger aria-label={ariaLabel}>{title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</BaseAccordionItem>
);
}
@@ -0,0 +1,52 @@
import * as React from "react";
import {
Alert as BaseAlert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
type AlertVariant = "solid" | "flat" | "faded" | "bordered";
interface AlertProps extends Omit<React.ComponentProps<"div">, "color" | "title"> {
color?: AlertColor;
description?: React.ReactNode;
title?: React.ReactNode;
variant?: AlertVariant;
}
function mapVariant(color: AlertColor) {
if (color === "danger") {
return "destructive" as const;
}
if (color === "success") {
return "success" as const;
}
if (color === "warning") {
return "warning" as const;
}
return "default" as const;
}
export function Alert({
children,
color = "default",
description,
title,
variant,
...props
}: AlertProps) {
return (
<BaseAlert
className={variant === "flat" ? "bg-opacity-15" : undefined}
variant={mapVariant(color)}
{...props}
>
{title ? <AlertTitle>{title}</AlertTitle> : null}
{description ? <AlertDescription>{description}</AlertDescription> : null}
{!description && !title ? children : null}
</BaseAlert>
);
}
@@ -0,0 +1,191 @@
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { Button as BaseButton } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type HeroButtonColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
type HeroButtonVariant = "solid" | "light" | "flat" | "ghost" | "bordered" | "shadow";
type HeroButtonSize = "sm" | "md" | "lg";
export interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
color?: HeroButtonColor;
endContent?: React.ReactNode;
isIconOnly?: boolean;
isLoading?: boolean;
onPress?: (event: React.MouseEvent<HTMLButtonElement>) => void;
size?: HeroButtonSize;
startContent?: React.ReactNode;
variant?: HeroButtonVariant;
}
function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "default" | "destructive" | "secondary" | "outline" | "ghost" | "light" | "flat" {
if (variant === "bordered") {
return "outline";
}
if (variant === "ghost") {
return "ghost";
}
if (variant === "light") {
return "light";
}
if (variant === "flat") {
return "flat";
}
if (color === "danger") {
return "destructive";
}
if (color === "secondary") {
return "secondary";
}
return "default";
}
function mapSize(size: HeroButtonSize, isIconOnly: boolean): "default" | "sm" | "lg" | "icon" {
if (isIconOnly) {
return "icon";
}
if (size === "sm") {
return "sm";
}
if (size === "lg") {
return "lg";
}
return "default";
}
const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
default:
"bg-default-200 text-default-800 hover:bg-default-300 dark:bg-default-100 dark:text-default-800 dark:hover:bg-default-200",
success: "bg-success text-white hover:bg-success-600",
warning: "bg-warning text-warning-900 hover:bg-warning-400",
};
const borderedColorClassMap: Record<HeroButtonColor, string> = {
default:
"border-default-300 text-default-700 hover:bg-default-100 dark:border-default-300 dark:text-default-600 dark:hover:bg-default-200/40",
primary: "border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
secondary:
"border-secondary text-secondary hover:bg-secondary-50 dark:border-secondary-500/60 dark:text-secondary-300 dark:hover:bg-secondary-900/20",
success: "border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
warning:
"border-warning text-warning-700 hover:bg-warning-50 dark:border-warning-500/60 dark:text-warning-300 dark:hover:bg-warning-900/20",
danger: "border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
};
const lightColorClassMap: Record<HeroButtonColor, string> = {
default: "text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
primary: "text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
secondary: "text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
success: "text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
warning: "text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
danger: "text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
};
const flatColorClassMap: Record<HeroButtonColor, string> = {
default:
"bg-default-100 text-default-700 hover:bg-default-200 dark:bg-default-100/45 dark:text-default-700 dark:hover:bg-default-200/60",
primary: "bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
secondary:
"bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-900/30 dark:text-secondary-300 dark:hover:bg-secondary-900/45",
success: "bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
warning: "bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
danger: "bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
};
const shadowColorClassMap: Record<HeroButtonColor, string> = {
default: "shadow-md shadow-default-400/40",
primary: "shadow-md shadow-primary-500/35",
secondary: "shadow-md shadow-secondary-500/35",
success: "shadow-md shadow-success-500/35",
warning: "shadow-md shadow-warning-500/40",
danger: "shadow-md shadow-danger-500/35",
};
function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
if (variant === "bordered") {
return borderedColorClassMap[color];
}
if (variant === "light") {
return lightColorClassMap[color];
}
if (variant === "flat") {
return flatColorClassMap[color];
}
if (variant === "solid" || variant === "shadow") {
return solidColorClassMap[color] ?? "";
}
return "";
}
function mapShadowClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
if (variant !== "shadow") {
return "";
}
return shadowColorClassMap[color];
}
export const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps & {
isDisabled?: boolean;
}
>(
(
{
children,
className,
color = "default",
disabled,
endContent,
isIconOnly = false,
isLoading = false,
isDisabled,
onClick,
onPress,
size = "md",
startContent,
type = "button",
variant = "solid",
...props
},
ref,
) => {
const resolvedVariant = mapVariant(color, variant);
const resolvedSize = mapSize(size, isIconOnly);
const resolvedDisabled = Boolean(disabled || isDisabled || isLoading);
const resolvedColorClass = mapColorClass(color, variant);
const resolvedShadowClass = mapShadowClass(color, variant);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
onPress?.(event);
};
return (
<BaseButton
className={cn(isIconOnly ? "p-0" : "", resolvedColorClass, resolvedShadowClass, className)}
disabled={resolvedDisabled}
ref={ref}
size={resolvedSize}
type={type}
variant={resolvedVariant}
onClick={handleClick}
{...props}
>
{isLoading ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : null}
{startContent}
{isIconOnly ? null : children}
{isIconOnly ? children : null}
{endContent}
</BaseButton>
);
},
);
Button.displayName = "HeroBridgeButton";
@@ -0,0 +1,20 @@
import * as React from "react";
import {
Card as BaseCard,
CardContent,
CardHeader as BaseCardHeader,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.ComponentProps<"div">) {
return <BaseCard className={className} {...props} />;
}
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />;
}
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
return <CardContent className={cn("p-4 md:p-6", className)} {...props} />;
}
@@ -0,0 +1,43 @@
import * as React from "react";
import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
export interface CheckboxProps
extends Omit<React.ComponentProps<typeof BaseCheckbox>, "checked" | "onCheckedChange"> {
classNames?: Record<string, string>;
color?: string;
isDisabled?: boolean;
isSelected?: boolean;
onValueChange?: (value: boolean) => void;
size?: string;
}
export function Checkbox({
children,
className,
isDisabled,
isSelected,
onValueChange,
...props
}: CheckboxProps & {
children?: React.ReactNode;
}) {
const handleCheckedChange: React.ComponentProps<
typeof BaseCheckbox
>["onCheckedChange"] = (value: boolean | "indeterminate") => {
onValueChange?.(value === true);
};
return (
<div className={cn("inline-flex items-center gap-2", isDisabled ? "opacity-50" : "", className)}>
<BaseCheckbox
checked={Boolean(isSelected)}
disabled={isDisabled}
onCheckedChange={handleCheckedChange}
{...props}
/>
{children ? <span className="text-sm">{children}</span> : null}
</div>
);
}
@@ -0,0 +1,56 @@
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type ChipColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
type ChipVariant = "solid" | "flat" | "light" | "bordered";
type ChipSize = "sm" | "md" | "lg";
export interface ChipProps extends React.ComponentProps<"span"> {
color?: ChipColor;
size?: ChipSize;
variant?: ChipVariant;
}
function colorVariant(color: ChipColor) {
if (color === "danger") {
return "destructive" as const;
}
if (color === "success") {
return "success" as const;
}
if (color === "warning") {
return "warning" as const;
}
if (color === "secondary") {
return "secondary" as const;
}
return "default" as const;
}
export function Chip({
children,
className,
color = "default",
size = "md",
variant = "solid",
...props
}: ChipProps) {
return (
<Badge
className={cn(
variant === "flat" || variant === "light" ? "bg-opacity-15" : "",
variant === "bordered" ? "border-current bg-transparent" : "",
size === "sm" ? "px-2 py-0 text-[10px]" : "",
size === "lg" ? "px-3 py-1 text-sm" : "",
className,
)}
variant={colorVariant(color)}
{...props}
>
{children}
</Badge>
);
}
@@ -0,0 +1,90 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { FieldContainer, type FieldMetaProps } from "./shared";
interface CalendarDateLike {
day: number;
month: number;
year: number;
}
export interface DatePickerProps extends FieldMetaProps {
className?: string;
isDisabled?: boolean;
isRequired?: boolean;
onChange?: (value: CalendarDateLike | null) => void;
showMonthAndYearPickers?: boolean;
value?: CalendarDateLike | null;
}
function formatDateValue(value: CalendarDateLike | null | undefined) {
if (!value) {
return "";
}
const month = String(value.month).padStart(2, "0");
const day = String(value.day).padStart(2, "0");
return `${value.year}-${month}-${day}`;
}
export function DatePicker({
className,
description,
errorMessage,
isDisabled,
isInvalid,
isRequired,
label,
onChange,
value,
}: DatePickerProps) {
const id = React.useId();
return (
<FieldContainer
description={description}
errorMessage={errorMessage}
id={id}
isInvalid={isInvalid}
isRequired={isRequired}
label={label}
>
<Input
aria-invalid={isInvalid}
className={cn(className)}
disabled={isDisabled}
id={id}
required={isRequired}
type="date"
value={formatDateValue(value)}
onChange={(event) => {
if (!onChange) {
return;
}
if (!event.target.value) {
onChange(null);
return;
}
const [yearText, monthText, dayText] = event.target.value.split("-");
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
onChange(null);
return;
}
onChange({ day, month, year });
}}
/>
</FieldContainer>
);
}
@@ -0,0 +1,7 @@
import * as React from "react";
import { Separator } from "@/components/ui/separator";
export function Divider(props: React.ComponentProps<typeof Separator>) {
return <Separator {...props} />;
}
@@ -0,0 +1,81 @@
import * as React from "react";
import {
DropdownMenu as BaseDropdownMenu,
DropdownMenuContent,
DropdownMenuItem as BaseDropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger as BaseDropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
interface DropdownContextValue {
align: "start" | "end";
}
const DropdownContext = React.createContext<DropdownContextValue>({ align: "start" });
export interface DropdownProps {
children: React.ReactNode;
placement?: Placement;
}
export function Dropdown({ children, placement = "bottom-start" }: DropdownProps) {
const align = placement.endsWith("end") ? "end" : "start";
return (
<DropdownContext.Provider value={{ align }}>
<BaseDropdownMenu>{children}</BaseDropdownMenu>
</DropdownContext.Provider>
);
}
export function DropdownTrigger({ children }: { children: React.ReactNode }) {
return <BaseDropdownMenuTrigger asChild>{children}</BaseDropdownMenuTrigger>;
}
export interface DropdownMenuProps {
"aria-label"?: string;
children: React.ReactNode;
}
export function DropdownMenu({ children }: DropdownMenuProps) {
const { align } = React.useContext(DropdownContext);
return <DropdownMenuContent align={align}>{children}</DropdownMenuContent>;
}
export interface DropdownItemProps {
children: React.ReactNode;
className?: string;
color?: "default" | "danger";
onPress?: () => void;
startContent?: React.ReactNode;
}
export function DropdownItem({
children,
className,
color = "default",
onPress,
startContent,
}: DropdownItemProps) {
return (
<BaseDropdownMenuItem
className={cn(color === "danger" ? "text-danger focus:text-danger" : "", className)}
onSelect={(event) => {
event.preventDefault();
onPress?.();
}}
>
{startContent}
{children}
</BaseDropdownMenuItem>
);
}
export { DropdownMenuLabel, DropdownMenuSeparator };
@@ -0,0 +1,157 @@
import * as React from "react";
import { Input as BaseInput } from "@/components/ui/input";
import { Textarea as BaseTextarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { FieldContainer, type FieldMetaProps } from "./shared";
type ClassNameMap = {
base?: string;
description?: string;
errorMessage?: string;
input?: string;
inputWrapper?: string;
label?: string;
};
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
FieldMetaProps {
classNames?: ClassNameMap;
endContent?: React.ReactNode;
isDisabled?: boolean;
size?: "sm" | "md" | "lg";
startContent?: React.ReactNode;
variant?: "flat" | "bordered" | "faded" | "underlined";
}
export interface TextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size">,
FieldMetaProps {
classNames?: ClassNameMap;
isDisabled?: boolean;
maxRows?: number;
minRows?: number;
size?: "sm" | "md" | "lg";
variant?: "flat" | "bordered" | "faded" | "underlined";
}
function inputSizeClass(size: "sm" | "md" | "lg" | undefined) {
if (size === "sm") {
return "h-8 text-xs";
}
if (size === "lg") {
return "h-10 text-base";
}
return "h-9 text-sm";
}
export function Input({
className,
classNames,
description,
endContent,
errorMessage,
id,
isDisabled,
isInvalid,
isRequired,
label,
size,
startContent,
variant,
...props
}: InputProps) {
const generatedId = React.useId();
const resolvedId = id ?? generatedId;
return (
<FieldContainer
className={classNames?.base}
description={description}
errorMessage={errorMessage}
id={resolvedId}
isInvalid={isInvalid}
isRequired={isRequired}
label={label}
>
<div
className={cn(
"relative flex items-center rounded-md",
variant === "bordered" ? "border border-input" : "",
classNames?.inputWrapper,
)}
>
{startContent ? <div className="pl-3 text-default-500">{startContent}</div> : null}
<BaseInput
aria-invalid={isInvalid}
className={cn(
inputSizeClass(size),
variant === "bordered" ? "border-0 shadow-none" : "",
startContent ? "pl-2" : "",
endContent ? "pr-2" : "",
classNames?.input,
className,
)}
disabled={isDisabled}
id={resolvedId}
required={isRequired}
{...props}
/>
{endContent ? <div className="pr-3 text-default-500">{endContent}</div> : null}
</div>
</FieldContainer>
);
}
export function Textarea({
className,
classNames,
description,
errorMessage,
id,
isDisabled,
isInvalid,
isRequired,
label,
maxRows,
minRows,
size,
variant,
...props
}: TextareaProps) {
const generatedId = React.useId();
const resolvedId = id ?? generatedId;
const rows = props.rows ?? minRows ?? 3;
return (
<FieldContainer
className={classNames?.base}
description={description}
errorMessage={errorMessage}
id={resolvedId}
isInvalid={isInvalid}
isRequired={isRequired}
label={label}
>
<BaseTextarea
aria-invalid={isInvalid}
className={cn(
variant === "bordered" ? "border border-input" : "",
size === "sm" ? "text-xs" : "",
size === "lg" ? "text-base" : "",
maxRows ? "max-h-[40vh]" : "",
classNames?.input,
className,
)}
disabled={isDisabled}
id={resolvedId}
required={isRequired}
rows={rows}
{...props}
/>
</FieldContainer>
);
}
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type LinkColor = "default" | "foreground" | "primary";
export interface LinkProps extends React.ComponentProps<"a"> {
color?: LinkColor;
}
function mapColor(color: LinkColor) {
if (color === "primary") {
return "text-primary hover:text-primary/80";
}
if (color === "foreground") {
return "text-foreground hover:text-foreground/80";
}
return "text-default-600 hover:text-default-700";
}
export function Link({ className, color = "default", ...props }: LinkProps) {
return <a className={cn("transition-colors", mapColor(color), className)} {...props} />;
}
@@ -0,0 +1,161 @@
import * as React from "react";
import { Dialog, DialogContent as BaseDialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface DisclosureOptions {
isOpen?: boolean;
}
type OpenChangeHandler = (open?: boolean) => void;
export function useDisclosure(options: DisclosureOptions = {}) {
const [isOpen, setIsOpen] = React.useState(Boolean(options.isOpen));
const onOpen = React.useCallback(() => {
setIsOpen(true);
}, []);
const onClose = React.useCallback(() => {
setIsOpen(false);
}, []);
const onOpenChange = React.useCallback<OpenChangeHandler>((open) => {
setIsOpen((prev) => (typeof open === "boolean" ? open : !prev));
}, []);
return {
isOpen,
onClose,
onOpen,
onOpenChange,
};
}
interface ModalContextValue {
classNames?: Record<string, string>;
onClose: () => void;
}
const ModalContext = React.createContext<ModalContextValue | null>(null);
function useModalContext() {
return React.useContext(ModalContext);
}
type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
function mapSize(size: ModalSize | undefined) {
if (size === "sm") {
return "max-w-md";
}
if (size === "lg") {
return "max-w-2xl";
}
if (size === "xl") {
return "max-w-3xl";
}
if (size === "2xl") {
return "max-w-5xl";
}
if (size === "4xl") {
return "max-w-6xl";
}
if (size === "full") {
return "w-[95vw] max-w-[95vw] h-[95vh] max-h-[95vh]";
}
return "max-w-lg";
}
export interface ModalProps {
backdrop?: "blur" | "opaque" | "transparent";
children: React.ReactNode;
className?: string;
classNames?: Record<string, string>;
isDismissable?: boolean;
isOpen?: boolean;
onClose?: () => void;
onOpenChange?: (open: boolean) => void;
placement?: "center" | "top" | "bottom";
scrollBehavior?: "inside" | "outside";
size?: ModalSize;
}
export function Modal({
children,
classNames,
isOpen = false,
onClose,
onOpenChange,
}: ModalProps) {
const handleOpenChange = (open: boolean) => {
onOpenChange?.(open);
if (!open) {
onClose?.();
}
};
const contextValue = {
classNames,
onClose: () => {
handleOpenChange(false);
},
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<ModalContext.Provider value={contextValue}>{children}</ModalContext.Provider>
</Dialog>
);
}
interface ModalContentProps extends Omit<React.ComponentProps<"div">, "children"> {
children: React.ReactNode | ((onClose: () => void) => React.ReactNode);
scrollBehavior?: "inside" | "outside";
size?: ModalSize;
}
export function ModalContent({
children,
className,
scrollBehavior,
size,
...props
}: ModalContentProps) {
const context = useModalContext();
const renderedChildren =
typeof children === "function" ? children(() => context?.onClose()) : children;
return (
<BaseDialogContent
className={cn(
mapSize(size),
context?.classNames?.base,
scrollBehavior === "outside" ? "max-h-[90vh] overflow-y-auto" : "",
className,
)}
showCloseButton={false}
{...props}
>
{renderedChildren}
</BaseDialogContent>
);
}
export function ModalHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("text-lg font-semibold", className)} {...props} />;
}
export function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("space-y-4", className)} {...props} />;
}
export function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
@@ -0,0 +1,90 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type NavbarPosition = "static" | "sticky";
type MaxWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
export interface NavbarProps extends React.ComponentProps<"nav"> {
height?: string;
maxWidth?: MaxWidth;
position?: NavbarPosition;
}
function maxWidthClass(maxWidth: MaxWidth | undefined) {
if (maxWidth === "sm") {
return "max-w-screen-sm";
}
if (maxWidth === "md") {
return "max-w-screen-md";
}
if (maxWidth === "lg") {
return "max-w-screen-lg";
}
if (maxWidth === "xl") {
return "max-w-screen-xl";
}
if (maxWidth === "2xl") {
return "max-w-screen-2xl";
}
if (maxWidth === "full") {
return "max-w-none";
}
return "max-w-screen-xl";
}
export function Navbar({
children,
className,
height,
maxWidth = "xl",
position = "static",
...props
}: NavbarProps) {
return (
<nav
className={cn(
"w-full border-b border-default-200 bg-white/90 backdrop-blur dark:bg-default-50/60",
position === "sticky" ? "sticky top-0 z-40" : "",
className,
)}
style={height ? { minHeight: height } : undefined}
{...props}
>
<div className={cn("mx-auto flex h-full w-full items-center justify-between px-4", maxWidthClass(maxWidth))}>
{children}
</div>
</nav>
);
}
export interface NavbarContentProps extends React.ComponentProps<"div"> {
justify?: "start" | "center" | "end";
}
export function NavbarContent({
children,
className,
justify = "start",
...props
}: NavbarContentProps) {
return (
<div
className={cn(
"flex flex-1 items-center gap-2",
justify === "center" ? "justify-center" : "",
justify === "end" ? "justify-end" : "",
justify === "start" ? "justify-start" : "",
className,
)}
{...props}
>
{children}
</div>
);
}
export function NavbarBrand({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex items-center", className)} {...props} />;
}
@@ -0,0 +1,57 @@
import * as React from "react";
import { Progress as BaseProgress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
type ProgressColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
export interface ProgressProps {
"aria-label"?: string;
className?: string;
color?: ProgressColor;
label?: React.ReactNode;
showValueLabel?: boolean;
size?: "sm" | "md" | "lg";
value?: number;
}
function indicatorClassName(color: ProgressColor) {
if (color === "danger") {
return "bg-danger";
}
if (color === "success") {
return "bg-success";
}
if (color === "warning") {
return "bg-warning";
}
return "bg-primary";
}
export function Progress({
"aria-label": ariaLabel,
className,
color = "primary",
label,
showValueLabel,
size = "md",
value = 0,
}: ProgressProps) {
return (
<div className={cn("w-full space-y-1", className)}>
{label || showValueLabel ? (
<div className="flex items-center justify-between text-xs text-default-500">
<span>{label}</span>
{showValueLabel ? <span>{Math.round(value)}%</span> : null}
</div>
) : null}
<BaseProgress
aria-label={ariaLabel}
className={cn(size === "sm" ? "h-1.5" : "", size === "lg" ? "h-3" : "")}
indicatorClassName={indicatorClassName(color)}
value={value}
/>
</div>
);
}
@@ -0,0 +1,63 @@
import * as React from "react";
import { RadioGroup as BaseRadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface RadioContextValue {
name: string;
}
const RadioContext = React.createContext<RadioContextValue>({ name: "radio-group" });
export interface RadioGroupProps {
children: React.ReactNode;
label?: React.ReactNode;
onValueChange?: (value: string) => void;
orientation?: "horizontal" | "vertical";
value?: string;
}
export function RadioGroup({
children,
label,
onValueChange,
orientation = "vertical",
value,
}: RadioGroupProps) {
const generatedName = React.useId();
return (
<div className="space-y-2">
{label ? <p className="text-sm font-medium">{label}</p> : null}
<RadioContext.Provider value={{ name: generatedName }}>
<BaseRadioGroup
className={cn(
orientation === "horizontal" ? "flex flex-wrap items-center gap-4" : "grid gap-3",
)}
value={value}
onValueChange={onValueChange}
>
{children}
</BaseRadioGroup>
</RadioContext.Provider>
</div>
);
}
export interface RadioProps {
children?: React.ReactNode;
value: string;
}
export function Radio({ children, value }: RadioProps) {
const context = React.useContext(RadioContext);
const id = `${context.name}-${value}`;
return (
<div className="flex items-center gap-2">
<RadioGroupItem id={id} value={value} />
<Label htmlFor={id}>{children}</Label>
</div>
);
}
@@ -0,0 +1,223 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { FieldContainer, extractText, type FieldMetaProps } from "./shared";
type SelectionMode = "single" | "multiple";
type SelectionValue = Iterable<React.Key> | Set<React.Key> | Array<React.Key>;
interface OptionItem {
disabled?: boolean;
key: string;
label: string;
}
interface ClassNameMap {
base?: string;
trigger?: string;
[key: string]: string | undefined;
}
export interface SelectProps<T = unknown> extends FieldMetaProps {
children?: React.ReactNode | ((item: T) => React.ReactNode);
className?: string;
classNames?: ClassNameMap;
disabledKeys?: SelectionValue;
isDisabled?: boolean;
items?: Iterable<T>;
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
onClick?: (event: React.MouseEvent<HTMLSelectElement>) => void;
onSelectionChange?: (keys: Set<React.Key>) => void;
placeholder?: string;
selectedKeys?: SelectionValue;
selectionMode?: SelectionMode;
size?: "sm" | "md" | "lg";
variant?: string;
}
export interface SelectItemProps {
children?: React.ReactNode;
description?: React.ReactNode;
textValue?: string;
}
export function SelectItem(_props: SelectItemProps) {
return null;
}
SelectItem.displayName = "HeroSelectItem";
function toSet(value?: SelectionValue) {
if (!value) {
return new Set<string>();
}
return new Set(Array.from(value).map((item) => String(item)));
}
function flattenOptionsFromNode(node: React.ReactNode, options: OptionItem[]) {
React.Children.forEach(node, (child, index) => {
if (child === null || child === undefined || typeof child === "boolean") {
return;
}
if (Array.isArray(child)) {
flattenOptionsFromNode(child, options);
return;
}
if (React.isValidElement(child)) {
if (child.type === React.Fragment) {
flattenOptionsFromNode(child.props.children, options);
return;
}
if (child.type === SelectItem) {
const key = child.key ? String(child.key) : String(index);
const props = child.props as SelectItemProps;
options.push({
key,
label: props.textValue ?? extractText(props.children) ?? key,
});
return;
}
}
});
}
function getOptions<T>(
children: React.ReactNode | ((item: T) => React.ReactNode) | undefined,
items: Iterable<T> | undefined,
) {
const options: OptionItem[] = [];
if (typeof children === "function" && items) {
Array.from(items).forEach((item, index) => {
const rendered = children(item);
if (React.isValidElement(rendered) && rendered.type === SelectItem) {
const key = rendered.key ? String(rendered.key) : String(index);
const props = rendered.props as SelectItemProps;
options.push({
key,
label: props.textValue ?? extractText(props.children) ?? key,
});
}
});
return options;
}
if (typeof children !== "function") {
flattenOptionsFromNode(children, options);
}
return options;
}
function sizeClass(size: SelectProps["size"]) {
if (size === "sm") {
return "h-8 text-xs";
}
if (size === "lg") {
return "h-10 text-base";
}
return "h-9 text-sm";
}
export function Select<T>({
children,
className,
classNames,
description,
disabledKeys,
errorMessage,
isDisabled,
isInvalid,
isRequired,
items,
label,
onChange,
onClick,
onSelectionChange,
placeholder,
selectedKeys,
selectionMode = "single",
size,
}: SelectProps<T>) {
const generatedId = React.useId();
const options = React.useMemo(() => getOptions(children, items), [children, items]);
const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]);
const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]);
const selectedArray = Array.from(selected);
const singleValue = selectedArray[0] ?? "";
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
onChange?.(event);
if (!onSelectionChange) {
return;
}
if (selectionMode === "multiple") {
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
onSelectionChange(new Set(values));
return;
}
if (!event.target.value) {
onSelectionChange(new Set());
return;
}
onSelectionChange(new Set([event.target.value]));
};
return (
<FieldContainer
className={classNames?.base}
description={description}
errorMessage={errorMessage}
id={generatedId}
isInvalid={isInvalid}
isRequired={isRequired}
label={label}
>
<select
className={cn(
"w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClass(size),
selectionMode === "multiple" ? "min-h-32" : "",
classNames?.trigger,
className,
)}
disabled={isDisabled}
id={generatedId}
multiple={selectionMode === "multiple"}
required={isRequired}
value={selectionMode === "multiple" ? selectedArray : singleValue}
onClick={onClick}
onChange={handleChange}
>
{selectionMode === "single" ? (
<option value="">{placeholder ?? "请选择"}</option>
) : null}
{options.map((option) => (
<option key={option.key} disabled={disabled.has(option.key)} value={option.key}>
{option.label}
</option>
))}
</select>
</FieldContainer>
);
}
@@ -0,0 +1,62 @@
import * as React from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
export interface FieldMetaProps {
label?: React.ReactNode;
description?: React.ReactNode;
errorMessage?: React.ReactNode;
isInvalid?: boolean;
isRequired?: boolean;
}
interface FieldContainerProps extends FieldMetaProps {
className?: string;
id?: string;
children: React.ReactNode;
}
export function FieldContainer({
children,
className,
description,
errorMessage,
id,
isInvalid,
isRequired,
label,
}: FieldContainerProps) {
return (
<div className={cn("w-full space-y-1.5", className)}>
{label ? (
<Label htmlFor={id}>
{label}
{isRequired ? <span className="ml-1 text-danger">*</span> : null}
</Label>
) : null}
{children}
{description ? <p className="text-xs text-default-500">{description}</p> : null}
{isInvalid && errorMessage ? (
<p className="text-xs text-danger">{errorMessage}</p>
) : null}
</div>
);
}
export function extractText(content: React.ReactNode): string {
if (content === null || content === undefined || typeof content === "boolean") {
return "";
}
if (typeof content === "string" || typeof content === "number") {
return String(content);
}
if (Array.isArray(content)) {
return content.map((item) => extractText(item)).join("").trim();
}
if (React.isValidElement(content)) {
return extractText(content.props.children);
}
return "";
}
@@ -0,0 +1,31 @@
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
type SpinnerSize = "sm" | "md" | "lg";
export interface SpinnerProps extends React.ComponentProps<"div"> {
label?: string;
size?: SpinnerSize;
}
function iconSize(size: SpinnerSize | undefined) {
if (size === "sm") {
return "h-4 w-4";
}
if (size === "lg") {
return "h-7 w-7";
}
return "h-5 w-5";
}
export function Spinner({ className, label, size = "md", ...props }: SpinnerProps) {
return (
<div className={cn("inline-flex items-center gap-2", className)} {...props}>
<Loader2Icon className={cn("animate-spin text-default-500", iconSize(size))} />
{label ? <span className="text-sm text-default-500">{label}</span> : null}
</div>
);
}
@@ -0,0 +1,31 @@
import * as React from "react";
import { Switch as BaseSwitch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
export interface SwitchProps extends Omit<React.ComponentProps<typeof BaseSwitch>, "checked" | "onCheckedChange"> {
classNames?: Record<string, string>;
color?: string;
isDisabled?: boolean;
isSelected?: boolean;
onValueChange?: (value: boolean) => void;
size?: string;
}
export function Switch({
className,
isDisabled,
isSelected,
onValueChange,
...props
}: SwitchProps) {
return (
<BaseSwitch
checked={Boolean(isSelected)}
className={cn(className)}
disabled={isDisabled}
onCheckedChange={onValueChange}
{...props}
/>
);
}
@@ -0,0 +1,11 @@
import * as React from "react";
export interface HeroUIProviderProps {
children: React.ReactNode;
navigate?: unknown;
useHref?: unknown;
}
export function HeroUIProvider({ children }: HeroUIProviderProps) {
return <>{children}</>;
}
@@ -0,0 +1,140 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface TableClassNames {
th?: string;
wrapper?: string;
}
interface TableStyleContextValue {
thClassName?: string;
}
const TableStyleContext = React.createContext<TableStyleContextValue>({});
export interface TableProps extends React.ComponentProps<"table"> {
classNames?: TableClassNames;
}
export function Table({ children, className, classNames, ...props }: TableProps) {
return (
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
<div className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}>
<table className={cn("w-full text-sm", className)} {...props}>
{children}
</table>
</div>
</TableStyleContext.Provider>
);
}
export function TableHeader({ children, className, ...props }: React.ComponentProps<"thead">) {
const childArray = React.Children.toArray(children);
const hasRow = childArray.some(
(child) => React.isValidElement(child) && child.type === TableRow,
);
return (
<thead className={cn("border-b bg-default-50/60", className)} {...props}>
{hasRow ? children : <TableRow>{children}</TableRow>}
</thead>
);
}
interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "children"> {
children?: React.ReactNode | ((item: T) => React.ReactNode);
emptyContent?: React.ReactNode;
isLoading?: boolean;
items?: T[];
loadingContent?: React.ReactNode;
}
export function TableBody<T>({
children,
className,
emptyContent,
isLoading,
items,
loadingContent,
...props
}: TableBodyProps<T>) {
if (isLoading) {
return (
<tbody className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{loadingContent ?? "加载中..."}
</td>
</tr>
</tbody>
);
}
if (items && typeof children === "function") {
if (items.length === 0) {
return (
<tbody className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{emptyContent ?? "暂无数据"}
</td>
</tr>
</tbody>
);
}
return (
<tbody className={className} {...props}>
{items.map((item) => {
const key =
typeof item === "object" && item !== null && "id" in item
? String((item as { id: React.Key }).id)
: JSON.stringify(item);
return <React.Fragment key={key}>{children(item)}</React.Fragment>;
})}
</tbody>
);
}
const staticChildren =
typeof children === "function" ? [] : React.Children.toArray(children);
if (staticChildren.length === 0) {
return (
<tbody className={className} {...props}>
<tr>
<td className="p-4 text-center text-default-500" colSpan={999}>
{emptyContent ?? "暂无数据"}
</td>
</tr>
</tbody>
);
}
return (
<tbody className={className} {...props}>
{typeof children === "function" ? null : children}
</tbody>
);
}
export function TableColumn({ className, ...props }: React.ComponentProps<"th">) {
const { thClassName } = React.useContext(TableStyleContext);
return (
<th
className={cn("px-3 py-2 text-left font-medium text-default-600", thClassName, className)}
{...props}
/>
);
}
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return <tr className={cn("border-b last:border-b-0", className)} {...props} />;
}
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
}
@@ -0,0 +1,87 @@
import * as React from "react";
import {
Tabs as BaseTabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
interface TabDefinition {
content: React.ReactNode;
key: string;
title: React.ReactNode;
}
export interface TabsProps {
"aria-label"?: string;
children: React.ReactNode;
disableCursorAnimation?: boolean;
onSelectionChange?: (key: React.Key) => void;
selectedKey?: React.Key;
}
export interface TabProps {
children: React.ReactNode;
title: React.ReactNode;
}
export function Tab(_props: TabProps) {
return null;
}
Tab.displayName = "HeroTab";
function parseTabs(children: React.ReactNode) {
const tabs: TabDefinition[] = [];
React.Children.forEach(children, (child, index) => {
if (!React.isValidElement(child) || child.type !== Tab) {
return;
}
const key = child.key ? String(child.key) : `tab-${index}`;
const props = child.props as TabProps;
tabs.push({
content: props.children,
key,
title: props.title,
});
});
return tabs;
}
export function Tabs({ children, onSelectionChange, selectedKey }: TabsProps) {
const tabs = React.useMemo(() => parseTabs(children), [children]);
const fallback = tabs[0]?.key ?? "";
const value = selectedKey ? String(selectedKey) : fallback;
return (
<BaseTabs
value={value}
onValueChange={(nextValue) => {
onSelectionChange?.(nextValue);
}}
>
<TabsList
className="grid w-full"
style={{
gridTemplateColumns: `repeat(${Math.max(tabs.length, 1)}, minmax(0, 1fr))`,
}}
>
{tabs.map((item) => (
<TabsTrigger key={item.key} value={item.key}>
{item.title}
</TabsTrigger>
))}
</TabsList>
{tabs.map((item) => (
<TabsContent key={item.key} value={item.key}>
{item.content}
</TabsContent>
))}
</BaseTabs>
);
}
@@ -0,0 +1,59 @@
import * as React from "react";
type ThemeMode = "light" | "dark";
const STORAGE_KEY = "flvx:theme";
function resolveInitialTheme(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
if (fromStorage === "dark" || fromStorage === "light") {
return fromStorage;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
let currentTheme: ThemeMode = resolveInitialTheme();
const listeners = new Set<(theme: ThemeMode) => void>();
function broadcast(theme: ThemeMode) {
currentTheme = theme;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, theme);
}
listeners.forEach((listener) => {
listener(theme);
});
}
export function useTheme() {
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
React.useEffect(() => {
const listener = (nextTheme: ThemeMode) => {
setThemeState(nextTheme);
};
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, []);
const setTheme = React.useCallback((nextTheme: string) => {
if (nextTheme !== "dark" && nextTheme !== "light") {
return;
}
broadcast(nextTheme);
}, []);
return {
setTheme,
theme,
};
}
+158 -63
View File
@@ -1,71 +1,129 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "./tailwind-theme.pcss";
/* 移动端视口高度优化 */
html, body {
height: 100%;
overflow-x: hidden;
}
/* 现代浏览器使用 100dvh,避免地址栏收起/展开导致的布局跳动 */
@supports (height: 100dvh) {
.h-screen {
height: 100dvh;
}
.min-h-screen {
min-height: 100dvh;
}
}
/* 解决移动端100vh问题 */
@supports (-webkit-touch-callout: none) {
.h-screen {
height: -webkit-fill-available;
}
.min-h-screen {
min-height: -webkit-fill-available;
}
}
/* 移动端滚动优化 */
@media (max-width: 768px) {
* {
-webkit-overflow-scrolling: touch;
}
body {
overscroll-behavior-y: none;
}
}
/* 安全区域变量与工具类(适配刘海/底部手势条) */
:root { :root {
--safe-area-top: env(safe-area-inset-top, 0px); --safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px); --safe-area-bottom: env(safe-area-inset-bottom, 0px);
--background: #f6f7fb;
--foreground: #111827;
--border: #e5e7eb;
--input: #d1d5db;
--ring: #93c5fd;
--content1: #ffffff;
--divider: #e5e7eb;
--default-50: #f9fafb;
--default-100: #f3f4f6;
--default-200: #e5e7eb;
--default-300: #d1d5db;
--default-400: #9ca3af;
--default-500: #6b7280;
--default-600: #4b5563;
--default-700: #374151;
--default-800: #1f2937;
--default-900: #111827;
--primary-50: #eff6ff;
--primary-100: #dbeafe;
--primary-200: #bfdbfe;
--primary-300: #93c5fd;
--primary-400: #60a5fa;
--primary-500: #3b82f6;
--primary-600: #2563eb;
--primary-700: #1d4ed8;
--primary-800: #1e40af;
--primary-900: #1e3a8a;
--secondary-50: #eef2ff;
--secondary-100: #e0e7ff;
--secondary-200: #c7d2fe;
--secondary-300: #a5b4fc;
--secondary-400: #818cf8;
--secondary-500: #6366f1;
--secondary-600: #4f46e5;
--secondary-700: #4338ca;
--secondary-800: #3730a3;
--secondary-900: #312e81;
--danger-50: #fef2f2;
--danger-100: #fee2e2;
--danger-200: #fecaca;
--danger-300: #fca5a5;
--danger-400: #f87171;
--danger-500: #ef4444;
--danger-600: #dc2626;
--danger-700: #b91c1c;
--danger-800: #991b1b;
--danger-900: #7f1d1d;
--success-50: #f0fdf4;
--success-100: #dcfce7;
--success-200: #bbf7d0;
--success-300: #86efac;
--success-400: #4ade80;
--success-500: #22c55e;
--success-600: #16a34a;
--success-700: #15803d;
--success-800: #166534;
--success-900: #14532d;
--warning-50: #fffbeb;
--warning-100: #fef3c7;
--warning-200: #fde68a;
--warning-300: #fcd34d;
--warning-400: #fbbf24;
--warning-500: #f59e0b;
--warning-600: #d97706;
--warning-700: #b45309;
--warning-800: #92400e;
--warning-900: #78350f;
--primary: #2563eb;
--primary-foreground: #ffffff;
--secondary: #6366f1;
--secondary-foreground: #ffffff;
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
} }
[data-slot="input-wrapper"] { .dark {
box-shadow: none; --background: #0b1020;
--foreground: #f3f4f6;
--border: #334155;
--input: #475569;
--ring: #60a5fa;
--content1: #111827;
--divider: #334155;
--default-50: #0f172a;
--default-100: #1e293b;
--default-200: #334155;
--default-300: #475569;
--default-400: #64748b;
--default-500: #94a3b8;
--default-600: #cbd5e1;
--default-700: #e2e8f0;
--default-800: #f1f5f9;
--default-900: #f8fafc;
--primary: #3b82f6;
--secondary: #818cf8;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
} }
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]), html,
[data-slot="input-wrapper"][data-focus="true"]:not([data-invalid="true"]), body {
[data-slot="input-wrapper"][data-focused="true"]:not([data-invalid="true"]), background: var(--background);
button[data-slot="trigger"][data-focus="true"]:not([data-invalid="true"]), color: var(--foreground);
button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) { height: 100%;
border-color: var(--heroui-default-200, #e5e7eb) !important; overflow-x: hidden;
outline: none !important;
outline-offset: 0 !important;
box-shadow: none !important;
}
[data-slot="input-wrapper"] input:focus,
[data-slot="input-wrapper"] textarea:focus {
outline: none !important;
box-shadow: none !important;
border-color: transparent !important;
} }
.safe-top { .safe-top {
@@ -76,8 +134,46 @@ button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
padding-bottom: var(--safe-area-bottom); padding-bottom: var(--safe-area-bottom);
} }
.text-small {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-tiny {
font-size: 0.75rem;
line-height: 1rem;
}
@supports (height: 100dvh) {
.h-screen {
height: 100dvh;
}
.min-h-screen {
min-height: 100dvh;
}
}
@supports (-webkit-touch-callout: none) {
.h-screen {
height: -webkit-fill-available;
}
.min-h-screen {
min-height: -webkit-fill-available;
}
}
@media (max-width: 768px) {
* {
-webkit-overflow-scrolling: touch;
}
body {
overscroll-behavior-y: none;
}
}
/* 验证码弹层动画 */
.captcha-modal-enter { .captcha-modal-enter {
animation: captchaModalIn 0.3s ease-out forwards; animation: captchaModalIn 0.3s ease-out forwards;
} }
@@ -87,13 +183,13 @@ button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
opacity: 0; opacity: 0;
transform: scale(0.9) translateY(-20px); transform: scale(0.9) translateY(-20px);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
} }
} }
/* 验证码弹层背景渐入 */
.captcha-backdrop-enter { .captcha-backdrop-enter {
animation: backdropFadeIn 0.3s ease-out forwards; animation: backdropFadeIn 0.3s ease-out forwards;
} }
@@ -102,9 +198,8 @@ button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
} }
@config "../../tailwind.config.js"
@@ -0,0 +1,82 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-content1: var(--content1);
--color-divider: var(--divider);
--color-default-50: var(--default-50);
--color-default-100: var(--default-100);
--color-default-200: var(--default-200);
--color-default-300: var(--default-300);
--color-default-400: var(--default-400);
--color-default-500: var(--default-500);
--color-default-600: var(--default-600);
--color-default-700: var(--default-700);
--color-default-800: var(--default-800);
--color-default-900: var(--default-900);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary-50: var(--secondary-50);
--color-secondary-100: var(--secondary-100);
--color-secondary-200: var(--secondary-200);
--color-secondary-300: var(--secondary-300);
--color-secondary-400: var(--secondary-400);
--color-secondary-500: var(--secondary-500);
--color-secondary-600: var(--secondary-600);
--color-secondary-700: var(--secondary-700);
--color-secondary-800: var(--secondary-800);
--color-secondary-900: var(--secondary-900);
--color-danger: var(--danger);
--color-danger-50: var(--danger-50);
--color-danger-100: var(--danger-100);
--color-danger-200: var(--danger-200);
--color-danger-300: var(--danger-300);
--color-danger-400: var(--danger-400);
--color-danger-500: var(--danger-500);
--color-danger-600: var(--danger-600);
--color-danger-700: var(--danger-700);
--color-danger-800: var(--danger-800);
--color-danger-900: var(--danger-900);
--color-success: var(--success);
--color-success-50: var(--success-50);
--color-success-100: var(--success-100);
--color-success-200: var(--success-200);
--color-success-300: var(--success-300);
--color-success-400: var(--success-400);
--color-success-500: var(--success-500);
--color-success-600: var(--success-600);
--color-success-700: var(--success-700);
--color-success-800: var(--success-800);
--color-success-900: var(--success-900);
--color-warning: var(--warning);
--color-warning-50: var(--warning-50);
--color-warning-100: var(--warning-100);
--color-warning-200: var(--warning-200);
--color-warning-300: var(--warning-300);
--color-warning-400: var(--warning-400);
--color-warning-500: var(--warning-500);
--color-warning-600: var(--warning-600);
--color-warning-700: var(--warning-700);
--color-warning-800: var(--warning-800);
--color-warning-900: var(--warning-900);
}
+7
View File
@@ -20,6 +20,12 @@ export interface User {
outFlow?: number; // 上传流量(字节) outFlow?: number; // 上传流量(字节)
} }
export interface UserGroup {
id: number;
name: string;
status: number;
}
export interface UserForm { export interface UserForm {
id?: number; id?: number;
name?: string; name?: string;
@@ -30,6 +36,7 @@ export interface UserForm {
num: number; num: number;
expTime: Date | null; expTime: Date | null;
flowResetTime: number; flowResetTime: number;
groupIds?: number[];
} }
export interface UserTunnel { export interface UserTunnel {
+3 -1
View File
@@ -1,7 +1,9 @@
import { clearSession } from "@/utils/session";
/** /**
* 安全退出登录函数 * 安全退出登录函数
* 清除登录相关数据,但保留用户偏好设置(如主题) * 清除登录相关数据,但保留用户偏好设置(如主题)
*/ */
export const safeLogout = () => { export const safeLogout = () => {
localStorage.clear(); clearSession();
}; };
+57
View File
@@ -0,0 +1,57 @@
const parseOrderIds = (rawValue: string | null): number[] | null => {
if (!rawValue) {
return null;
}
try {
const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) {
return null;
}
return parsed
.map((id) => Number(id))
.filter((id) => Number.isInteger(id) && id >= 0);
} catch {
return null;
}
};
export const loadStoredOrder = (
storageKey: string,
sourceIds: number[],
): number[] => {
const parsed = parseOrderIds(localStorage.getItem(storageKey));
if (!parsed || parsed.length === 0) {
return sourceIds;
}
const idSet = new Set(sourceIds);
const validIds: number[] = [];
parsed.forEach((id) => {
if (idSet.has(id) && !validIds.includes(id)) {
validIds.push(id);
}
});
if (validIds.length === 0) {
return sourceIds;
}
sourceIds.forEach((id) => {
if (!validIds.includes(id)) {
validIds.push(id);
}
});
return validIds;
};
export const saveOrder = (storageKey: string, ids: number[]): void => {
try {
localStorage.setItem(storageKey, JSON.stringify(ids));
} catch {}
};
+90
View File
@@ -0,0 +1,90 @@
export const SESSION_STORAGE_KEYS = {
token: "token",
roleId: "role_id",
name: "name",
admin: "admin",
} as const;
export interface SessionData {
token: string | null;
roleId: number | null;
name: string | null;
isAdmin: boolean;
}
export interface LoginSessionPayload {
token: string;
role_id: number;
name: string;
}
const SESSION_EVENT_NAME = "sessionUpdated";
const parseRoleId = (value: string | null): number | null => {
if (value === null) {
return null;
}
const roleId = Number.parseInt(value, 10);
return Number.isNaN(roleId) ? null : roleId;
};
export const getToken = (): string | null => {
return localStorage.getItem(SESSION_STORAGE_KEYS.token);
};
export const getRoleId = (): number | null => {
return parseRoleId(localStorage.getItem(SESSION_STORAGE_KEYS.roleId));
};
export const getSessionName = (): string | null => {
return localStorage.getItem(SESSION_STORAGE_KEYS.name);
};
export const getAdminFlag = (): boolean => {
const adminValue = localStorage.getItem(SESSION_STORAGE_KEYS.admin);
if (adminValue !== null) {
return adminValue === "true";
}
const roleId = getRoleId();
const isAdmin = roleId === 0;
if (roleId !== null) {
localStorage.setItem(SESSION_STORAGE_KEYS.admin, String(isAdmin));
}
return isAdmin;
};
export const readSession = (): SessionData => {
return {
token: getToken(),
roleId: getRoleId(),
name: getSessionName(),
isAdmin: getAdminFlag(),
};
};
export const writeLoginSession = (payload: LoginSessionPayload): void => {
localStorage.setItem(SESSION_STORAGE_KEYS.token, payload.token);
localStorage.setItem(SESSION_STORAGE_KEYS.roleId, String(payload.role_id));
localStorage.setItem(SESSION_STORAGE_KEYS.name, payload.name);
localStorage.setItem(
SESSION_STORAGE_KEYS.admin,
String(payload.role_id === 0),
);
window.dispatchEvent(new Event(SESSION_EVENT_NAME));
};
export const clearSession = (): void => {
localStorage.removeItem(SESSION_STORAGE_KEYS.token);
localStorage.removeItem(SESSION_STORAGE_KEYS.roleId);
localStorage.removeItem(SESSION_STORAGE_KEYS.name);
localStorage.removeItem(SESSION_STORAGE_KEYS.admin);
window.dispatchEvent(new Event(SESSION_EVENT_NAME));
};
export const SESSION_UPDATED_EVENT = SESSION_EVENT_NAME;

Some files were not shown because too many files have changed in this diff Show More