diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index bec3b3b..e010fda 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -89,6 +89,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/user/update", h.userUpdate) mux.HandleFunc("/api/v1/user/delete", h.userDelete) 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/list", h.getConfigs) mux.HandleFunc("/api/v1/config/update", h.updateConfigs) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 815ab0d..de3f506 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -60,10 +60,21 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) { roleID := 1 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())) 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()) } @@ -133,9 +144,36 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) { } 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()) } +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) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) @@ -3204,3 +3242,17 @@ func randomToken(n int) string { } 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 +} diff --git a/go-backend/internal/store/repo/repository_groups.go b/go-backend/internal/store/repo/repository_groups.go index 24683b3..88ee6b8 100644 --- a/go-backend/internal/store/repo/repository_groups.go +++ b/go-backend/internal/store/repo/repository_groups.go @@ -50,8 +50,17 @@ func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][ return result, err } -// ListGroupPermissionPairsByTunnelGroup returns [userGroupID, tunnelGroupID] pairs -// for all group permissions associated with a tunnel group. +func (r *Repository) GetUserGroupIDsByUserID(userID int64) ([]int64, error) { + 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) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 21e2817..6332e1e 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -34,9 +34,9 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool 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 { - return errors.New("repository not initialized") + return 0, errors.New("repository not initialized") } user := model.User{ User: username, @@ -52,7 +52,10 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, 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) { @@ -1406,3 +1409,70 @@ func parsePortRangeSpec(input string) []int { sort.Ints(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 +} diff --git a/vite-frontend/components.json b/vite-frontend/components.json new file mode 100644 index 0000000..3154a3d --- /dev/null +++ b/vite-frontend/components.json @@ -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" +} diff --git a/vite-frontend/docs/frontend-refactor-plan-2026-02-18.md b/vite-frontend/docs/frontend-refactor-plan-2026-02-18.md new file mode 100644 index 0000000..8af6ca8 --- /dev/null +++ b/vite-frontend/docs/frontend-refactor-plan-2026-02-18.md @@ -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` 与必要诊断校验 diff --git a/vite-frontend/docs/shadcn-ui-full-migration-2026-02-19.md b/vite-frontend/docs/shadcn-ui-full-migration-2026-02-19.md new file mode 100644 index 0000000..897ed55 --- /dev/null +++ b/vite-frontend/docs/shadcn-ui-full-migration-2026-02-19.md @@ -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` 噪音) diff --git a/vite-frontend/package.json b/vite-frontend/package.json index aea3441..e695e36 100644 --- a/vite-frontend/package.json +++ b/vite-frontend/package.json @@ -13,45 +13,31 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@heroui/accordion": "^2.2.21", - "@heroui/alert": "^2.2.24", - "@heroui/autocomplete": "^2.3.25", - "@heroui/avatar": "^2.2.19", - "@heroui/badge": "^2.2.15", - "@heroui/button": "2.2.23", - "@heroui/card": "^2.2.22", - "@heroui/chip": "^2.2.19", - "@heroui/code": "2.2.17", - "@heroui/date-picker": "^2.3.22", - "@heroui/dropdown": "2.3.23", - "@heroui/image": "^2.2.15", - "@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", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^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-types/shared": "3.30.0", "@tailwindcss/postcss": "4.1.11", "@tailwindcss/vite": "^4.1.18", "@types/react-beautiful-dnd": "^13.1.8", "axios": "^1.11.0", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", "framer-motion": "11.18.2", + "lucide-react": "^0.542.0", "react": "18.3.1", "react-beautiful-dnd": "^13.1.1", "react-dom": "18.3.1", @@ -60,8 +46,10 @@ "react-router-dom": "6.30.3", "recharts": "^3.1.1", "sonner": "^2.0.6", + "tailwind-merge": "^2.5.5", "tailwind-variants": "1.0.0", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.11", + "tw-animate-css": "^1.3.0" }, "devDependencies": { "@eslint/compat": "1.2.8", diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index d7c1528..a882f13 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -1,5 +1,5 @@ import { Route, Routes, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import IndexPage from "@/pages/index"; import ChangePasswordPage from "@/pages/change-password"; @@ -19,50 +19,7 @@ import H5Layout from "@/layouts/h5"; import H5SimpleLayout from "@/layouts/h5-simple"; import { isLoggedIn } from "@/utils/auth"; import { siteConfig } from "@/config/site"; - -// 检测是否为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; -}; +import { useH5Mode } from "@/hooks/useH5Mode"; // 简化的路由保护组件 - 使用 React Router 导航避免循环 const ProtectedRoute = ({ @@ -99,15 +56,8 @@ const ProtectedRoute = ({ } // 根据模式和页面类型选择布局 - let Layout; - - if (isH5 && useSimpleLayout) { - Layout = H5SimpleLayout; - } else if (isH5) { - Layout = H5Layout; - } else { - Layout = AdminLayout; - } + const Layout = + isH5 && useSimpleLayout ? H5SimpleLayout : isH5 ? H5Layout : AdminLayout; return {children}; }; diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts new file mode 100644 index 0000000..11d861f --- /dev/null +++ b/vite-frontend/src/api/error-message.ts @@ -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; +}; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index dc33537..bea1164 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -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 Network from "./network"; @@ -20,20 +49,25 @@ export const login = (data: LoginData) => Network.post("/user/login", data); // 用户CRUD操作 - 全部使用POST请求 -export const createUser = (data: any) => Network.post("/user/create", data); -export const getAllUsers = (pageData: any = {}) => - Network.post("/user/list", pageData); -export const updateUser = (data: any) => Network.post("/user/update", data); +export const createUser = (data: UserMutationPayload) => + Network.post("/user/create", data); +export const getAllUsers = (pageData: UserListQuery = {}) => + Network.post("/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 getUserPackageInfo = () => Network.post("/user/package"); +export const getUserPackageInfo = () => + Network.post("/user/package"); // 节点CRUD操作 - 全部使用POST请求 -export const createNode = (data: any) => Network.post("/node/create", data); -export const getNodeList = () => Network.post("/node/list"); -export const updateNode = (data: any) => Network.post("/node/update", data); +export const createNode = (data: NodeMutationPayload) => + Network.post("/node/create", data); +export const getNodeList = () => Network.post("/node/list"); +export const updateNode = (data: NodeMutationPayload) => + Network.post("/node/update", data); export const deleteNode = (id: number) => Network.post("/node/delete", { id }); export const getNodeInstallCommand = (id: number) => - Network.post("/node/install", { id }); + Network.post("/node/install", { id }); export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }>; }) => Network.post("/node/update-order", data); @@ -55,44 +89,50 @@ export const batchUpgradeNodes = (ids: number[], version?: string) => { ids, version: version || "" }, { timeout: 15 * 60 * 1000 }, ); -export const getNodeReleases = () => Network.post("/node/releases"); +export const getNodeReleases = () => + Network.post("/node/releases"); export const rollbackNode = (id: number) => Network.post("/node/rollback", { id }); // 隧道CRUD操作 - 全部使用POST请求 -export const createTunnel = (data: any) => Network.post("/tunnel/create", data); -export const getTunnelList = () => Network.post("/tunnel/list"); +export const createTunnel = (data: TunnelMutationPayload) => + Network.post("/tunnel/create", data); +export const getTunnelList = () => + Network.post("/tunnel/list"); export const getTunnelById = (id: number) => - Network.post("/tunnel/get", { id }); -export const updateTunnel = (data: any) => Network.post("/tunnel/update", data); + Network.post("/tunnel/get", { id }); +export const updateTunnel = (data: TunnelMutationPayload) => + Network.post("/tunnel/update", data); export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); export const diagnoseTunnel = (tunnelId: number) => - Network.post("/tunnel/diagnose", { tunnelId }); + Network.post("/tunnel/diagnose", { tunnelId }); export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: number }>; }) => Network.post("/tunnel/update-order", data); // 用户隧道权限管理操作 - 全部使用POST请求 -export const assignUserTunnel = (data: any) => +export const assignUserTunnel = (data: UserTunnelAssignPayload) => Network.post("/tunnel/user/assign", data); export const batchAssignUserTunnel = (data: { userId: number; tunnels: Array<{ tunnelId: number; speedId?: number | null }>; }) => Network.post("/tunnel/user/batch-assign", data); -export const getUserTunnelList = (queryData: any = {}) => - Network.post("/tunnel/user/list", queryData); -export const removeUserTunnel = (params: any) => +export const getUserTunnelList = (queryData: UserTunnelListQuery = {}) => + Network.post("/tunnel/user/list", queryData); +export const removeUserTunnel = (params: UserTunnelRemovePayload) => Network.post("/tunnel/user/remove", params); -export const updateUserTunnel = (data: any) => +export const updateUserTunnel = (data: UserTunnelAssignPayload) => Network.post("/tunnel/user/update", data); -export const userTunnel = () => Network.post("/tunnel/user/tunnel"); +export const userTunnel = () => + Network.post("/tunnel/user/tunnel"); // 转发CRUD操作 - 全部使用POST请求 -export const createForward = (data: any) => +export const createForward = (data: ForwardMutationPayload) => Network.post("/forward/create", data); -export const getForwardList = () => Network.post("/forward/list"); -export const updateForward = (data: any) => +export const getForwardList = () => + Network.post("/forward/list"); +export const updateForward = (data: ForwardMutationPayload) => Network.post("/forward/update", data); export const deleteForward = (id: number) => Network.post("/forward/delete", { id }); @@ -107,7 +147,7 @@ export const resumeForwardService = (forwardId: number) => // 转发诊断操作 export const diagnoseForward = (forwardId: number) => - Network.post("/forward/diagnose", { forwardId }); + Network.post("/forward/diagnose", { forwardId }); // 转发排序操作 export const updateForwardOrder = (data: { @@ -115,34 +155,41 @@ export const updateForwardOrder = (data: { }) => Network.post("/forward/update-order", data); // 限速规则CRUD操作 - 全部使用POST请求 -export const createSpeedLimit = (data: any) => +export const createSpeedLimit = (data: SpeedLimitMutationPayload) => Network.post("/speed-limit/create", data); -export const getSpeedLimitList = () => Network.post("/speed-limit/list"); -export const updateSpeedLimit = (data: any) => +export const getSpeedLimitList = () => + Network.post("/speed-limit/list"); +export const updateSpeedLimit = (data: SpeedLimitMutationPayload) => Network.post("/speed-limit/update", data); export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id }); // 修改密码接口 -export const updatePassword = (data: any) => +export const updatePassword = (data: UpdatePasswordPayload) => Network.post("/user/updatePassword", data); // 重置流量接口 export const resetUserFlow = (data: { id: number; type: number }) => Network.post("/user/reset", data); +export const getUserGroups = (id: number) => + Network.post("/user/groups", { id }); + // 网站配置相关接口 -export const getConfigs = () => Network.post("/config/list"); +export const getConfigs = () => + Network.post>("/config/list"); export const getConfigByName = (name: string) => - Network.post("/config/get", { name }); + Network.post<{ name: string; value: string }>("/config/get", { name }); export const updateConfigs = (configMap: Record) => Network.post("/config/update", configMap); export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value }); export const exportBackupData = () => Network.post("/backup/export"); -export const importBackupData = (data: any) => Network.post("/backup/import", data); -export const restoreBackupData = (data: any) => Network.post("/backup/restore", data); +export const importBackupData = (data: BackupImportPayload) => + Network.post("/backup/import", data); +export const restoreBackupData = (data: BackupImportPayload) => + Network.post("/backup/restore", data); // 验证码相关接口 export const checkCaptcha = () => Network.post("/captcha/check"); @@ -152,26 +199,27 @@ export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => // 批量操作接口 export const batchDeleteForwards = (ids: number[]) => - Network.post("/forward/batch-delete", { ids }); + Network.post("/forward/batch-delete", { ids }); export const batchPauseForwards = (ids: number[]) => - Network.post("/forward/batch-pause", { ids }); + Network.post("/forward/batch-pause", { ids }); export const batchResumeForwards = (ids: number[]) => - Network.post("/forward/batch-resume", { ids }); + Network.post("/forward/batch-resume", { ids }); export const batchDeleteTunnels = (ids: number[]) => - Network.post("/tunnel/batch-delete", { ids }); + Network.post("/tunnel/batch-delete", { ids }); export const batchDeleteNodes = (ids: number[]) => - Network.post("/node/batch-delete", { ids }); + Network.post("/node/batch-delete", { ids }); export const batchRedeployForwards = (ids: number[]) => - Network.post("/forward/batch-redeploy", { ids }); + Network.post("/forward/batch-redeploy", { ids }); export const batchRedeployTunnels = (ids: number[]) => - Network.post("/tunnel/batch-redeploy", { ids }); + Network.post("/tunnel/batch-redeploy", { ids }); export const batchChangeTunnel = (data: { forwardIds: number[]; targetTunnelId: number; -}) => Network.post("/forward/batch-change-tunnel", data); +}) => Network.post("/forward/batch-change-tunnel", data); // 分组与权限分配接口 -export const getTunnelGroupList = () => Network.post("/group/tunnel/list"); +export const getTunnelGroupList = () => + Network.post("/group/tunnel/list"); export const createTunnelGroup = (data: { name: string; status?: number }) => Network.post("/group/tunnel/create", data); export const updateTunnelGroup = (data: { @@ -186,7 +234,8 @@ export const assignTunnelsToGroup = (data: { tunnelIds: number[]; }) => Network.post("/group/tunnel/assign", data); -export const getUserGroupList = () => Network.post("/group/user/list"); +export const getUserGroupList = () => + Network.post("/group/user/list"); export const createUserGroup = (data: { name: string; status?: number }) => Network.post("/group/user/create", data); export const updateUserGroup = (data: { @@ -202,7 +251,7 @@ export const assignUsersToGroup = (data: { }) => Network.post("/group/user/assign", data); export const getGroupPermissionList = () => - Network.post("/group/permission/list"); + Network.post("/group/permission/list"); export const assignGroupPermission = (data: { userGroupId: number; tunnelGroupId: number; @@ -211,7 +260,8 @@ export const removeGroupPermission = (id: number) => Network.post("/group/permission/remove", { id }); // 面板共享 (Federation) 接口 -export const getPeerShareList = () => Network.post("/federation/share/list"); +export const getPeerShareList = () => + Network.post>>("/federation/share/list"); export const createPeerShare = (data: { name: string; nodeId: number; @@ -237,7 +287,9 @@ export const deletePeerShare = (id: number) => export const resetPeerShareFlow = (id: number) => Network.post("/federation/share/reset-flow", { id }); export const getPeerRemoteUsageList = () => - Network.post("/federation/share/remote-usage/list"); + Network.post>>( + "/federation/share/remote-usage/list", + ); export const importRemoteNode = (data: { remoteUrl: string; token: string }) => Network.post("/federation/node/import", data); @@ -283,7 +335,7 @@ export const exportBackup = async (types: string[] = []) => { window.URL.revokeObjectURL(url); }; -export const importBackup = (data: { types: string[]; [key: string]: any }) => +export const importBackup = (data: BackupImportPayload) => Network.post("/backup/import", data); export interface AnnouncementData { @@ -291,5 +343,7 @@ export interface AnnouncementData { enabled: number; } -export const getAnnouncement = () => Network.get("/announcement/get"); -export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data); +export const getAnnouncement = () => + Network.get("/announcement/get"); +export const updateAnnouncement = (data: AnnouncementData) => + Network.post("/announcement/update", data); diff --git a/vite-frontend/src/api/network.ts b/vite-frontend/src/api/network.ts index d7af375..0c00dc5 100644 --- a/vite-frontend/src/api/network.ts +++ b/vite-frontend/src/api/network.ts @@ -1,6 +1,11 @@ import axios, { AxiosResponse } from "axios"; +import { + extractApiErrorMessage, + isUnauthorizedError, +} from "@/api/error-message"; import { getPanelAddresses, isWebViewFunc } from "@/utils/panel"; +import { clearSession, getToken } from "@/utils/session"; interface PanelAddress { name: string; @@ -37,7 +42,7 @@ export const reinitializeBaseURL = () => { reinitializeBaseURL(); -interface ApiResponse { +interface ApiResponse { code: number; msg: string; data: T; @@ -49,10 +54,7 @@ interface RequestOptions { // 处理token失效的逻辑 function handleTokenExpired() { - // 清除localStorage中的token - window.localStorage.removeItem("token"); - window.localStorage.removeItem("role_id"); - window.localStorage.removeItem("name"); + clearSession(); // 跳转到登录页面 if (window.location.pathname !== "/") { @@ -61,7 +63,7 @@ function handleTokenExpired() { } // 检查响应是否为token失效 -function isTokenExpired(response: ApiResponse) { +function isTokenExpired(response: ApiResponse) { return ( response && response.code === 401 && @@ -72,9 +74,9 @@ function isTokenExpired(response: ApiResponse) { } const Network = { - get: function ( + get: function ( path: string = "", - data: any = {}, + data: unknown = {}, options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { @@ -90,38 +92,45 @@ const Network = { params: data, timeout: options.timeout ?? 30000, headers: { - Authorization: window.localStorage.getItem("token"), + Authorization: getToken(), }, }) .then(function (response: AxiosResponse>) { // 检查是否token失效 if (isTokenExpired(response.data)) { handleTokenExpired(); - - return; } + resolve(response.data); }) - .catch(function (error: any) { + .catch(function (error: unknown) { + const errorMessage = extractApiErrorMessage(error); + // 检查是否是401错误(token失效) - if (error.response && error.response.status === 401) { + if (isUnauthorizedError(error)) { handleTokenExpired(); + resolve({ + code: 401, + msg: "未登录或token已过期", + data: null as T, + }); + return; } resolve({ code: -1, - msg: error.message || "网络请求失败", + msg: errorMessage, data: null as T, }); }); }); }, - post: function ( + post: function ( path: string = "", - data: any = {}, + data: unknown = {}, options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { @@ -136,7 +145,7 @@ const Network = { .post(path, data, { timeout: options.timeout ?? 30000, headers: { - Authorization: window.localStorage.getItem("token"), + Authorization: getToken(), "Content-Type": "application/json", }, }) @@ -144,22 +153,29 @@ const Network = { // 检查是否token失效 if (isTokenExpired(response.data)) { handleTokenExpired(); - - return; } + resolve(response.data); }) - .catch(function (error: any) { + .catch(function (error: unknown) { + const errorMessage = extractApiErrorMessage(error); + // 检查是否是401错误(token失效) - if (error.response && error.response.status === 401) { + if (isUnauthorizedError(error)) { handleTokenExpired(); + resolve({ + code: 401, + msg: "未登录或token已过期", + data: null as T, + }); + return; } resolve({ code: -1, - msg: error.message || "网络请求失败", + msg: errorMessage, data: null as T, }); }); diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts new file mode 100644 index 0000000..b4ae2e0 --- /dev/null +++ b/vite-frontend/src/api/types.ts @@ -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; +} diff --git a/vite-frontend/src/components/navbar.tsx b/vite-frontend/src/components/navbar.tsx index b7b0c02..78d7c7a 100644 --- a/vite-frontend/src/components/navbar.tsx +++ b/vite-frontend/src/components/navbar.tsx @@ -1,26 +1,21 @@ -import { useState, useEffect } from "react"; -import { Link } from "@heroui/link"; +import { useEffect, useState } from "react"; +import { Link } from "@/shadcn-bridge/heroui/link"; import { Navbar as HeroUINavbar, NavbarBrand, NavbarContent, -} from "@heroui/navbar"; +} from "@/shadcn-bridge/heroui/navbar"; import { useNavigate } from "react-router-dom"; -import { isWebViewFunc } from "@/utils/panel"; import { Logo } from "@/components/icons"; import { siteConfig, getCachedConfig } from "@/config/site"; +import { useWebViewMode } from "@/hooks/useWebViewMode"; export const Navbar = () => { const navigate = useNavigate(); // 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁 const [appName, setAppName] = useState(siteConfig.name); - const [isWebView, setIsWebView] = useState(false); - - // 检测是否在WebView中运行 - useEffect(() => { - setIsWebView(isWebViewFunc()); - }, []); + const isWebView = useWebViewMode(); useEffect(() => { // 异步检查是否有更新的配置 diff --git a/vite-frontend/src/components/page-state.tsx b/vite-frontend/src/components/page-state.tsx new file mode 100644 index 0000000..0d00dce --- /dev/null +++ b/vite-frontend/src/components/page-state.tsx @@ -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 ( +
+
+ + {message} +
+
+ ); +}; + +export const PageEmptyState = ({ + message, + className = "h-48", +}: BaseStateProps) => { + return ( +
+ {message} +
+ ); +}; + +export const PageErrorState = ({ + message, + className = "h-48", +}: BaseStateProps) => { + return ( +
+ {message} +
+ ); +}; diff --git a/vite-frontend/src/components/theme-provider.tsx b/vite-frontend/src/components/theme-provider.tsx index 66cdecf..72c60c6 100644 --- a/vite-frontend/src/components/theme-provider.tsx +++ b/vite-frontend/src/components/theme-provider.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import { useTheme } from "@heroui/use-theme"; +import { useTheme } from "@/shadcn-bridge/heroui/use-theme"; interface ThemeProviderProps { children: React.ReactNode; diff --git a/vite-frontend/src/components/ui/accordion.tsx b/vite-frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..eba1eae --- /dev/null +++ b/vite-frontend/src/components/ui/accordion.tsx @@ -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) { + return ; +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + data-slot="accordion-trigger" + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/vite-frontend/src/components/ui/alert.tsx b/vite-frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..96ed73d --- /dev/null +++ b/vite-frontend/src/components/ui/alert.tsx @@ -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) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertDescription, AlertTitle }; diff --git a/vite-frontend/src/components/ui/badge.tsx b/vite-frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..b13a087 --- /dev/null +++ b/vite-frontend/src/components/ui/badge.tsx @@ -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) { + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/vite-frontend/src/components/ui/button.tsx b/vite-frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..ba4ffb7 --- /dev/null +++ b/vite-frontend/src/components/ui/button.tsx @@ -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 & { + asChild?: boolean; + }; + +const Button = React.forwardRef( + ({ asChild = false, className, size, variant, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + ); + }, +); + +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/vite-frontend/src/components/ui/card.tsx b/vite-frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..7d031ab --- /dev/null +++ b/vite-frontend/src/components/ui/card.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { + return ( +

+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/vite-frontend/src/components/ui/checkbox.tsx b/vite-frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..7a48c1c --- /dev/null +++ b/vite-frontend/src/components/ui/checkbox.tsx @@ -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) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/vite-frontend/src/components/ui/dialog.tsx b/vite-frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..13c9357 --- /dev/null +++ b/vite-frontend/src/components/ui/dialog.tsx @@ -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) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/vite-frontend/src/components/ui/dropdown-menu.tsx b/vite-frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..5ac423c --- /dev/null +++ b/vite-frontend/src/components/ui/dropdown-menu.tsx @@ -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) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/vite-frontend/src/components/ui/input.tsx b/vite-frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..d238a6d --- /dev/null +++ b/vite-frontend/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/vite-frontend/src/components/ui/label.tsx b/vite-frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..9391551 --- /dev/null +++ b/vite-frontend/src/components/ui/label.tsx @@ -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) { + return ( + + ); +} + +export { Label }; diff --git a/vite-frontend/src/components/ui/progress.tsx b/vite-frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..95e9898 --- /dev/null +++ b/vite-frontend/src/components/ui/progress.tsx @@ -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 & { + indicatorClassName?: string; +}) { + return ( + + + + ); +} + +export { Progress }; diff --git a/vite-frontend/src/components/ui/radio-group.tsx b/vite-frontend/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..34ad09c --- /dev/null +++ b/vite-frontend/src/components/ui/radio-group.tsx @@ -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) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/vite-frontend/src/components/ui/select.tsx b/vite-frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..9a28ac6 --- /dev/null +++ b/vite-frontend/src/components/ui/select.tsx @@ -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) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/vite-frontend/src/components/ui/separator.tsx b/vite-frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..0dd4c82 --- /dev/null +++ b/vite-frontend/src/components/ui/separator.tsx @@ -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) { + return ( + + ); +} + +export { Separator }; diff --git a/vite-frontend/src/components/ui/switch.tsx b/vite-frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..c75163e --- /dev/null +++ b/vite-frontend/src/components/ui/switch.tsx @@ -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) { + return ( + + + + ); +} + +export { Switch }; diff --git a/vite-frontend/src/components/ui/table.tsx b/vite-frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..3ac5fa2 --- /dev/null +++ b/vite-frontend/src/components/ui/table.tsx @@ -0,0 +1,91 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ); +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ); +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + data-slot="table-footer" + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +}; diff --git a/vite-frontend/src/components/ui/tabs.tsx b/vite-frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..e57c653 --- /dev/null +++ b/vite-frontend/src/components/ui/tabs.tsx @@ -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) { + return ( + + ); +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/vite-frontend/src/components/ui/textarea.tsx b/vite-frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..cd7d5d4 --- /dev/null +++ b/vite-frontend/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +