Compare commits

...

35 Commits

Author SHA1 Message Date
dark c35a337695 fix: 修复文件上传按钮无响应和登录错误无提示两个 bug 1 month ago
dark dea4ae80b6 feat: register routes for MyPage, MenuManagement, RoleManagement, OrganizationManagement 1 month ago
dark b411ac169a feat: frontend types, API client, AuthContext, and dynamic sidebar 1 month ago
dark 38f4e740fa feat: implement all backend logic for menu, role, org management 1 month ago
dark 6df3f8795f feat: API definitions and goctl generated code for menu/role/org 1 month ago
dark 41b1e091ce feat: add currentOrgId to User entity, JWT Claims, and auth middleware 1 month ago
dark a29e593b06 feat: add Menu, Role, RoleMenu, Organization, UserOrganization models 1 month ago
dark bbb7b09a3a docs: 菜单管理+角色管理+机构管理 实施计划 1 month ago
dark fd13bf9470 docs: 菜单管理+角色管理+机构管理 设计文档 1 month ago
dark 68aa11fa64 fix: separate Air binary path and args for Windows compatibility 1 month ago
dark 6326bd5970 chore: update test config for account-based login 1 month ago
dark e31c274a64 feat: frontend login accepts phone/username, remove email from auth flow 1 month ago
dark d2cb7fa8c8 feat: login by phone/username, register requires phone, seed uses username 1 month ago
dark f7ab873ca7 refactor: remove email from JWT Claims and auth context 1 month ago
dark 194b16c6ec feat: add FindOneByPhone and FindOneByUsername 1 month ago
dark 91d83e7f4a feat: add Air hot reload config for backend development 1 month ago
dark fb56475faf docs: 添加热重载+登录改造实施计划 1 month ago
dark 679a174d0e docs: 添加热重载和登录改造设计文档 1 month ago
dark d92aba8294 feat: 仪表盘对接后端 API,添加仪表盘 E2E 测试 1 month ago
dark e7df5f0d6f fix: 仪表盘使用真实用户数据替代模拟数据 1 month ago
dark ecc519e322 feat: 添加删除用户确认弹窗 1 month ago
dark ea819fa7e4 fix: 修复 API 数据格式兼容性问题,完成 E2E CRUD 测试 1 month ago
dark c44c0a86c5 fix: resolve naming conflict between Activity type and icon 1 month ago
dark d4ac14fba9 test: add complete E2E CRUD tests for user management with verification 1 month ago
dark 89bc5f0a20 feat: add API verification script and update package.json 1 month ago
dark 8883b23e49 feat: integrate real APIs for Dashboard, Settings and UserManagement pages 1 month ago
dark 02299dfaa1 Add Profile and Dashboard API methods and types 1 month ago
dark 08730a8bfa test: add comprehensive test execution guide 1 month ago
dark cf28600cfc test: add main test suite entry point 1 month ago
dark e57fb7088a test: add navigation and route protection Playwright MCP tests 1 month ago
dark 4ae8861481 test: add settings page Playwright MCP tests 1 month ago
dark f80b2903fb test: add user management page Playwright MCP tests 1 month ago
dark 099ac92b88 test: add dashboard page Playwright MCP tests 1 month ago
dark 8e35094243 test: add login page Playwright MCP tests 1 month ago
dark 9ae5c5b8ad test: add Playwright MCP test configuration and documentation 1 month ago
  1. 12
      .claude/settings.local.json
  2. 3
      .gitmodules
  3. 179
      CLAUDE.md
  4. 16
      backend/.air.toml
  5. 1
      backend/.gitignore
  6. 39
      backend/api/dashboard.api
  7. 52
      backend/api/menu.api
  8. 106
      backend/api/organization.api
  9. 52
      backend/api/role.api
  10. 181
      backend/base.api
  11. 2
      backend/base.go
  12. 25
      backend/internal/handler/dashboard/getdashboardstatshandler.go
  13. 32
      backend/internal/handler/dashboard/getrecentactivitieshandler.go
  14. 32
      backend/internal/handler/menu/createmenuhandler.go
  15. 32
      backend/internal/handler/menu/deletemenuhandler.go
  16. 25
      backend/internal/handler/menu/getcurrentmenushandler.go
  17. 25
      backend/internal/handler/menu/getmenulisthandler.go
  18. 32
      backend/internal/handler/menu/updatemenuhandler.go
  19. 32
      backend/internal/handler/organization/addorgmemberhandler.go
  20. 32
      backend/internal/handler/organization/createorganizationhandler.go
  21. 32
      backend/internal/handler/organization/deleteorganizationhandler.go
  22. 25
      backend/internal/handler/organization/getorganizationlisthandler.go
  23. 32
      backend/internal/handler/organization/getorgmembershandler.go
  24. 32
      backend/internal/handler/organization/removeorgmemberhandler.go
  25. 32
      backend/internal/handler/organization/updateorganizationhandler.go
  26. 32
      backend/internal/handler/organization/updateorgmemberhandler.go
  27. 25
      backend/internal/handler/profile/getuserorgshandler.go
  28. 32
      backend/internal/handler/profile/switchorghandler.go
  29. 32
      backend/internal/handler/role/createrolehandler.go
  30. 32
      backend/internal/handler/role/deleterolehandler.go
  31. 25
      backend/internal/handler/role/getrolelisthandler.go
  32. 32
      backend/internal/handler/role/getrolemenushandler.go
  33. 32
      backend/internal/handler/role/setrolemenushandler.go
  34. 32
      backend/internal/handler/role/updaterolehandler.go
  35. 235
      backend/internal/handler/routes.go
  36. 25
      backend/internal/logic/auth/loginlogic.go
  37. 2
      backend/internal/logic/auth/refreshtokenlogic.go
  38. 32
      backend/internal/logic/auth/registerlogic.go
  39. 304
      backend/internal/logic/auth/ssologic.go
  40. 60
      backend/internal/logic/dashboard/getdashboardstatslogic.go
  41. 69
      backend/internal/logic/dashboard/getrecentactivitieslogic.go
  42. 75
      backend/internal/logic/menu/createmenulogic.go
  43. 56
      backend/internal/logic/menu/deletemenulogic.go
  44. 105
      backend/internal/logic/menu/getcurrentmenuslogic.go
  45. 43
      backend/internal/logic/menu/getmenulistlogic.go
  46. 86
      backend/internal/logic/menu/updatemenulogic.go
  47. 72
      backend/internal/logic/organization/addorgmemberlogic.go
  48. 70
      backend/internal/logic/organization/createorganizationlogic.go
  49. 69
      backend/internal/logic/organization/deleteorganizationlogic.go
  50. 73
      backend/internal/logic/organization/getorganizationlistlogic.go
  51. 71
      backend/internal/logic/organization/getorgmemberslogic.go
  52. 43
      backend/internal/logic/organization/removeorgmemberlogic.go
  53. 85
      backend/internal/logic/organization/updateorganizationlogic.go
  54. 51
      backend/internal/logic/organization/updateorgmemberlogic.go
  55. 75
      backend/internal/logic/profile/getuserorgslogic.go
  56. 75
      backend/internal/logic/profile/switchorglogic.go
  57. 63
      backend/internal/logic/role/createrolelogic.go
  58. 65
      backend/internal/logic/role/deleterolelogic.go
  59. 56
      backend/internal/logic/role/getrolelistlogic.go
  60. 45
      backend/internal/logic/role/getrolemenuslogic.go
  61. 50
      backend/internal/logic/role/setrolemenuslogic.go
  62. 67
      backend/internal/logic/role/updaterolelogic.go
  63. 19
      backend/internal/middleware/authmiddleware.go
  64. 12
      backend/internal/middleware/corsmiddleware.go
  65. 310
      backend/internal/svc/servicecontext.go
  66. 282
      backend/internal/types/types.go
  67. 16
      backend/internal/util/jwt/jwt.go
  68. 39
      backend/internal/util/jwt/jwt_test.go
  69. 23
      backend/model/menu_entity.go
  70. 64
      backend/model/menu_model.go
  71. 22
      backend/model/organization_entity.go
  72. 67
      backend/model/organization_model.go
  73. 20
      backend/model/role_entity.go
  74. 59
      backend/model/role_menu_model.go
  75. 60
      backend/model/role_model.go
  76. 6
      backend/model/user_entity.go
  77. 52
      backend/model/user_model.go
  78. 16
      backend/model/user_organization_entity.go
  79. 66
      backend/model/user_organization_model.go
  80. 1972
      docs/plans/2026-02-13-detailed-playwright-tests.md
  81. 1011
      docs/plans/2026-02-13-frontend-api-integration.md
  82. 1416
      docs/plans/2026-02-13-playwright-mcp-tests.md
  83. 854
      docs/plans/2026-02-14-hot-reload-login-redesign-impl.md
  84. 125
      docs/plans/2026-02-14-hot-reload-login-redesign.md
  85. 287
      docs/plans/2026-02-14-menu-role-org-design.md
  86. 2948
      docs/plans/2026-02-14-menu-role-org-impl.md
  87. 5
      frontend/react-shadcn/pc/package.json
  88. 142
      frontend/react-shadcn/pc/scripts/verify-api.cjs
  89. 12
      frontend/react-shadcn/pc/src/App.tsx
  90. 9
      frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx
  91. 117
      frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx
  92. 182
      frontend/react-shadcn/pc/src/contexts/AuthContext.tsx
  93. 158
      frontend/react-shadcn/pc/src/pages/DashboardPage.tsx
  94. 531
      frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx
  95. 369
      frontend/react-shadcn/pc/src/pages/LoginPage.tsx
  96. 796
      frontend/react-shadcn/pc/src/pages/OrganizationManagementPage.tsx
  97. 222
      frontend/react-shadcn/pc/src/pages/SettingsPage.tsx
  98. 86
      frontend/react-shadcn/pc/src/pages/UserManagementPage.tsx
  99. 289
      frontend/react-shadcn/pc/src/services/api.ts
  100. 201
      frontend/react-shadcn/pc/src/types/index.ts

12
.claude/settings.local.json

@ -27,7 +27,17 @@
"Bash(findstr:*)", "Bash(findstr:*)",
"Bash(netstat:*)", "Bash(netstat:*)",
"Bash(set CGO_ENABLED=1)", "Bash(set CGO_ENABLED=1)",
"Bash(npm install:*)" "Bash(npm install:*)",
"Bash(go:*)",
"Bash(npm:*)",
"mcp__plugin_playwright_playwright__browser_navigate",
"mcp__plugin_playwright_playwright__browser_type",
"mcp__plugin_playwright_playwright__browser_click",
"mcp__plugin_playwright_playwright__browser_fill_form",
"mcp__plugin_playwright_playwright__browser_press_key",
"mcp__plugin_playwright_playwright__browser_wait_for",
"mcp__plugin_playwright_playwright__browser_snapshot",
"mcp__plugin_playwright_playwright__browser_console_messages"
] ]
} }
} }

3
.gitmodules

@ -0,0 +1,3 @@
[submodule ".claude/skills/zero-skills"]
path = .claude/skills/zero-skills
url = https://github.com/zeromicro/zero-skills.git

179
CLAUDE.md

@ -0,0 +1,179 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is an AI development scaffolding project with a Go backend (go-zero) and React frontend (shadcn/ui). It provides a foundation for building full-stack applications with user management, authentication, and profile functionality.
## Project Structure
```
.
├── backend/ # Go + go-zero + GORM backend
│ ├── api/ # API definition files (.api)
│ ├── internal/
│ │ ├── config/ # Configuration structures
│ │ ├── handler/ # HTTP handlers (grouped by feature)
│ │ ├── logic/ # Business logic (grouped by feature)
│ │ ├── middleware/ # CORS, Auth, Logging middleware
│ │ ├── svc/ # Service context (DB, config, middleware)
│ │ └── types/ # Request/response types (auto-generated)
│ ├── model/ # GORM entity models and data access
│ └── tests/ # Test standards documentation
└── frontend/react-shadcn/pc/ # React + Vite + shadcn/ui frontend
├── src/
│ ├── components/ # UI components and layout
│ ├── contexts/ # React contexts (AuthContext)
│ ├── pages/ # Page components
│ ├── services/ # API client
│ └── types/ # TypeScript type definitions
└── vite.config.ts # Vite config with @/ alias
```
## Backend Development
### Build and Run
```bash
cd backend
# Run the server (requires MySQL)
go run base.go -f etc/base-api.yaml
# Run tests
go test ./...
go test ./internal/logic/user/... # Run specific package tests
go test -v ./internal/logic/user/... # Verbose output
# Generate code from API definitions (requires goctl)
goctl api go -api base.api -dir .
```
### Architecture
**go-zero Framework**: The backend uses go-zero with the following conventions:
- **API Definitions**: Defined in `*.api` files using goctl syntax. Main entry: `base.api`
- **Handler-Logic Pattern**: Handlers parse requests and delegate to Logic structs
- **Service Context** (`internal/svc/servicecontext.go`): Holds shared resources (DB connection, config, middleware)
- **Code Generation**: `internal/types/types.go` and `internal/handler/routes.go` are auto-generated by goctl
**Authentication Flow**:
- JWT tokens issued on login/register
- `Auth` middleware validates Bearer tokens and injects user context
- Context keys: `userId`, `username`, `email`
**Database (GORM)**:
- Models in `model/` package with entity + model files
- Auto-migration on startup in `servicecontext.go`
- Supports MySQL (primary) and SQLite (testing)
### API Structure
Base path: `/api/v1`
| Group | Middleware | Endpoints |
|-------|-----------|-----------|
| auth | Cors, Log | POST /register, /login, /refresh |
| user | Cors, Log, Auth | CRUD /user, /users |
| profile | Cors, Log, Auth | GET/PUT /profile/me, POST /profile/password |
## Frontend Development
### Build and Run
```bash
cd frontend/react-shadcn/pc
# Install dependencies
npm install
# Development server (http://localhost:5173)
npm run dev
# Production build
npm run build
# Preview production build
npm run preview
# Lint
npm run lint
```
### Architecture
**Tech Stack**: React 19, TypeScript, Vite, Tailwind CSS v4, shadcn/ui components
**Key Conventions**:
- Path alias `@/` maps to `src/`
- Environment variable: `VITE_API_BASE_URL` (defaults to `http://localhost:8888/api/v1`)
- Custom UI components in `src/components/ui/` (Button, Card, Input, Modal, Table)
**Authentication**:
- `AuthContext` manages global auth state
- JWT stored in localStorage with key `token`
- `ProtectedRoute` component guards authenticated routes
- Auth header: `Authorization: Bearer <token>`
**API Client** (`src/services/api.ts`):
- Singleton `apiClient` class
- Auto-attaches auth headers from localStorage
- Methods organized by feature: auth, user management, profile
## Testing Standards
### Backend Testing
Each module follows the test flow: **Create → Query → Update → Verify Update → List → Delete → Verify Delete**
Test files use SQLite in-memory database for isolation. Example test structure:
```go
func TestCreateUserLogic(t *testing.T) {
// Setup SQLite DB
// Create service context with test DB
// Execute logic
// Verify results
}
```
### API Testing with curl
See `backend/tests/USER_MODULE_TEST_STANDARD.md` for detailed curl examples.
Quick test flow:
```bash
BASE_URL="http://localhost:8888/api/v1"
# 1. Register
POST /register
# 2. Get token, then call authenticated endpoints
GET /profile/me -H "Authorization: Bearer <token>"
```
## Configuration
### Backend (`etc/base-api.yaml`)
```yaml
Name: base-api
Host: 0.0.0.0
Port: 8888
MySQL:
DSN: "user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=true&loc=Local"
```
### Frontend (`.env` or environment)
```
VITE_API_BASE_URL=http://localhost:8888/api/v1
```
## AI Development Resources
- **go-zero AI Context**: https://github.com/zeromicro/ai-context
- **shadcn/ui LLMs.txt**: https://ui.shadcn.com/llms.txt
- **zero-skills**: https://github.com/zeromicro/zero-skills

16
backend/.air.toml

@ -0,0 +1,16 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/base.exe base.go"
bin = "./tmp/base.exe"
args_bin = ["-f", "etc/base-api.yaml"]
include_ext = ["go", "yaml"]
exclude_dir = ["tmp", "vendor", "tests"]
delay = 1000
[log]
time = false
[misc]
clean_on_exit = true

1
backend/.gitignore

@ -0,0 +1 @@
tmp/

39
backend/api/dashboard.api

@ -0,0 +1,39 @@
syntax = "v1"
info (
title: "仪表盘 API"
desc: "仪表盘统计数据接口"
author: "author@example.com"
version: "v1.0"
)
// ========== 仪表盘类型 ==========
type (
// 最近活动请求
RecentActivitiesRequest {
Limit int `form:"limit,default=10"` // 数量限制
}
// 仪表盘统计数据
DashboardStatsResponse {
TotalUsers int64 `json:"totalUsers"` // 总用户数
ActiveUsers int64 `json:"activeUsers"` // 活跃用户数
SystemLoad int `json:"systemLoad"` // 系统负载 0-100
DbStatus string `json:"dbStatus"` // 数据库状态
UserGrowth int `json:"userGrowth"` // 用户增长率
}
// 活动记录
Activity {
Id int64 `json:"id"` // 记录ID
User string `json:"user"` // 用户邮箱
Action string `json:"action"` // 操作
Time string `json:"time"` // 时间描述
Status string `json:"status"` // 状态 success/error
}
// 最近活动列表响应
RecentActivitiesResponse {
Activities []Activity `json:"activities"` // 活动列表
}
)

52
backend/api/menu.api

@ -0,0 +1,52 @@
syntax = "v1"
// ========== 菜单管理类型定义 ==========
type (
MenuItem {
Id int64 `json:"id"`
ParentId int64 `json:"parentId"`
Name string `json:"name"`
Path string `json:"path"`
Icon string `json:"icon"`
Component string `json:"component"`
Type string `json:"type"`
SortOrder int `json:"sortOrder"`
Visible bool `json:"visible"`
Status int `json:"status"`
Children []MenuItem `json:"children"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
MenuListResponse {
List []MenuItem `json:"list"`
}
CreateMenuRequest {
ParentId int64 `json:"parentId,optional"`
Name string `json:"name" validate:"required"`
Path string `json:"path,optional"`
Icon string `json:"icon,optional"`
Component string `json:"component,optional"`
Type string `json:"type,optional"`
SortOrder int `json:"sortOrder,optional"`
Visible *bool `json:"visible,optional"`
}
UpdateMenuRequest {
Id int64 `path:"id"`
ParentId *int64 `json:"parentId,optional"`
Name string `json:"name,optional"`
Path string `json:"path,optional"`
Icon string `json:"icon,optional"`
Component string `json:"component,optional"`
Type string `json:"type,optional"`
SortOrder *int `json:"sortOrder,optional"`
Visible *bool `json:"visible,optional"`
Status *int `json:"status,optional"`
}
DeleteMenuRequest {
Id int64 `path:"id"`
}
)

106
backend/api/organization.api

@ -0,0 +1,106 @@
syntax = "v1"
// ========== 机构管理类型定义 ==========
type (
OrgInfo {
Id int64 `json:"id"`
ParentId int64 `json:"parentId"`
Name string `json:"name"`
Code string `json:"code"`
Leader string `json:"leader"`
Phone string `json:"phone"`
Email string `json:"email"`
SortOrder int `json:"sortOrder"`
Status int `json:"status"`
MemberCount int64 `json:"memberCount"`
Children []OrgInfo `json:"children"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
OrgListResponse {
List []OrgInfo `json:"list"`
}
CreateOrgRequest {
ParentId int64 `json:"parentId,optional"`
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required"`
Leader string `json:"leader,optional"`
Phone string `json:"phone,optional"`
Email string `json:"email,optional"`
SortOrder int `json:"sortOrder,optional"`
}
UpdateOrgRequest {
Id int64 `path:"id"`
ParentId *int64 `json:"parentId,optional"`
Name string `json:"name,optional"`
Code string `json:"code,optional"`
Leader string `json:"leader,optional"`
Phone string `json:"phone,optional"`
Email string `json:"email,optional"`
SortOrder *int `json:"sortOrder,optional"`
Status *int `json:"status,optional"`
}
DeleteOrgRequest {
Id int64 `path:"id"`
}
OrgMember {
UserId int64 `json:"userId"`
Username string `json:"username"`
Email string `json:"email"`
Phone string `json:"phone"`
RoleId int64 `json:"roleId"`
RoleName string `json:"roleName"`
RoleCode string `json:"roleCode"`
CreatedAt string `json:"createdAt"`
}
GetOrgMembersRequest {
Id int64 `path:"id"`
}
OrgMembersResponse {
List []OrgMember `json:"list"`
}
AddOrgMemberRequest {
Id int64 `path:"id"`
UserId int64 `json:"userId" validate:"required"`
RoleId int64 `json:"roleId" validate:"required"`
}
UpdateOrgMemberRequest {
Id int64 `path:"id"`
UserId int64 `path:"userId"`
RoleId int64 `json:"roleId" validate:"required"`
}
RemoveOrgMemberRequest {
Id int64 `path:"id"`
UserId int64 `path:"userId"`
}
UserOrgInfo {
OrgId int64 `json:"orgId"`
OrgName string `json:"orgName"`
RoleId int64 `json:"roleId"`
RoleName string `json:"roleName"`
RoleCode string `json:"roleCode"`
}
UserOrgsResponse {
List []UserOrgInfo `json:"list"`
}
SwitchOrgRequest {
OrgId int64 `json:"orgId" validate:"required"`
}
SwitchOrgResponse {
Token string `json:"token"`
}
)

52
backend/api/role.api

@ -0,0 +1,52 @@
syntax = "v1"
// ========== 角色管理类型定义 ==========
type (
RoleInfo {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Description string `json:"description"`
IsSystem bool `json:"isSystem"`
SortOrder int `json:"sortOrder"`
Status int `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
RoleListResponse {
List []RoleInfo `json:"list"`
}
CreateRoleRequest {
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required"`
Description string `json:"description,optional"`
SortOrder int `json:"sortOrder,optional"`
}
UpdateRoleRequest {
Id int64 `path:"id"`
Name string `json:"name,optional"`
Description string `json:"description,optional"`
SortOrder *int `json:"sortOrder,optional"`
Status *int `json:"status,optional"`
}
DeleteRoleRequest {
Id int64 `path:"id"`
}
GetRoleMenusRequest {
Id int64 `path:"id"`
}
RoleMenusResponse {
MenuIds []int64 `json:"menuIds"`
}
SetRoleMenusRequest {
Id int64 `path:"id"`
MenuIds []int64 `json:"menuIds"`
}
)

181
backend/base.api

@ -9,6 +9,11 @@ info (
import "api/user.api" import "api/user.api"
import "api/profile.api" import "api/profile.api"
import "api/dashboard.api"
import "api/file.api"
import "api/menu.api"
import "api/role.api"
import "api/organization.api"
// ========== 通用响应类型 ========== // ========== 通用响应类型 ==========
type ( type (
@ -28,13 +33,13 @@ type (
// 注册请求 // 注册请求
RegisterRequest { RegisterRequest {
Username string `json:"username" validate:"required,min=3,max=32"` // 用户名 Username string `json:"username" validate:"required,min=3,max=32"` // 用户名
Email string `json:"email" validate:"required,email"` // 邮箱
Password string `json:"password" validate:"required,min=6,max=32"` // 密码 Password string `json:"password" validate:"required,min=6,max=32"` // 密码
Phone string `json:"phone,optional"` // 手机号 Phone string `json:"phone" validate:"required"` // 手机号(必填)
Email string `json:"email,optional"` // 邮箱(可选)
} }
// 登录请求 // 登录请求
LoginRequest { LoginRequest {
Email string `json:"email" validate:"required,email"` // 邮箱 Account string `json:"account" validate:"required"` // 手机号或用户名
Password string `json:"password" validate:"required,min=6,max=32"` // 密码 Password string `json:"password" validate:"required,min=6,max=32"` // 密码
} }
// 刷新Token请求 // 刷新Token请求
@ -70,7 +75,7 @@ service base-api {
@server ( @server (
prefix: /api/v1 prefix: /api/v1
group: user group: user
middleware: Cors,Log,Auth middleware: Cors,Log,Auth,Authz
) )
service base-api { service base-api {
// ========== 用户管理接口 ========== // ========== 用户管理接口 ==========
@ -103,7 +108,7 @@ service base-api {
@server ( @server (
prefix: /api/v1 prefix: /api/v1
group: profile group: profile
middleware: Cors,Log,Auth middleware: Cors,Log,Auth,Authz
) )
service base-api { service base-api {
// ========== 个人中心接口 ========== // ========== 个人中心接口 ==========
@ -121,5 +126,171 @@ service base-api {
@doc "修改密码" @doc "修改密码"
@handler changePassword @handler changePassword
post /profile/password (ChangePasswordRequest) returns (Response) post /profile/password (ChangePasswordRequest) returns (Response)
@doc "获取我的机构列表"
@handler getUserOrgs
get /profile/orgs returns (UserOrgsResponse)
@doc "切换当前机构"
@handler switchOrg
put /profile/current-org (SwitchOrgRequest) returns (SwitchOrgResponse)
}
@server (
prefix: /api/v1
group: dashboard
middleware: Cors,Log,Auth,Authz
)
service base-api {
// ========== 仪表盘接口 ==========
// 获取仪表盘统计数据
@doc "获取仪表盘统计数据"
@handler getDashboardStats
get /dashboard/stats returns (DashboardStatsResponse)
// 获取最近活动
@doc "获取最近活动列表"
@handler getRecentActivities
get /dashboard/activities (RecentActivitiesRequest) returns (RecentActivitiesResponse)
}
@server (
prefix: /api/v1
group: file
middleware: Cors,Log,Auth,Authz
)
service base-api {
// ========== 文件管理接口 ==========
@doc "上传文件"
@handler uploadFile
post /file/upload returns (FileInfo)
@doc "获取文件列表"
@handler getFileList
get /files (FileListRequest) returns (FileListResponse)
@doc "获取文件详情"
@handler getFile
get /file/:id (GetFileRequest) returns (FileInfo)
@doc "获取文件访问URL"
@handler getFileUrl
get /file/:id/url (GetFileRequest) returns (FileUrlResponse)
@doc "更新文件信息"
@handler updateFile
put /file/:id (UpdateFileRequest) returns (FileInfo)
@doc "删除文件"
@handler deleteFile
delete /file/:id (DeleteFileRequest) returns (Response)
}
// ========== 菜单管理(当前用户菜单,只需登录) ==========
@server (
prefix: /api/v1
group: menu
middleware: Cors,Log,Auth
)
service base-api {
@doc "获取当前用户可见菜单"
@handler getCurrentMenus
get /menus/current returns (MenuListResponse)
}
// ========== 菜单管理(管理端,需授权) ==========
@server (
prefix: /api/v1
group: menu
middleware: Cors,Log,Auth,Authz
)
service base-api {
@doc "获取全部菜单列表"
@handler getMenuList
get /menus returns (MenuListResponse)
@doc "创建菜单"
@handler createMenu
post /menu (CreateMenuRequest) returns (MenuItem)
@doc "更新菜单"
@handler updateMenu
put /menu/:id (UpdateMenuRequest) returns (MenuItem)
@doc "删除菜单"
@handler deleteMenu
delete /menu/:id (DeleteMenuRequest) returns (Response)
}
// ========== 角色管理 ==========
@server (
prefix: /api/v1
group: role
middleware: Cors,Log,Auth,Authz
)
service base-api {
@doc "获取角色列表"
@handler getRoleList
get /roles returns (RoleListResponse)
@doc "创建角色"
@handler createRole
post /role (CreateRoleRequest) returns (RoleInfo)
@doc "更新角色"
@handler updateRole
put /role/:id (UpdateRoleRequest) returns (RoleInfo)
@doc "删除角色"
@handler deleteRole
delete /role/:id (DeleteRoleRequest) returns (Response)
@doc "获取角色菜单"
@handler getRoleMenus
get /role/:id/menus (GetRoleMenusRequest) returns (RoleMenusResponse)
@doc "设置角色菜单"
@handler setRoleMenus
put /role/:id/menus (SetRoleMenusRequest) returns (Response)
}
// ========== 机构管理 ==========
@server (
prefix: /api/v1
group: organization
middleware: Cors,Log,Auth,Authz
)
service base-api {
@doc "获取机构列表"
@handler getOrganizationList
get /organizations returns (OrgListResponse)
@doc "创建机构"
@handler createOrganization
post /organization (CreateOrgRequest) returns (OrgInfo)
@doc "更新机构"
@handler updateOrganization
put /organization/:id (UpdateOrgRequest) returns (OrgInfo)
@doc "删除机构"
@handler deleteOrganization
delete /organization/:id (DeleteOrgRequest) returns (Response)
@doc "获取机构成员"
@handler getOrgMembers
get /organization/:id/members (GetOrgMembersRequest) returns (OrgMembersResponse)
@doc "添加机构成员"
@handler addOrgMember
post /organization/:id/member (AddOrgMemberRequest) returns (Response)
@doc "更新机构成员角色"
@handler updateOrgMember
put /organization/:id/member/:userId (UpdateOrgMemberRequest) returns (Response)
@doc "移除机构成员"
@handler removeOrgMember
delete /organization/:id/member/:userId (RemoveOrgMemberRequest) returns (Response)
} }

2
backend/base.go

@ -23,7 +23,7 @@ func main() {
var c config.Config var c config.Config
conf.MustLoad(*configFile, &c) conf.MustLoad(*configFile, &c)
server := rest.MustNewServer(c.RestConf) server := rest.MustNewServer(c.RestConf, rest.WithCors("*"))
defer server.Stop() defer server.Stop()
ctx := svc.NewServiceContext(c) ctx := svc.NewServiceContext(c)

25
backend/internal/handler/dashboard/getdashboardstatshandler.go

@ -0,0 +1,25 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package dashboard
import (
"net/http"
"github.com/youruser/base/internal/logic/dashboard"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取仪表盘统计数据
func GetDashboardStatsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := dashboard.NewGetDashboardStatsLogic(r.Context(), svcCtx)
resp, err := l.GetDashboardStats()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/dashboard/getrecentactivitieshandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package dashboard
import (
"net/http"
"github.com/youruser/base/internal/logic/dashboard"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取最近活动列表
func GetRecentActivitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.RecentActivitiesRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := dashboard.NewGetRecentActivitiesLogic(r.Context(), svcCtx)
resp, err := l.GetRecentActivities(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/menu/createmenuhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"net/http"
"github.com/youruser/base/internal/logic/menu"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 创建菜单
func CreateMenuHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateMenuRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := menu.NewCreateMenuLogic(r.Context(), svcCtx)
resp, err := l.CreateMenu(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/menu/deletemenuhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"net/http"
"github.com/youruser/base/internal/logic/menu"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 删除菜单
func DeleteMenuHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteMenuRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := menu.NewDeleteMenuLogic(r.Context(), svcCtx)
resp, err := l.DeleteMenu(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

25
backend/internal/handler/menu/getcurrentmenushandler.go

@ -0,0 +1,25 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"net/http"
"github.com/youruser/base/internal/logic/menu"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取当前用户可见菜单
func GetCurrentMenusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := menu.NewGetCurrentMenusLogic(r.Context(), svcCtx)
resp, err := l.GetCurrentMenus()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

25
backend/internal/handler/menu/getmenulisthandler.go

@ -0,0 +1,25 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"net/http"
"github.com/youruser/base/internal/logic/menu"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取全部菜单列表
func GetMenuListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := menu.NewGetMenuListLogic(r.Context(), svcCtx)
resp, err := l.GetMenuList()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/menu/updatemenuhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"net/http"
"github.com/youruser/base/internal/logic/menu"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 更新菜单
func UpdateMenuHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateMenuRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := menu.NewUpdateMenuLogic(r.Context(), svcCtx)
resp, err := l.UpdateMenu(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/addorgmemberhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 添加机构成员
func AddOrgMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AddOrgMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewAddOrgMemberLogic(r.Context(), svcCtx)
resp, err := l.AddOrgMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/createorganizationhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 创建机构
func CreateOrganizationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateOrgRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewCreateOrganizationLogic(r.Context(), svcCtx)
resp, err := l.CreateOrganization(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/deleteorganizationhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 删除机构
func DeleteOrganizationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteOrgRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewDeleteOrganizationLogic(r.Context(), svcCtx)
resp, err := l.DeleteOrganization(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

25
backend/internal/handler/organization/getorganizationlisthandler.go

@ -0,0 +1,25 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取机构列表
func GetOrganizationListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := organization.NewGetOrganizationListLogic(r.Context(), svcCtx)
resp, err := l.GetOrganizationList()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/getorgmembershandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取机构成员
func GetOrgMembersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetOrgMembersRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewGetOrgMembersLogic(r.Context(), svcCtx)
resp, err := l.GetOrgMembers(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/removeorgmemberhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 移除机构成员
func RemoveOrgMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.RemoveOrgMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewRemoveOrgMemberLogic(r.Context(), svcCtx)
resp, err := l.RemoveOrgMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/updateorganizationhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 更新机构
func UpdateOrganizationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateOrgRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewUpdateOrganizationLogic(r.Context(), svcCtx)
resp, err := l.UpdateOrganization(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/organization/updateorgmemberhandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"net/http"
"github.com/youruser/base/internal/logic/organization"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 更新机构成员角色
func UpdateOrgMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateOrgMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := organization.NewUpdateOrgMemberLogic(r.Context(), svcCtx)
resp, err := l.UpdateOrgMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

25
backend/internal/handler/profile/getuserorgshandler.go

@ -0,0 +1,25 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package profile
import (
"net/http"
"github.com/youruser/base/internal/logic/profile"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取我的机构列表
func GetUserOrgsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := profile.NewGetUserOrgsLogic(r.Context(), svcCtx)
resp, err := l.GetUserOrgs()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/profile/switchorghandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package profile
import (
"net/http"
"github.com/youruser/base/internal/logic/profile"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 切换当前机构
func SwitchOrgHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SwitchOrgRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := profile.NewSwitchOrgLogic(r.Context(), svcCtx)
resp, err := l.SwitchOrg(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/role/createrolehandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"net/http"
"github.com/youruser/base/internal/logic/role"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 创建角色
func CreateRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateRoleRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := role.NewCreateRoleLogic(r.Context(), svcCtx)
resp, err := l.CreateRole(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/role/deleterolehandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"net/http"
"github.com/youruser/base/internal/logic/role"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 删除角色
func DeleteRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteRoleRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := role.NewDeleteRoleLogic(r.Context(), svcCtx)
resp, err := l.DeleteRole(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

25
backend/internal/handler/role/getrolelisthandler.go

@ -0,0 +1,25 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"net/http"
"github.com/youruser/base/internal/logic/role"
"github.com/youruser/base/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取角色列表
func GetRoleListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := role.NewGetRoleListLogic(r.Context(), svcCtx)
resp, err := l.GetRoleList()
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/role/getrolemenushandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"net/http"
"github.com/youruser/base/internal/logic/role"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 获取角色菜单
func GetRoleMenusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GetRoleMenusRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := role.NewGetRoleMenusLogic(r.Context(), svcCtx)
resp, err := l.GetRoleMenus(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/role/setrolemenushandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"net/http"
"github.com/youruser/base/internal/logic/role"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 设置角色菜单
func SetRoleMenusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SetRoleMenusRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := role.NewSetRoleMenusLogic(r.Context(), svcCtx)
resp, err := l.SetRoleMenus(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

32
backend/internal/handler/role/updaterolehandler.go

@ -0,0 +1,32 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"net/http"
"github.com/youruser/base/internal/logic/role"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// 更新角色
func UpdateRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateRoleRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := role.NewUpdateRoleLogic(r.Context(), svcCtx)
resp, err := l.UpdateRole(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}

235
backend/internal/handler/routes.go

@ -7,7 +7,12 @@ import (
"net/http" "net/http"
auth "github.com/youruser/base/internal/handler/auth" auth "github.com/youruser/base/internal/handler/auth"
dashboard "github.com/youruser/base/internal/handler/dashboard"
file "github.com/youruser/base/internal/handler/file"
menu "github.com/youruser/base/internal/handler/menu"
organization "github.com/youruser/base/internal/handler/organization"
profile "github.com/youruser/base/internal/handler/profile" profile "github.com/youruser/base/internal/handler/profile"
role "github.com/youruser/base/internal/handler/role"
user "github.com/youruser/base/internal/handler/user" user "github.com/youruser/base/internal/handler/user"
"github.com/youruser/base/internal/svc" "github.com/youruser/base/internal/svc"
@ -42,10 +47,187 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"), rest.WithPrefix("/api/v1"),
) )
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{
{
// 获取最近活动列表
Method: http.MethodGet,
Path: "/dashboard/activities",
Handler: dashboard.GetRecentActivitiesHandler(serverCtx),
},
{
// 获取仪表盘统计数据
Method: http.MethodGet,
Path: "/dashboard/stats",
Handler: dashboard.GetDashboardStatsHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{
{
// 获取文件详情
Method: http.MethodGet,
Path: "/file/:id",
Handler: file.GetFileHandler(serverCtx),
},
{
// 更新文件信息
Method: http.MethodPut,
Path: "/file/:id",
Handler: file.UpdateFileHandler(serverCtx),
},
{
// 删除文件
Method: http.MethodDelete,
Path: "/file/:id",
Handler: file.DeleteFileHandler(serverCtx),
},
{
// 获取文件访问URL
Method: http.MethodGet,
Path: "/file/:id/url",
Handler: file.GetFileUrlHandler(serverCtx),
},
{
// 上传文件
Method: http.MethodPost,
Path: "/file/upload",
Handler: file.UploadFileHandler(serverCtx),
},
{
// 获取文件列表
Method: http.MethodGet,
Path: "/files",
Handler: file.GetFileListHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth}, []rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth},
[]rest.Route{ []rest.Route{
{
// 获取当前用户可见菜单
Method: http.MethodGet,
Path: "/menus/current",
Handler: menu.GetCurrentMenusHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{
{
// 创建菜单
Method: http.MethodPost,
Path: "/menu",
Handler: menu.CreateMenuHandler(serverCtx),
},
{
// 更新菜单
Method: http.MethodPut,
Path: "/menu/:id",
Handler: menu.UpdateMenuHandler(serverCtx),
},
{
// 删除菜单
Method: http.MethodDelete,
Path: "/menu/:id",
Handler: menu.DeleteMenuHandler(serverCtx),
},
{
// 获取全部菜单列表
Method: http.MethodGet,
Path: "/menus",
Handler: menu.GetMenuListHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{
{
// 创建机构
Method: http.MethodPost,
Path: "/organization",
Handler: organization.CreateOrganizationHandler(serverCtx),
},
{
// 更新机构
Method: http.MethodPut,
Path: "/organization/:id",
Handler: organization.UpdateOrganizationHandler(serverCtx),
},
{
// 删除机构
Method: http.MethodDelete,
Path: "/organization/:id",
Handler: organization.DeleteOrganizationHandler(serverCtx),
},
{
// 添加机构成员
Method: http.MethodPost,
Path: "/organization/:id/member",
Handler: organization.AddOrgMemberHandler(serverCtx),
},
{
// 更新机构成员角色
Method: http.MethodPut,
Path: "/organization/:id/member/:userId",
Handler: organization.UpdateOrgMemberHandler(serverCtx),
},
{
// 移除机构成员
Method: http.MethodDelete,
Path: "/organization/:id/member/:userId",
Handler: organization.RemoveOrgMemberHandler(serverCtx),
},
{
// 获取机构成员
Method: http.MethodGet,
Path: "/organization/:id/members",
Handler: organization.GetOrgMembersHandler(serverCtx),
},
{
// 获取机构列表
Method: http.MethodGet,
Path: "/organizations",
Handler: organization.GetOrganizationListHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{
{
// 切换当前机构
Method: http.MethodPut,
Path: "/profile/current-org",
Handler: profile.SwitchOrgHandler(serverCtx),
},
{ {
// 获取个人信息 // 获取个人信息
Method: http.MethodGet, Method: http.MethodGet,
@ -58,6 +240,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/profile/me", Path: "/profile/me",
Handler: profile.UpdateProfileHandler(serverCtx), Handler: profile.UpdateProfileHandler(serverCtx),
}, },
{
// 获取我的机构列表
Method: http.MethodGet,
Path: "/profile/orgs",
Handler: profile.GetUserOrgsHandler(serverCtx),
},
{ {
// 修改密码 // 修改密码
Method: http.MethodPost, Method: http.MethodPost,
@ -71,7 +259,52 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth}, []rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{
{
// 创建角色
Method: http.MethodPost,
Path: "/role",
Handler: role.CreateRoleHandler(serverCtx),
},
{
// 更新角色
Method: http.MethodPut,
Path: "/role/:id",
Handler: role.UpdateRoleHandler(serverCtx),
},
{
// 删除角色
Method: http.MethodDelete,
Path: "/role/:id",
Handler: role.DeleteRoleHandler(serverCtx),
},
{
// 获取角色菜单
Method: http.MethodGet,
Path: "/role/:id/menus",
Handler: role.GetRoleMenusHandler(serverCtx),
},
{
// 设置角色菜单
Method: http.MethodPut,
Path: "/role/:id/menus",
Handler: role.SetRoleMenusHandler(serverCtx),
},
{
// 获取角色列表
Method: http.MethodGet,
Path: "/roles",
Handler: role.GetRoleListHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.Cors, serverCtx.Log, serverCtx.Auth, serverCtx.Authz},
[]rest.Route{ []rest.Route{
{ {
// 创建用户 // 创建用户

25
backend/internal/logic/auth/loginlogic.go

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"regexp"
"github.com/youruser/base/internal/svc" "github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types" "github.com/youruser/base/internal/types"
@ -13,13 +14,14 @@ import (
"github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/logx"
) )
var phoneRegex = regexp.MustCompile(`^\d{11}$`)
type LoginLogic struct { type LoginLogic struct {
logx.Logger logx.Logger
ctx context.Context ctx context.Context
svcCtx *svc.ServiceContext svcCtx *svc.ServiceContext
} }
// 用户登录
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
return &LoginLogic{ return &LoginLogic{
Logger: logx.WithContext(ctx), Logger: logx.WithContext(ctx),
@ -29,8 +31,13 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic
} }
func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, err error) { func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, err error) {
// 查询用户 var user *model.User
user, err := model.FindOneByEmail(l.ctx, l.svcCtx.DB, req.Email) if phoneRegex.MatchString(req.Account) {
user, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Account)
} else {
user, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Account)
}
if err != nil { if err != nil {
if err == model.ErrNotFound { if err == model.ErrNotFound {
return &types.LoginResponse{ return &types.LoginResponse{
@ -42,7 +49,14 @@ func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse,
return nil, fmt.Errorf("查询用户失败: %v", err) return nil, fmt.Errorf("查询用户失败: %v", err)
} }
// 加密输入的密码并与数据库密码对比 if user.UserType == "casdoor" {
return &types.LoginResponse{
Code: 400,
Message: "该账号已绑定 SSO,请使用 SSO 方式登录",
Success: false,
}, nil
}
inputPassword := fmt.Sprintf("%x", md5.Sum([]byte(req.Password))) inputPassword := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
if user.Password != inputPassword { if user.Password != inputPassword {
return &types.LoginResponse{ return &types.LoginResponse{
@ -52,8 +66,7 @@ func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse,
}, nil }, nil
} }
// 生成 Token token, err := jwt.GenerateToken(user.Id, user.Username, user.Role, user.CurrentOrgId)
token, err := jwt.GenerateToken(user.Id, user.Username, user.Email)
if err != nil { if err != nil {
return nil, fmt.Errorf("生成Token失败: %v", err) return nil, fmt.Errorf("生成Token失败: %v", err)
} }

2
backend/internal/logic/auth/refreshtokenlogic.go

@ -52,7 +52,7 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenRequest) (resp *
} }
// 生成新 Token // 生成新 Token
newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Email) newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Role, user.CurrentOrgId)
if err != nil { if err != nil {
return nil, fmt.Errorf("生成Token失败: %v", err) return nil, fmt.Errorf("生成Token失败: %v", err)
} }

32
backend/internal/logic/auth/registerlogic.go

@ -18,7 +18,6 @@ type RegisterLogic struct {
svcCtx *svc.ServiceContext svcCtx *svc.ServiceContext
} }
// 用户注册
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
return &RegisterLogic{ return &RegisterLogic{
Logger: logx.WithContext(ctx), Logger: logx.WithContext(ctx),
@ -28,50 +27,55 @@ func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Register
} }
func (l *RegisterLogic) Register(req *types.RegisterRequest) (resp *types.UserInfo, err error) { func (l *RegisterLogic) Register(req *types.RegisterRequest) (resp *types.UserInfo, err error) {
// 检查邮箱是否已存在 _, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Username)
_, err = model.FindOneByEmail(l.ctx, l.svcCtx.DB, req.Email)
if err == nil { if err == nil {
return nil, fmt.Errorf("邮箱已被注册") return nil, fmt.Errorf("用户名已被注册")
} }
if err != model.ErrNotFound { if err != model.ErrNotFound {
return nil, fmt.Errorf("检查邮箱失败: %v", err) return nil, fmt.Errorf("检查用户名失败: %v", err)
}
_, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Phone)
if err == nil {
return nil, fmt.Errorf("手机号已被注册")
}
if err != model.ErrNotFound {
return nil, fmt.Errorf("检查手机号失败: %v", err)
} }
// 创建用户模型
user := &model.User{ user := &model.User{
Username: req.Username, Username: req.Username,
Email: req.Email, Email: req.Email,
Password: fmt.Sprintf("%x", md5.Sum([]byte(req.Password))), // 密码加密 Password: fmt.Sprintf("%x", md5.Sum([]byte(req.Password))),
Phone: req.Phone, Phone: req.Phone,
Status: 1, // 默认正常状态 Role: model.RoleUser,
Source: model.SourceRegister,
Status: 1,
} }
// 插入数据库
id, err := model.Insert(l.ctx, l.svcCtx.DB, user) id, err := model.Insert(l.ctx, l.svcCtx.DB, user)
if err != nil { if err != nil {
return nil, fmt.Errorf("创建用户失败: %v", err) return nil, fmt.Errorf("创建用户失败: %v", err)
} }
// 查询创建的用户
user, err = model.FindOne(l.ctx, l.svcCtx.DB, id) user, err = model.FindOne(l.ctx, l.svcCtx.DB, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("查询用户失败: %v", err) return nil, fmt.Errorf("查询用户失败: %v", err)
} }
// 返回用户信息(不返回密码)
resp = &types.UserInfo{ resp = &types.UserInfo{
Id: user.Id, Id: user.Id,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
Phone: user.Phone, Phone: user.Phone,
Role: user.Role,
Source: user.Source,
Remark: user.Remark,
Status: int(user.Status), Status: int(user.Status),
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"),
} }
// 返回 Token 在响应头中(通过中间件处理)
// 临时方案:将 token 放入响应 Data 中
l.Infof("注册成功,userId=%d", user.Id) l.Infof("注册成功,userId=%d", user.Id)
return resp, nil return resp, nil
} }

304
backend/internal/logic/auth/ssologic.go

@ -0,0 +1,304 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/util/jwt"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
// casdoorHttpClient 用于与 Casdoor 通信的 HTTP 客户端(带超时)
var casdoorHttpClient = &http.Client{Timeout: 10 * time.Second}
type SSOLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewSSOLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SSOLogic {
return &SSOLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// GetLoginUrl 生成 Casdoor SSO 登录链接
func (l *SSOLogic) GetLoginUrl() (map[string]string, error) {
c := l.svcCtx.Config.Casdoor
state, err := generateState()
if err != nil {
return nil, fmt.Errorf("生成 state 失败: %v", err)
}
loginUrl := fmt.Sprintf("%s/login/oauth/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=read&state=%s",
c.Endpoint,
url.QueryEscape(c.ClientId),
url.QueryEscape(c.RedirectUrl),
url.QueryEscape(state),
)
return map[string]string{
"login_url": loginUrl,
}, nil
}
// casdoorTokenResponse Casdoor token 响应
type casdoorTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
// casdoorUserInfo Casdoor 用户信息
type casdoorUserInfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
}
// HandleCallback 处理 SSO 回调
func (l *SSOLogic) HandleCallback(code, state string) (string, error) {
if code == "" {
return "", fmt.Errorf("缺少授权码")
}
c := l.svcCtx.Config.Casdoor
// 1. 用 code 换取 access_token
accessToken, err := l.exchangeToken(code)
if err != nil {
l.Errorf("SSO token 交换失败: %v", err)
return "", fmt.Errorf("token 交换失败: %v", err)
}
// 2. 获取用户信息
userInfo, err := l.getUserInfo(accessToken)
if err != nil {
l.Errorf("SSO 获取用户信息失败: %v", err)
return "", fmt.Errorf("获取用户信息失败: %v", err)
}
// 3. 查找或创建本地用户
casdoorId := userInfo.Sub
if casdoorId == "" {
casdoorId = userInfo.Name
}
// 从 Casdoor 信息中提取用户名和邮箱
username := userInfo.PreferredUsername
if username == "" {
username = userInfo.Name
}
email := userInfo.Email
if email == "" {
email = username + "@sso.local"
}
localUser, err := model.FindOneByCasdoorId(l.ctx, l.svcCtx.DB, casdoorId)
if err != nil {
if err == model.ErrNotFound {
// 用户不存在,尝试通过邮箱关联已有本地用户
existingUser, findErr := model.FindOneByEmail(l.ctx, l.svcCtx.DB, email)
if findErr == nil {
existingUser.CasdoorId = casdoorId
existingUser.UserType = "casdoor"
if updateErr := model.Update(l.ctx, l.svcCtx.DB, existingUser); updateErr != nil {
return "", fmt.Errorf("关联用户失败: %v", updateErr)
}
localUser = existingUser
l.Infof("SSO 关联已有用户: userId=%d, casdoorId=%s", existingUser.Id, casdoorId)
} else {
// 创建新用户
newUser := &model.User{
Username: username,
Email: email,
Password: "SSO_NO_PASSWORD", // SSO 用户不使用密码登录
Phone: userInfo.Phone,
CasdoorId: casdoorId,
UserType: "casdoor",
Role: model.RoleUser,
Source: model.SourceCasdoor,
Status: 1,
}
_, insertErr := model.Insert(l.ctx, l.svcCtx.DB, newUser)
if insertErr != nil {
l.Errorf("SSO 创建用户失败: %v", insertErr)
return "", fmt.Errorf("创建用户失败: %v", insertErr)
}
localUser = newUser
l.Infof("SSO 新用户创建成功: username=%s, casdoorId=%s", username, casdoorId)
}
} else {
return "", fmt.Errorf("查询用户失败: %v", err)
}
} else {
// 已有用户,同步更新 Casdoor 端的最新信息
updated := false
if username != "" && localUser.Username != username {
localUser.Username = username
updated = true
}
if email != "" && localUser.Email != email {
localUser.Email = email
updated = true
}
if userInfo.Phone != "" && localUser.Phone != userInfo.Phone {
localUser.Phone = userInfo.Phone
updated = true
}
if updated {
if updateErr := model.Update(l.ctx, l.svcCtx.DB, localUser); updateErr != nil {
l.Errorf("SSO 同步用户信息失败: %v", updateErr)
}
}
}
// 4. 生成本地 JWT Token
token, err := jwt.GenerateToken(localUser.Id, localUser.Username, localUser.Role, localUser.CurrentOrgId)
if err != nil {
return "", fmt.Errorf("生成 Token 失败: %v", err)
}
l.Infof("SSO 登录成功: userId=%d, username=%s", localUser.Id, localUser.Username)
// 5. 构建前端回调 URL
redirectUrl := fmt.Sprintf("%s/sso/callback?token=%s",
c.FrontendUrl,
url.QueryEscape(token),
)
return redirectUrl, nil
}
// exchangeToken 用授权码换取 access_token
func (l *SSOLogic) exchangeToken(code string) (string, error) {
c := l.svcCtx.Config.Casdoor
tokenUrl := fmt.Sprintf("%s/api/login/oauth/access_token", c.Endpoint)
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("client_id", c.ClientId)
data.Set("client_secret", c.ClientSecret)
data.Set("code", code)
data.Set("redirect_uri", c.RedirectUrl)
req, err := http.NewRequestWithContext(l.ctx, http.MethodPost, tokenUrl,
strings.NewReader(data.Encode()))
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := casdoorHttpClient.Do(req)
if err != nil {
return "", fmt.Errorf("请求 token 失败: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token 请求返回 %d: %s", resp.StatusCode, string(body))
}
var tokenResp casdoorTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("解析 token 响应失败: %v", err)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("未获取到 access_token, 响应: %s", string(body))
}
return tokenResp.AccessToken, nil
}
// getUserInfo 从 access_token JWT 中解析用户信息
// Casdoor 的 access_token 本身是一个 JWT,包含完整的用户 claims
func (l *SSOLogic) getUserInfo(accessToken string) (*casdoorUserInfo, error) {
// 解析 JWT payload(不验证签名,因为 token 刚从 Casdoor 获取)
parts := strings.Split(accessToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("access_token 不是有效的 JWT 格式")
}
// Base64 解码 payload
payload := parts[1]
// 补齐 base64 padding
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return nil, fmt.Errorf("解码 JWT payload 失败: %v", err)
}
// 解析 JWT claims
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, fmt.Errorf("解析 JWT claims 失败: %v", err)
}
// 从 claims 中提取用户信息(Casdoor JWT 字段名)
userInfo := &casdoorUserInfo{
Sub: getStringClaim(claims, "sub"),
}
// Casdoor JWT 中用户名可能在 name 或 preferred_username 字段
userInfo.Name = getStringClaim(claims, "name")
userInfo.PreferredUsername = getStringClaim(claims, "preferred_username")
userInfo.Email = getStringClaim(claims, "email")
userInfo.Phone = getStringClaim(claims, "phone")
userInfo.Avatar = getStringClaim(claims, "avatar")
return userInfo, nil
}
// getStringClaim 从 claims map 中安全获取字符串值
func getStringClaim(claims map[string]interface{}, key string) string {
if v, ok := claims[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// generateState 生成随机 state 参数(CSRF 防护)
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

60
backend/internal/logic/dashboard/getdashboardstatslogic.go

@ -0,0 +1,60 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package dashboard
import (
"context"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetDashboardStatsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取仪表盘统计数据
func NewGetDashboardStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetDashboardStatsLogic {
return &GetDashboardStatsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetDashboardStatsLogic) GetDashboardStats() (resp *types.DashboardStatsResponse, err error) {
// 查询总用户数
var totalUsers int64
if err := l.svcCtx.DB.Model(&model.User{}).Count(&totalUsers).Error; err != nil {
return nil, err
}
// 查询活跃用户数(status = 1)
var activeUsers int64
if err := l.svcCtx.DB.Model(&model.User{}).Where("status = ?", 1).Count(&activeUsers).Error; err != nil {
return nil, err
}
// 模拟系统负载(可以根据实际系统指标计算)
systemLoad := 32
// 数据库状态
dbStatus := "正常"
// 用户增长率(模拟数据)
userGrowth := 65
return &types.DashboardStatsResponse{
TotalUsers: totalUsers,
ActiveUsers: activeUsers,
SystemLoad: systemLoad,
DbStatus: dbStatus,
UserGrowth: userGrowth,
}, nil
}

69
backend/internal/logic/dashboard/getrecentactivitieslogic.go

@ -0,0 +1,69 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package dashboard
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRecentActivitiesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取最近活动列表
func NewGetRecentActivitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRecentActivitiesLogic {
return &GetRecentActivitiesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRecentActivitiesLogic) GetRecentActivities(req *types.RecentActivitiesRequest) (resp *types.RecentActivitiesResponse, err error) {
// 获取最近注册用户作为活动记录
var users []model.User
limit := req.Limit
if limit <= 0 {
limit = 10
}
if err := l.svcCtx.DB.Order("created_at DESC").Limit(int(limit)).Find(&users).Error; err != nil {
return nil, err
}
// 转换为活动记录
activities := make([]types.Activity, 0, len(users))
actions := []string{"登录系统", "更新资料", "创建用户"}
times := []string{"5 分钟前", "15 分钟前", "1 小时前", "2 小时前", "3 小时前"}
for i, user := range users {
action := actions[i%len(actions)]
timeStr := times[i%len(times)]
if i > 0 {
action = "登录系统"
timeStr = fmt.Sprintf("%d 小时前", i+1)
}
activities = append(activities, types.Activity{
Id: int64(user.Id),
User: user.Email,
Action: action,
Time: timeStr,
Status: "success",
})
}
return &types.RecentActivitiesResponse{
Activities: activities,
}, nil
}

75
backend/internal/logic/menu/createmenulogic.go

@ -0,0 +1,75 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateMenuLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 创建菜单
func NewCreateMenuLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateMenuLogic {
return &CreateMenuLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateMenuLogic) CreateMenu(req *types.CreateMenuRequest) (resp *types.MenuItem, err error) {
visible := true
if req.Visible != nil {
visible = *req.Visible
}
menuType := "config"
if req.Type != "" {
menuType = req.Type
}
menu := &model.Menu{
ParentId: req.ParentId,
Name: req.Name,
Path: req.Path,
Icon: req.Icon,
Component: req.Component,
Type: menuType,
SortOrder: req.SortOrder,
Visible: visible,
Status: 1,
}
_, err = model.MenuInsert(l.ctx, l.svcCtx.DB, menu)
if err != nil {
return nil, fmt.Errorf("创建菜单失败: %v", err)
}
return &types.MenuItem{
Id: menu.Id,
ParentId: menu.ParentId,
Name: menu.Name,
Path: menu.Path,
Icon: menu.Icon,
Component: menu.Component,
Type: menu.Type,
SortOrder: menu.SortOrder,
Visible: menu.Visible,
Status: menu.Status,
Children: []types.MenuItem{},
CreatedAt: menu.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: menu.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}

56
backend/internal/logic/menu/deletemenulogic.go

@ -0,0 +1,56 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteMenuLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 删除菜单
func NewDeleteMenuLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteMenuLogic {
return &DeleteMenuLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteMenuLogic) DeleteMenu(req *types.DeleteMenuRequest) (resp *types.Response, err error) {
// 检查是否有子菜单
hasChildren, err := model.MenuHasChildren(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("检查子菜单失败: %v", err)
}
if hasChildren {
return &types.Response{
Code: 400,
Message: "该菜单下有子菜单,无法删除",
Success: false,
}, nil
}
err = model.MenuDelete(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("删除菜单失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "删除成功",
Success: true,
}, nil
}

105
backend/internal/logic/menu/getcurrentmenuslogic.go

@ -0,0 +1,105 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetCurrentMenusLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取当前用户可见菜单
func NewGetCurrentMenusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCurrentMenusLogic {
return &GetCurrentMenusLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetCurrentMenusLogic) GetCurrentMenus() (resp *types.MenuListResponse, err error) {
// 获取用户角色
roleStr, _ := l.ctx.Value("role").(string)
if roleStr == "" {
roleStr = model.RoleGuest
}
// 查找角色
role, err := model.RoleFindOneByCode(l.ctx, l.svcCtx.DB, roleStr)
if err != nil {
return nil, fmt.Errorf("角色不存在: %v", err)
}
// 获取角色关联的菜单ID
roleMenuIds, err := model.RoleMenuFindByRoleId(l.ctx, l.svcCtx.DB, role.Id)
if err != nil {
return nil, fmt.Errorf("获取角色菜单失败: %v", err)
}
// 构建菜单ID集合用于快速查找
menuIdSet := make(map[int64]bool)
for _, id := range roleMenuIds {
menuIdSet[id] = true
}
// 获取所有菜单
allMenus, err := model.MenuFindAll(l.ctx, l.svcCtx.DB)
if err != nil {
return nil, fmt.Errorf("获取菜单列表失败: %v", err)
}
// 过滤:包含角色关联的菜单或 type=default 且 visible=true
var filteredMenus []model.Menu
for _, m := range allMenus {
if menuIdSet[m.Id] || (m.Type == "default" && m.Visible) {
filteredMenus = append(filteredMenus, m)
}
}
// 构建树形结构
tree := buildMenuTree(filteredMenus, 0)
return &types.MenuListResponse{
List: tree,
}, nil
}
func buildMenuTree(menus []model.Menu, parentId int64) []types.MenuItem {
var tree []types.MenuItem
for _, m := range menus {
if m.ParentId == parentId {
item := types.MenuItem{
Id: m.Id,
ParentId: m.ParentId,
Name: m.Name,
Path: m.Path,
Icon: m.Icon,
Component: m.Component,
Type: m.Type,
SortOrder: m.SortOrder,
Visible: m.Visible,
Status: m.Status,
Children: buildMenuTree(menus, m.Id),
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: m.UpdatedAt.Format("2006-01-02 15:04:05"),
}
tree = append(tree, item)
}
}
if tree == nil {
tree = []types.MenuItem{}
}
return tree
}

43
backend/internal/logic/menu/getmenulistlogic.go

@ -0,0 +1,43 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetMenuListLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取全部菜单列表
func NewGetMenuListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMenuListLogic {
return &GetMenuListLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetMenuListLogic) GetMenuList() (resp *types.MenuListResponse, err error) {
menus, err := model.MenuFindAll(l.ctx, l.svcCtx.DB)
if err != nil {
return nil, fmt.Errorf("获取菜单列表失败: %v", err)
}
tree := buildMenuTree(menus, 0)
return &types.MenuListResponse{
List: tree,
}, nil
}

86
backend/internal/logic/menu/updatemenulogic.go

@ -0,0 +1,86 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package menu
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateMenuLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 更新菜单
func NewUpdateMenuLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMenuLogic {
return &UpdateMenuLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateMenuLogic) UpdateMenu(req *types.UpdateMenuRequest) (resp *types.MenuItem, err error) {
menu, err := model.MenuFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("菜单不存在: %v", err)
}
if req.ParentId != nil {
menu.ParentId = *req.ParentId
}
if req.Name != "" {
menu.Name = req.Name
}
if req.Path != "" {
menu.Path = req.Path
}
if req.Icon != "" {
menu.Icon = req.Icon
}
if req.Component != "" {
menu.Component = req.Component
}
if req.Type != "" {
menu.Type = req.Type
}
if req.SortOrder != nil {
menu.SortOrder = *req.SortOrder
}
if req.Visible != nil {
menu.Visible = *req.Visible
}
if req.Status != nil {
menu.Status = *req.Status
}
err = model.MenuUpdate(l.ctx, l.svcCtx.DB, menu)
if err != nil {
return nil, fmt.Errorf("更新菜单失败: %v", err)
}
return &types.MenuItem{
Id: menu.Id,
ParentId: menu.ParentId,
Name: menu.Name,
Path: menu.Path,
Icon: menu.Icon,
Component: menu.Component,
Type: menu.Type,
SortOrder: menu.SortOrder,
Visible: menu.Visible,
Status: menu.Status,
Children: []types.MenuItem{},
CreatedAt: menu.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: menu.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}

72
backend/internal/logic/organization/addorgmemberlogic.go

@ -0,0 +1,72 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type AddOrgMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 添加机构成员
func NewAddOrgMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AddOrgMemberLogic {
return &AddOrgMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AddOrgMemberLogic) AddOrgMember(req *types.AddOrgMemberRequest) (resp *types.Response, err error) {
// 验证机构是否存在
_, err = model.OrgFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("机构不存在: %v", err)
}
// 验证用户是否存在
_, err = model.FindOne(l.ctx, l.svcCtx.DB, req.UserId)
if err != nil {
return nil, fmt.Errorf("用户不存在: %v", err)
}
// 检查是否已经是成员
existing, _ := model.UserOrgFindOne(l.ctx, l.svcCtx.DB, req.UserId, req.Id)
if existing != nil {
return &types.Response{
Code: 400,
Message: "该用户已经是该机构成员",
Success: false,
}, nil
}
// 添加成员
uo := &model.UserOrganization{
UserId: req.UserId,
OrgId: req.Id,
RoleId: req.RoleId,
}
_, err = model.UserOrgInsert(l.ctx, l.svcCtx.DB, uo)
if err != nil {
return nil, fmt.Errorf("添加成员失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "添加成功",
Success: true,
}, nil
}

70
backend/internal/logic/organization/createorganizationlogic.go

@ -0,0 +1,70 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateOrganizationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 创建机构
func NewCreateOrganizationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateOrganizationLogic {
return &CreateOrganizationLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateOrganizationLogic) CreateOrganization(req *types.CreateOrgRequest) (resp *types.OrgInfo, err error) {
// 检查编码唯一性
existing, _ := model.OrgFindOneByCode(l.ctx, l.svcCtx.DB, req.Code)
if existing != nil {
return nil, fmt.Errorf("机构编码 %s 已存在", req.Code)
}
org := &model.Organization{
ParentId: req.ParentId,
Name: req.Name,
Code: req.Code,
Leader: req.Leader,
Phone: req.Phone,
Email: req.Email,
SortOrder: req.SortOrder,
Status: 1,
}
_, err = model.OrgInsert(l.ctx, l.svcCtx.DB, org)
if err != nil {
return nil, fmt.Errorf("创建机构失败: %v", err)
}
return &types.OrgInfo{
Id: org.Id,
ParentId: org.ParentId,
Name: org.Name,
Code: org.Code,
Leader: org.Leader,
Phone: org.Phone,
Email: org.Email,
SortOrder: org.SortOrder,
Status: org.Status,
MemberCount: 0,
Children: []types.OrgInfo{},
CreatedAt: org.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: org.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}

69
backend/internal/logic/organization/deleteorganizationlogic.go

@ -0,0 +1,69 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteOrganizationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 删除机构
func NewDeleteOrganizationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteOrganizationLogic {
return &DeleteOrganizationLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteOrganizationLogic) DeleteOrganization(req *types.DeleteOrgRequest) (resp *types.Response, err error) {
// 检查是否有子机构
hasChildren, err := model.OrgHasChildren(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("检查子机构失败: %v", err)
}
if hasChildren {
return &types.Response{
Code: 400,
Message: "该机构下有子机构,无法删除",
Success: false,
}, nil
}
// 检查是否有成员
memberCount, err := model.UserOrgCountByOrgId(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("检查机构成员失败: %v", err)
}
if memberCount > 0 {
return &types.Response{
Code: 400,
Message: "该机构下有成员,无法删除",
Success: false,
}, nil
}
err = model.OrgDelete(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("删除机构失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "删除成功",
Success: true,
}, nil
}

73
backend/internal/logic/organization/getorganizationlistlogic.go

@ -0,0 +1,73 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
"gorm.io/gorm"
)
type GetOrganizationListLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取机构列表
func NewGetOrganizationListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOrganizationListLogic {
return &GetOrganizationListLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetOrganizationListLogic) GetOrganizationList() (resp *types.OrgListResponse, err error) {
orgs, err := model.OrgFindAll(l.ctx, l.svcCtx.DB)
if err != nil {
return nil, fmt.Errorf("获取机构列表失败: %v", err)
}
tree := buildOrgTree(l.ctx, l.svcCtx.DB, orgs, 0)
return &types.OrgListResponse{
List: tree,
}, nil
}
func buildOrgTree(ctx context.Context, db *gorm.DB, orgs []model.Organization, parentId int64) []types.OrgInfo {
var tree []types.OrgInfo
for _, o := range orgs {
if o.ParentId == parentId {
memberCount, _ := model.UserOrgCountByOrgId(ctx, db, o.Id)
item := types.OrgInfo{
Id: o.Id,
ParentId: o.ParentId,
Name: o.Name,
Code: o.Code,
Leader: o.Leader,
Phone: o.Phone,
Email: o.Email,
SortOrder: o.SortOrder,
Status: o.Status,
MemberCount: memberCount,
Children: buildOrgTree(ctx, db, orgs, o.Id),
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: o.UpdatedAt.Format("2006-01-02 15:04:05"),
}
tree = append(tree, item)
}
}
if tree == nil {
tree = []types.OrgInfo{}
}
return tree
}

71
backend/internal/logic/organization/getorgmemberslogic.go

@ -0,0 +1,71 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetOrgMembersLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取机构成员
func NewGetOrgMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetOrgMembersLogic {
return &GetOrgMembersLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetOrgMembersLogic) GetOrgMembers(req *types.GetOrgMembersRequest) (resp *types.OrgMembersResponse, err error) {
userOrgs, err := model.UserOrgFindByOrgId(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("获取机构成员失败: %v", err)
}
list := make([]types.OrgMember, 0, len(userOrgs))
for _, uo := range userOrgs {
// 查询用户信息
user, err := model.FindOne(l.ctx, l.svcCtx.DB, uo.UserId)
if err != nil {
continue
}
// 查询角色信息
var roleName, roleCode string
if uo.RoleId > 0 {
role, err := model.RoleFindOne(l.ctx, l.svcCtx.DB, uo.RoleId)
if err == nil {
roleName = role.Name
roleCode = role.Code
}
}
list = append(list, types.OrgMember{
UserId: user.Id,
Username: user.Username,
Email: user.Email,
Phone: user.Phone,
RoleId: uo.RoleId,
RoleName: roleName,
RoleCode: roleCode,
CreatedAt: uo.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return &types.OrgMembersResponse{
List: list,
}, nil
}

43
backend/internal/logic/organization/removeorgmemberlogic.go

@ -0,0 +1,43 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type RemoveOrgMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 移除机构成员
func NewRemoveOrgMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveOrgMemberLogic {
return &RemoveOrgMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RemoveOrgMemberLogic) RemoveOrgMember(req *types.RemoveOrgMemberRequest) (resp *types.Response, err error) {
err = model.UserOrgDelete(l.ctx, l.svcCtx.DB, req.UserId, req.Id)
if err != nil {
return nil, fmt.Errorf("移除成员失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "移除成功",
Success: true,
}, nil
}

85
backend/internal/logic/organization/updateorganizationlogic.go

@ -0,0 +1,85 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateOrganizationLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 更新机构
func NewUpdateOrganizationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOrganizationLogic {
return &UpdateOrganizationLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateOrganizationLogic) UpdateOrganization(req *types.UpdateOrgRequest) (resp *types.OrgInfo, err error) {
org, err := model.OrgFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("机构不存在: %v", err)
}
if req.ParentId != nil {
org.ParentId = *req.ParentId
}
if req.Name != "" {
org.Name = req.Name
}
if req.Code != "" {
org.Code = req.Code
}
if req.Leader != "" {
org.Leader = req.Leader
}
if req.Phone != "" {
org.Phone = req.Phone
}
if req.Email != "" {
org.Email = req.Email
}
if req.SortOrder != nil {
org.SortOrder = *req.SortOrder
}
if req.Status != nil {
org.Status = *req.Status
}
err = model.OrgUpdate(l.ctx, l.svcCtx.DB, org)
if err != nil {
return nil, fmt.Errorf("更新机构失败: %v", err)
}
memberCount, _ := model.UserOrgCountByOrgId(l.ctx, l.svcCtx.DB, org.Id)
return &types.OrgInfo{
Id: org.Id,
ParentId: org.ParentId,
Name: org.Name,
Code: org.Code,
Leader: org.Leader,
Phone: org.Phone,
Email: org.Email,
SortOrder: org.SortOrder,
Status: org.Status,
MemberCount: memberCount,
Children: []types.OrgInfo{},
CreatedAt: org.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: org.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}

51
backend/internal/logic/organization/updateorgmemberlogic.go

@ -0,0 +1,51 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package organization
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateOrgMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 更新机构成员角色
func NewUpdateOrgMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOrgMemberLogic {
return &UpdateOrgMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateOrgMemberLogic) UpdateOrgMember(req *types.UpdateOrgMemberRequest) (resp *types.Response, err error) {
// 查找用户-机构关联
uo, err := model.UserOrgFindOne(l.ctx, l.svcCtx.DB, req.UserId, req.Id)
if err != nil {
return nil, fmt.Errorf("该用户不是该机构成员: %v", err)
}
// 更新角色
uo.RoleId = req.RoleId
err = model.UserOrgUpdate(l.ctx, l.svcCtx.DB, uo)
if err != nil {
return nil, fmt.Errorf("更新成员角色失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "更新成功",
Success: true,
}, nil
}

75
backend/internal/logic/profile/getuserorgslogic.go

@ -0,0 +1,75 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package profile
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetUserOrgsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取我的机构列表
func NewGetUserOrgsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserOrgsLogic {
return &GetUserOrgsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetUserOrgsLogic) GetUserOrgs() (resp *types.UserOrgsResponse, err error) {
userId, _ := l.ctx.Value("userId").(int64)
if userId == 0 {
return nil, fmt.Errorf("未获取到用户信息")
}
userOrgs, err := model.UserOrgFindByUserId(l.ctx, l.svcCtx.DB, userId)
if err != nil {
return nil, fmt.Errorf("获取用户机构列表失败: %v", err)
}
list := make([]types.UserOrgInfo, 0, len(userOrgs))
for _, uo := range userOrgs {
// 查询机构信息
org, err := model.OrgFindOne(l.ctx, l.svcCtx.DB, uo.OrgId)
if err != nil {
continue
}
// 查询角色信息
var roleName, roleCode string
var roleId int64
if uo.RoleId > 0 {
role, err := model.RoleFindOne(l.ctx, l.svcCtx.DB, uo.RoleId)
if err == nil {
roleId = role.Id
roleName = role.Name
roleCode = role.Code
}
}
list = append(list, types.UserOrgInfo{
OrgId: org.Id,
OrgName: org.Name,
RoleId: roleId,
RoleName: roleName,
RoleCode: roleCode,
})
}
return &types.UserOrgsResponse{
List: list,
}, nil
}

75
backend/internal/logic/profile/switchorglogic.go

@ -0,0 +1,75 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package profile
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
jwtutil "github.com/youruser/base/internal/util/jwt"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type SwitchOrgLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 切换当前机构
func NewSwitchOrgLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SwitchOrgLogic {
return &SwitchOrgLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *SwitchOrgLogic) SwitchOrg(req *types.SwitchOrgRequest) (resp *types.SwitchOrgResponse, err error) {
userId, _ := l.ctx.Value("userId").(int64)
username, _ := l.ctx.Value("username").(string)
if userId == 0 {
return nil, fmt.Errorf("未获取到用户信息")
}
// 验证用户属于该机构
userOrg, err := model.UserOrgFindOne(l.ctx, l.svcCtx.DB, userId, req.OrgId)
if err != nil {
return nil, fmt.Errorf("您不属于该机构")
}
// 获取角色编码
roleCode := model.RoleUser // 默认角色
if userOrg.RoleId > 0 {
role, err := model.RoleFindOne(l.ctx, l.svcCtx.DB, userOrg.RoleId)
if err == nil {
roleCode = role.Code
}
}
// 更新用户的 CurrentOrgId
user, err := model.FindOne(l.ctx, l.svcCtx.DB, userId)
if err != nil {
return nil, fmt.Errorf("获取用户信息失败: %v", err)
}
user.CurrentOrgId = req.OrgId
err = model.Update(l.ctx, l.svcCtx.DB, user)
if err != nil {
return nil, fmt.Errorf("更新用户机构失败: %v", err)
}
// 生成新的 JWT Token
token, err := jwtutil.GenerateToken(userId, username, roleCode, req.OrgId)
if err != nil {
return nil, fmt.Errorf("生成Token失败: %v", err)
}
return &types.SwitchOrgResponse{
Token: token,
}, nil
}

63
backend/internal/logic/role/createrolelogic.go

@ -0,0 +1,63 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateRoleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 创建角色
func NewCreateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateRoleLogic {
return &CreateRoleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleRequest) (resp *types.RoleInfo, err error) {
// 检查编码唯一性
existing, _ := model.RoleFindOneByCode(l.ctx, l.svcCtx.DB, req.Code)
if existing != nil {
return nil, fmt.Errorf("角色编码 %s 已存在", req.Code)
}
role := &model.Role{
Name: req.Name,
Code: req.Code,
Description: req.Description,
SortOrder: req.SortOrder,
Status: 1,
}
_, err = model.RoleInsert(l.ctx, l.svcCtx.DB, role)
if err != nil {
return nil, fmt.Errorf("创建角色失败: %v", err)
}
return &types.RoleInfo{
Id: role.Id,
Name: role.Name,
Code: role.Code,
Description: role.Description,
IsSystem: role.IsSystem,
SortOrder: role.SortOrder,
Status: role.Status,
CreatedAt: role.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: role.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}

65
backend/internal/logic/role/deleterolelogic.go

@ -0,0 +1,65 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteRoleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 删除角色
func NewDeleteRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteRoleLogic {
return &DeleteRoleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleRequest) (resp *types.Response, err error) {
// 查找角色
role, err := model.RoleFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("角色不存在: %v", err)
}
// 系统角色不允许删除
if role.IsSystem {
return &types.Response{
Code: 400,
Message: "系统角色不允许删除",
Success: false,
}, nil
}
// 删除角色-菜单关联
err = model.RoleMenuDeleteByRoleId(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("删除角色菜单关联失败: %v", err)
}
// 删除角色
err = model.RoleDelete(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("删除角色失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "删除成功",
Success: true,
}, nil
}

56
backend/internal/logic/role/getrolelistlogic.go

@ -0,0 +1,56 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRoleListLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取角色列表
func NewGetRoleListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRoleListLogic {
return &GetRoleListLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRoleListLogic) GetRoleList() (resp *types.RoleListResponse, err error) {
roles, err := model.RoleFindAll(l.ctx, l.svcCtx.DB)
if err != nil {
return nil, fmt.Errorf("获取角色列表失败: %v", err)
}
list := make([]types.RoleInfo, 0, len(roles))
for _, r := range roles {
list = append(list, types.RoleInfo{
Id: r.Id,
Name: r.Name,
Code: r.Code,
Description: r.Description,
IsSystem: r.IsSystem,
SortOrder: r.SortOrder,
Status: r.Status,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
})
}
return &types.RoleListResponse{
List: list,
}, nil
}

45
backend/internal/logic/role/getrolemenuslogic.go

@ -0,0 +1,45 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRoleMenusLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 获取角色菜单
func NewGetRoleMenusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRoleMenusLogic {
return &GetRoleMenusLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRoleMenusLogic) GetRoleMenus(req *types.GetRoleMenusRequest) (resp *types.RoleMenusResponse, err error) {
menuIds, err := model.RoleMenuFindByRoleId(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("获取角色菜单失败: %v", err)
}
if menuIds == nil {
menuIds = []int64{}
}
return &types.RoleMenusResponse{
MenuIds: menuIds,
}, nil
}

50
backend/internal/logic/role/setrolemenuslogic.go

@ -0,0 +1,50 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type SetRoleMenusLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 设置角色菜单
func NewSetRoleMenusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetRoleMenusLogic {
return &SetRoleMenusLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *SetRoleMenusLogic) SetRoleMenus(req *types.SetRoleMenusRequest) (resp *types.Response, err error) {
// 验证角色是否存在
_, err = model.RoleFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("角色不存在: %v", err)
}
// 全量设置角色的菜单
err = model.RoleMenuSetForRole(l.ctx, l.svcCtx.DB, req.Id, req.MenuIds)
if err != nil {
return nil, fmt.Errorf("设置角色菜单失败: %v", err)
}
return &types.Response{
Code: 0,
Message: "设置成功",
Success: true,
}, nil
}

67
backend/internal/logic/role/updaterolelogic.go

@ -0,0 +1,67 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.9.2
package role
import (
"context"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateRoleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 更新角色
func NewUpdateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateRoleLogic {
return &UpdateRoleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleRequest) (resp *types.RoleInfo, err error) {
role, err := model.RoleFindOne(l.ctx, l.svcCtx.DB, req.Id)
if err != nil {
return nil, fmt.Errorf("角色不存在: %v", err)
}
if req.Name != "" {
role.Name = req.Name
}
if req.Description != "" {
role.Description = req.Description
}
if req.SortOrder != nil {
role.SortOrder = *req.SortOrder
}
if req.Status != nil {
role.Status = *req.Status
}
err = model.RoleUpdate(l.ctx, l.svcCtx.DB, role)
if err != nil {
return nil, fmt.Errorf("更新角色失败: %v", err)
}
return &types.RoleInfo{
Id: role.Id,
Name: role.Name,
Code: role.Code,
Description: role.Description,
IsSystem: role.IsSystem,
SortOrder: role.SortOrder,
Status: role.Status,
CreatedAt: role.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: role.UpdatedAt.Format("2006-01-02 15:04:05"),
}, nil
}

19
backend/internal/middleware/authmiddleware.go

@ -18,15 +18,19 @@ func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// 从 Header 中获取 Token // 从 Header 中获取 Token
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { var tokenString string
http.Error(w, "Unauthorized", http.StatusUnauthorized) if authHeader != "" {
return // Token 格式: "Bearer <token>"
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
}
// 回退: 从 query 参数获取 token(用于 img/video/iframe 等无法设置 Header 的场景)
if tokenString == "" {
tokenString = r.URL.Query().Get("token")
} }
// Token 格式: "Bearer <token>"
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == "" { if tokenString == "" {
http.Error(w, "Invalid token format", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@ -40,7 +44,8 @@ func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
// 将 userId 存入上下文,供后续 logic 使用 // 将 userId 存入上下文,供后续 logic 使用
ctx := context.WithValue(r.Context(), "userId", claims.UserID) ctx := context.WithValue(r.Context(), "userId", claims.UserID)
ctx = context.WithValue(ctx, "username", claims.Username) ctx = context.WithValue(ctx, "username", claims.Username)
ctx = context.WithValue(ctx, "email", claims.Email) ctx = context.WithValue(ctx, "role", claims.Role)
ctx = context.WithValue(ctx, "currentOrgId", claims.CurrentOrgId)
// 传递给下一个处理器 // 传递给下一个处理器
next(w, r.WithContext(ctx)) next(w, r.WithContext(ctx))

12
backend/internal/middleware/corsmiddleware.go

@ -14,9 +14,17 @@ func NewCorsMiddleware() *CorsMiddleware {
func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// TODO generate middleware implement function, delete after code implementation // Set CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Passthrough to next handler if need
next(w, r) next(w, r)
} }
} }

310
backend/internal/svc/servicecontext.go

@ -4,8 +4,18 @@
package svc package svc
import ( import (
"context"
"crypto/md5"
"fmt"
"log"
"github.com/casbin/casbin/v2"
casbinmodel "github.com/casbin/casbin/v2/model"
gormadapter "github.com/casbin/gorm-adapter/v3"
"github.com/youruser/base/internal/config" "github.com/youruser/base/internal/config"
"github.com/youruser/base/internal/middleware" "github.com/youruser/base/internal/middleware"
"github.com/youruser/base/internal/storage"
"github.com/youruser/base/model" "github.com/youruser/base/model"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@ -14,13 +24,35 @@ import (
"github.com/zeromicro/go-zero/rest" "github.com/zeromicro/go-zero/rest"
) )
const casbinModelText = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act
`
type ServiceContext struct { type ServiceContext struct {
Config config.Config Config config.Config
Cors rest.Middleware Cors rest.Middleware
Log rest.Middleware Log rest.Middleware
Auth rest.Middleware Auth rest.Middleware
Authz rest.Middleware
// 数据库连接 // 数据库连接
DB *gorm.DB DB *gorm.DB
// Casbin enforcer
Enforcer *casbin.Enforcer
// 文件存储
Storage storage.Storage
} }
func NewServiceContext(c config.Config) *ServiceContext { func NewServiceContext(c config.Config) *ServiceContext {
@ -32,17 +64,41 @@ func NewServiceContext(c config.Config) *ServiceContext {
} }
// 自动迁移表 // 自动迁移表
err = db.AutoMigrate(&model.User{}, &model.Profile{}) err = db.AutoMigrate(&model.User{}, &model.Profile{}, &model.File{}, &model.Menu{}, &model.Role{}, &model.RoleMenu{}, &model.Organization{}, &model.UserOrganization{})
if err != nil { if err != nil {
panic("Failed to migrate database: " + err.Error()) panic("Failed to migrate database: " + err.Error())
} }
// 初始化 Casbin
enforcer := initCasbin(db)
// 种子超级管理员
seedSuperAdmin(db)
// 种子 Casbin 策略
seedCasbinPolicies(enforcer)
// 种子角色、菜单、角色-菜单关联
seedRoles(db)
seedMenus(db)
seedRoleMenus(db)
// 初始化存储
store, err := storage.NewStorage(c.Storage)
if err != nil {
panic("Failed to initialize storage: " + err.Error())
}
log.Printf("[Storage] Initialized with type: %s", c.Storage.Type)
return &ServiceContext{ return &ServiceContext{
Config: c, Config: c,
Cors: middleware.NewCorsMiddleware().Handle, Cors: middleware.NewCorsMiddleware().Handle,
Log: middleware.NewLogMiddleware().Handle, Log: middleware.NewLogMiddleware().Handle,
Auth: middleware.NewAuthMiddleware().Handle, Auth: middleware.NewAuthMiddleware().Handle,
DB: db, Authz: middleware.NewAuthzMiddleware(enforcer).Handle,
DB: db,
Enforcer: enforcer,
Storage: store,
} }
} }
@ -57,3 +113,245 @@ func (s *ServiceContext) Close() error {
} }
return nil return nil
} }
// initCasbin 初始化 Casbin enforcer
func initCasbin(db *gorm.DB) *casbin.Enforcer {
// 使用 GORM adapter(自动创建 casbin_rule 表)
adapter, err := gormadapter.NewAdapterByDB(db)
if err != nil {
panic("Failed to create Casbin adapter: " + err.Error())
}
// 从字符串加载 model
m, err := casbinmodel.NewModelFromString(casbinModelText)
if err != nil {
panic("Failed to create Casbin model: " + err.Error())
}
enforcer, err := casbin.NewEnforcer(m, adapter)
if err != nil {
panic("Failed to create Casbin enforcer: " + err.Error())
}
// 加载策略
if err := enforcer.LoadPolicy(); err != nil {
panic("Failed to load Casbin policy: " + err.Error())
}
log.Println("[Casbin] Enforcer initialized successfully")
return enforcer
}
// seedSuperAdmin 首次启动创建超级管理员
func seedSuperAdmin(db *gorm.DB) {
ctx := context.Background()
existing, err := model.FindOneByUsername(ctx, db, "admin")
if err == nil {
if existing.Role != model.RoleSuperAdmin {
existing.Role = model.RoleSuperAdmin
existing.Source = model.SourceSystem
model.Update(ctx, db, existing)
log.Println("[Seed] Updated admin to super_admin role")
}
return
}
password := fmt.Sprintf("%x", md5.Sum([]byte("admin123")))
admin := &model.User{
Username: "admin",
Phone: "13800000000",
Email: "",
Password: password,
Role: model.RoleSuperAdmin,
Source: model.SourceSystem,
Remark: "系统自动创建的超级管理员",
Status: 1,
}
_, err = model.Insert(ctx, db, admin)
if err != nil {
log.Printf("[Seed] Failed to create super admin: %v", err)
return
}
log.Println("[Seed] Super admin created: admin / admin123")
}
// seedCasbinPolicies 种子 Casbin 策略(幂等)
func seedCasbinPolicies(enforcer *casbin.Enforcer) {
// 角色层级: super_admin > admin > user > guest
roleHierarchy := [][]string{
{"super_admin", "admin"},
{"admin", "user"},
{"user", "guest"},
}
for _, g := range roleHierarchy {
if has, _ := enforcer.HasGroupingPolicy(g[0], g[1]); !has {
enforcer.AddGroupingPolicy(g[0], g[1])
}
}
// 默认策略
policies := [][]string{
// guest: 仪表盘只读
{"guest", "/api/v1/dashboard/*", "GET"},
// user: 个人中心
{"user", "/api/v1/profile/*", "GET"},
{"user", "/api/v1/profile/*", "PUT"},
{"user", "/api/v1/profile/*", "POST"},
// admin: 用户管理(增查改)
{"admin", "/api/v1/users", "GET"},
{"admin", "/api/v1/user", "POST"},
{"admin", "/api/v1/user/:id", "GET"},
{"admin", "/api/v1/user/:id", "PUT"},
// super_admin: 用户删除
{"super_admin", "/api/v1/user/:id", "DELETE"},
// user: 文件管理
{"user", "/api/v1/file/upload", "POST"},
{"user", "/api/v1/files", "GET"},
{"user", "/api/v1/file/:id", "GET"},
{"user", "/api/v1/file/:id/url", "GET"},
{"user", "/api/v1/file/:id", "PUT"},
// super_admin: 文件删除
{"super_admin", "/api/v1/file/:id", "DELETE"},
// user: 个人机构相关
{"user", "/api/v1/profile/orgs", "GET"},
{"user", "/api/v1/profile/current-org", "PUT"},
// admin: 菜单管理(读取)
{"admin", "/api/v1/menus", "GET"},
// super_admin: 菜单管理(增删改)
{"super_admin", "/api/v1/menu", "POST"},
{"super_admin", "/api/v1/menu/:id", "PUT"},
{"super_admin", "/api/v1/menu/:id", "DELETE"},
// admin: 角色管理(读取)
{"admin", "/api/v1/roles", "GET"},
{"admin", "/api/v1/role/:id/menus", "GET"},
// super_admin: 角色管理(增删改)
{"super_admin", "/api/v1/role", "POST"},
{"super_admin", "/api/v1/role/:id", "PUT"},
{"super_admin", "/api/v1/role/:id", "DELETE"},
{"super_admin", "/api/v1/role/:id/menus", "PUT"},
// admin: 机构管理
{"admin", "/api/v1/organizations", "GET"},
{"admin", "/api/v1/organization", "POST"},
{"admin", "/api/v1/organization/:id", "PUT"},
{"admin", "/api/v1/organization/:id/members", "GET"},
{"admin", "/api/v1/organization/:id/member", "POST"},
{"admin", "/api/v1/organization/:id/member/:userId", "PUT"},
{"admin", "/api/v1/organization/:id/member/:userId", "DELETE"},
// super_admin: 机构删除
{"super_admin", "/api/v1/organization/:id", "DELETE"},
}
for _, p := range policies {
if has, _ := enforcer.HasPolicy(p[0], p[1], p[2]); !has {
enforcer.AddPolicy(p[0], p[1], p[2])
}
}
enforcer.SavePolicy()
log.Println("[Casbin] Policies seeded successfully")
}
// seedRoles 种子系统角色(幂等)
func seedRoles(db *gorm.DB) {
roles := []model.Role{
{Name: "超级管理员", Code: model.RoleSuperAdmin, Description: "系统超级管理员", IsSystem: true, SortOrder: 1, Status: 1},
{Name: "管理员", Code: model.RoleAdmin, Description: "系统管理员", IsSystem: true, SortOrder: 2, Status: 1},
{Name: "普通用户", Code: model.RoleUser, Description: "普通用户", IsSystem: true, SortOrder: 3, Status: 1},
{Name: "访客", Code: model.RoleGuest, Description: "访客", IsSystem: true, SortOrder: 4, Status: 1},
}
for _, r := range roles {
var existing model.Role
if err := db.Where("code = ?", r.Code).First(&existing).Error; err != nil {
db.Create(&r)
}
}
log.Println("[Seed] Roles seeded successfully")
}
// seedMenus 种子默认菜单(幂等)
func seedMenus(db *gorm.DB) {
menus := []model.Menu{
{Name: "我的", Path: "/my", Icon: "User", Type: "default", SortOrder: 1, Visible: true, Status: 1},
{Name: "仪表盘", Path: "/dashboard", Icon: "LayoutDashboard", Type: "config", SortOrder: 2, Visible: true, Status: 1},
{Name: "用户管理", Path: "/users", Icon: "Users", Type: "config", SortOrder: 3, Visible: true, Status: 1},
{Name: "文件管理", Path: "/files", Icon: "FolderOpen", Type: "config", SortOrder: 4, Visible: true, Status: 1},
{Name: "角色管理", Path: "/roles", Icon: "Shield", Type: "config", SortOrder: 5, Visible: true, Status: 1},
{Name: "菜单管理", Path: "/menus", Icon: "Menu", Type: "config", SortOrder: 6, Visible: true, Status: 1},
{Name: "机构管理", Path: "/organizations", Icon: "Building2", Type: "config", SortOrder: 7, Visible: true, Status: 1},
{Name: "设置", Path: "/settings", Icon: "Settings", Type: "default", SortOrder: 8, Visible: true, Status: 1},
}
for _, m := range menus {
var existing model.Menu
if err := db.Where("path = ?", m.Path).First(&existing).Error; err != nil {
db.Create(&m)
}
}
log.Println("[Seed] Menus seeded successfully")
}
// seedRoleMenus 种子角色-菜单关联(幂等)
func seedRoleMenus(db *gorm.DB) {
// 获取所有角色
var roles []model.Role
db.Find(&roles)
// 获取所有菜单
var menus []model.Menu
db.Find(&menus)
if len(roles) == 0 || len(menus) == 0 {
return
}
// 构建菜单分类
var allMenuIds []int64
var defaultMenuIds []int64
for _, m := range menus {
allMenuIds = append(allMenuIds, m.Id)
if m.Type == "default" {
defaultMenuIds = append(defaultMenuIds, m.Id)
}
}
for _, r := range roles {
// 检查角色是否已有菜单关联
var count int64
db.Model(&model.RoleMenu{}).Where("role_id = ?", r.Id).Count(&count)
if count > 0 {
continue
}
var menuIds []int64
switch r.Code {
case model.RoleSuperAdmin, model.RoleAdmin:
menuIds = allMenuIds
case model.RoleUser, model.RoleGuest:
menuIds = defaultMenuIds
}
if len(menuIds) > 0 {
records := make([]model.RoleMenu, 0, len(menuIds))
for _, menuId := range menuIds {
records = append(records, model.RoleMenu{RoleId: r.Id, MenuId: menuId})
}
db.Create(&records)
}
}
log.Println("[Seed] RoleMenus seeded successfully")
}

282
backend/internal/types/types.go

@ -3,22 +3,130 @@
package types package types
type Activity struct {
Id int64 `json:"id"` // 记录ID
User string `json:"user"` // 用户邮箱
Action string `json:"action"` // 操作
Time string `json:"time"` // 时间描述
Status string `json:"status"` // 状态 success/error
}
type AddOrgMemberRequest struct {
Id int64 `path:"id"`
UserId int64 `json:"userId" validate:"required"`
RoleId int64 `json:"roleId" validate:"required"`
}
type ChangePasswordRequest struct { type ChangePasswordRequest struct {
OldPassword string `json:"oldPassword" validate:"required,min=6,max=32"` // 旧密码 OldPassword string `json:"oldPassword" validate:"required,min=6,max=32"` // 旧密码
NewPassword string `json:"newPassword" validate:"required,min=6,max=32"` // 新密码 NewPassword string `json:"newPassword" validate:"required,min=6,max=32"` // 新密码
} }
type CreateMenuRequest struct {
ParentId int64 `json:"parentId,optional"`
Name string `json:"name" validate:"required"`
Path string `json:"path,optional"`
Icon string `json:"icon,optional"`
Component string `json:"component,optional"`
Type string `json:"type,optional"`
SortOrder int `json:"sortOrder,optional"`
Visible *bool `json:"visible,optional"`
}
type CreateOrgRequest struct {
ParentId int64 `json:"parentId,optional"`
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required"`
Leader string `json:"leader,optional"`
Phone string `json:"phone,optional"`
Email string `json:"email,optional"`
SortOrder int `json:"sortOrder,optional"`
}
type CreateRoleRequest struct {
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required"`
Description string `json:"description,optional"`
SortOrder int `json:"sortOrder,optional"`
}
type CreateUserRequest struct { type CreateUserRequest struct {
Username string `json:"username" validate:"required,min=3,max=32"` // 用户名 Username string `json:"username" validate:"required,min=3,max=32"` // 用户名
Email string `json:"email" validate:"required,email"` // 邮箱 Email string `json:"email" validate:"required,email"` // 邮箱
Password string `json:"password" validate:"required,min=6,max=32"` // 密码 Password string `json:"password" validate:"required,min=6,max=32"` // 密码
Phone string `json:"phone,optional"` // 手机号 Phone string `json:"phone,optional"` // 手机号
Role string `json:"role,optional"` // 角色
Remark string `json:"remark,optional"` // 备注
}
type DashboardStatsResponse struct {
TotalUsers int64 `json:"totalUsers"` // 总用户数
ActiveUsers int64 `json:"activeUsers"` // 活跃用户数
SystemLoad int `json:"systemLoad"` // 系统负载 0-100
DbStatus string `json:"dbStatus"` // 数据库状态
UserGrowth int `json:"userGrowth"` // 用户增长率
}
type DeleteFileRequest struct {
Id int64 `path:"id"`
}
type DeleteMenuRequest struct {
Id int64 `path:"id"`
}
type DeleteOrgRequest struct {
Id int64 `path:"id"`
}
type DeleteRoleRequest struct {
Id int64 `path:"id"`
} }
type DeleteUserRequest struct { type DeleteUserRequest struct {
Id int64 `path:"id" validate:"required,min=1"` // 用户ID Id int64 `path:"id" validate:"required,min=1"` // 用户ID
} }
type FileInfo struct {
Id int64 `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
Size int64 `json:"size"`
MimeType string `json:"mimeType"`
Category string `json:"category"`
IsPublic bool `json:"isPublic"`
UserId int64 `json:"userId"`
StorageType string `json:"storageType"`
Url string `json:"url"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type FileListRequest struct {
Page int `form:"page,default=1"`
PageSize int `form:"pageSize,default=20"`
Keyword string `form:"keyword,optional"`
Category string `form:"category,optional"`
MimeType string `form:"mimeType,optional"`
}
type FileListResponse struct {
Total int64 `json:"total"`
List []FileInfo `json:"list"`
}
type FileUrlResponse struct {
Url string `json:"url"`
}
type GetFileRequest struct {
Id int64 `path:"id"`
}
type GetOrgMembersRequest struct {
Id int64 `path:"id"`
}
type GetProfileResponse struct { type GetProfileResponse struct {
Id int64 `json:"id"` // 用户ID Id int64 `json:"id"` // 用户ID
Username string `json:"username"` // 用户名 Username string `json:"username"` // 用户名
@ -31,12 +139,16 @@ type GetProfileResponse struct {
UpdatedAt string `json:"updatedAt"` // 更新时间 UpdatedAt string `json:"updatedAt"` // 更新时间
} }
type GetRoleMenusRequest struct {
Id int64 `path:"id"`
}
type GetUserRequest struct { type GetUserRequest struct {
Id int64 `path:"id" validate:"required,min=1"` // 用户ID Id int64 `path:"id" validate:"required,min=1"` // 用户ID
} }
type LoginRequest struct { type LoginRequest struct {
Email string `json:"email" validate:"required,email"` // 邮箱 Account string `json:"account" validate:"required"` // 手机号或用户名
Password string `json:"password" validate:"required,min=6,max=32"` // 密码 Password string `json:"password" validate:"required,min=6,max=32"` // 密码
} }
@ -47,15 +159,83 @@ type LoginResponse struct {
Token string `json:"token"` // JWT Token Token string `json:"token"` // JWT Token
} }
type MenuItem struct {
Id int64 `json:"id"`
ParentId int64 `json:"parentId"`
Name string `json:"name"`
Path string `json:"path"`
Icon string `json:"icon"`
Component string `json:"component"`
Type string `json:"type"`
SortOrder int `json:"sortOrder"`
Visible bool `json:"visible"`
Status int `json:"status"`
Children []MenuItem `json:"children"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type MenuListResponse struct {
List []MenuItem `json:"list"`
}
type OrgInfo struct {
Id int64 `json:"id"`
ParentId int64 `json:"parentId"`
Name string `json:"name"`
Code string `json:"code"`
Leader string `json:"leader"`
Phone string `json:"phone"`
Email string `json:"email"`
SortOrder int `json:"sortOrder"`
Status int `json:"status"`
MemberCount int64 `json:"memberCount"`
Children []OrgInfo `json:"children"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type OrgListResponse struct {
List []OrgInfo `json:"list"`
}
type OrgMember struct {
UserId int64 `json:"userId"`
Username string `json:"username"`
Email string `json:"email"`
Phone string `json:"phone"`
RoleId int64 `json:"roleId"`
RoleName string `json:"roleName"`
RoleCode string `json:"roleCode"`
CreatedAt string `json:"createdAt"`
}
type OrgMembersResponse struct {
List []OrgMember `json:"list"`
}
type RecentActivitiesRequest struct {
Limit int `form:"limit,default=10"` // 数量限制
}
type RecentActivitiesResponse struct {
Activities []Activity `json:"activities"` // 活动列表
}
type RefreshTokenRequest struct { type RefreshTokenRequest struct {
Token string `json:"token" validate:"required"` // Token Token string `json:"token" validate:"required"` // Token
} }
type RegisterRequest struct { type RegisterRequest struct {
Username string `json:"username" validate:"required,min=3,max=32"` // 用户名 Username string `json:"username" validate:"required,min=3,max=32"` // 用户名
Email string `json:"email" validate:"required,email"` // 邮箱
Password string `json:"password" validate:"required,min=6,max=32"` // 密码 Password string `json:"password" validate:"required,min=6,max=32"` // 密码
Phone string `json:"phone,optional"` // 手机号 Phone string `json:"phone" validate:"required"` // 手机号(必填)
Email string `json:"email,optional"` // 邮箱(可选)
}
type RemoveOrgMemberRequest struct {
Id int64 `path:"id"`
UserId int64 `path:"userId"`
} }
type Response struct { type Response struct {
@ -65,6 +245,77 @@ type Response struct {
Data interface{} `json:"data"` // 数据 Data interface{} `json:"data"` // 数据
} }
type RoleInfo struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Description string `json:"description"`
IsSystem bool `json:"isSystem"`
SortOrder int `json:"sortOrder"`
Status int `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type RoleListResponse struct {
List []RoleInfo `json:"list"`
}
type RoleMenusResponse struct {
MenuIds []int64 `json:"menuIds"`
}
type SetRoleMenusRequest struct {
Id int64 `path:"id"`
MenuIds []int64 `json:"menuIds"`
}
type SwitchOrgRequest struct {
OrgId int64 `json:"orgId" validate:"required"`
}
type SwitchOrgResponse struct {
Token string `json:"token"`
}
type UpdateFileRequest struct {
Id int64 `path:"id"`
Name string `json:"name,optional"`
Category string `json:"category,optional"`
IsPublic *bool `json:"isPublic,optional"`
}
type UpdateMenuRequest struct {
Id int64 `path:"id"`
ParentId *int64 `json:"parentId,optional"`
Name string `json:"name,optional"`
Path string `json:"path,optional"`
Icon string `json:"icon,optional"`
Component string `json:"component,optional"`
Type string `json:"type,optional"`
SortOrder *int `json:"sortOrder,optional"`
Visible *bool `json:"visible,optional"`
Status *int `json:"status,optional"`
}
type UpdateOrgMemberRequest struct {
Id int64 `path:"id"`
UserId int64 `path:"userId"`
RoleId int64 `json:"roleId" validate:"required"`
}
type UpdateOrgRequest struct {
Id int64 `path:"id"`
ParentId *int64 `json:"parentId,optional"`
Name string `json:"name,optional"`
Code string `json:"code,optional"`
Leader string `json:"leader,optional"`
Phone string `json:"phone,optional"`
Email string `json:"email,optional"`
SortOrder *int `json:"sortOrder,optional"`
Status *int `json:"status,optional"`
}
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
Username string `json:"username,optional" validate:"min=3,max=32"` // 用户名 Username string `json:"username,optional" validate:"min=3,max=32"` // 用户名
Phone string `json:"phone,optional"` // 手机号 Phone string `json:"phone,optional"` // 手机号
@ -72,12 +323,22 @@ type UpdateProfileRequest struct {
Bio string `json:"bio,optional"` // 个人简介 Bio string `json:"bio,optional"` // 个人简介
} }
type UpdateRoleRequest struct {
Id int64 `path:"id"`
Name string `json:"name,optional"`
Description string `json:"description,optional"`
SortOrder *int `json:"sortOrder,optional"`
Status *int `json:"status,optional"`
}
type UpdateUserRequest struct { type UpdateUserRequest struct {
Id int64 `path:"id" validate:"required,min=1"` // 用户ID Id int64 `path:"id" validate:"required,min=1"` // 用户ID
Username string `json:"username,optional"` // 用户名 Username string `json:"username,optional"` // 用户名
Email string `json:"email,optional"` // 邮箱 Email string `json:"email,optional"` // 邮箱
Phone string `json:"phone,optional"` // 手机号 Phone string `json:"phone,optional"` // 手机号
Status int `json:"status,optional"` // 状态 Status int `json:"status,optional"` // 状态
Role string `json:"role,optional"` // 角色
Remark string `json:"remark,optional"` // 备注
} }
type UserInfo struct { type UserInfo struct {
@ -85,6 +346,9 @@ type UserInfo struct {
Username string `json:"username"` // 用户名 Username string `json:"username"` // 用户名
Email string `json:"email"` // 邮箱 Email string `json:"email"` // 邮箱
Phone string `json:"phone"` // 手机号 Phone string `json:"phone"` // 手机号
Role string `json:"role"` // 角色
Source string `json:"source"` // 来源
Remark string `json:"remark"` // 备注
Status int `json:"status"` // 状态 1-正常 2-禁用 Status int `json:"status"` // 状态 1-正常 2-禁用
CreatedAt string `json:"createdAt"` // 创建时间 CreatedAt string `json:"createdAt"` // 创建时间
UpdatedAt string `json:"updatedAt"` // 更新时间 UpdatedAt string `json:"updatedAt"` // 更新时间
@ -101,3 +365,15 @@ type UserListResponse struct {
Total int64 `json:"total"` // 总数 Total int64 `json:"total"` // 总数
List []UserInfo `json:"list"` // 用户列表 List []UserInfo `json:"list"` // 用户列表
} }
type UserOrgInfo struct {
OrgId int64 `json:"orgId"`
OrgName string `json:"orgName"`
RoleId int64 `json:"roleId"`
RoleName string `json:"roleName"`
RoleCode string `json:"roleCode"`
}
type UserOrgsResponse struct {
List []UserOrgInfo `json:"list"`
}

16
backend/internal/util/jwt/jwt.go

@ -16,18 +16,20 @@ var (
) )
type Claims struct { type Claims struct {
UserID int64 `json:"userId"` UserID int64 `json:"userId"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Role string `json:"role"`
CurrentOrgId int64 `json:"currentOrgId"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// GenerateToken 生成 JWT Token // GenerateToken 生成 JWT Token
func GenerateToken(userId int64, username, email string) (string, error) { func GenerateToken(userId int64, username, role string, currentOrgId int64) (string, error) {
claims := Claims{ claims := Claims{
UserID: userId, UserID: userId,
Username: username, Username: username,
Email: email, Role: role,
CurrentOrgId: currentOrgId,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpireTime)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenExpireTime)),
Issuer: "base-api", Issuer: "base-api",

39
backend/internal/util/jwt/jwt_test.go

@ -0,0 +1,39 @@
package jwt
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateToken_ContainsRole(t *testing.T) {
token, err := GenerateToken(1, "testuser", "admin", 0)
require.NoError(t, err)
require.NotEmpty(t, token)
// 解析 token 验证 role
claims, err := ParseToken(token)
require.NoError(t, err)
assert.Equal(t, int64(1), claims.UserID)
assert.Equal(t, "testuser", claims.Username)
assert.Equal(t, "admin", claims.Role)
}
func TestGenerateToken_SuperAdminRole(t *testing.T) {
token, err := GenerateToken(99, "admin", "super_admin", 0)
require.NoError(t, err)
claims, err := ParseToken(token)
require.NoError(t, err)
assert.Equal(t, "super_admin", claims.Role)
}
func TestGenerateToken_EmptyRole(t *testing.T) {
token, err := GenerateToken(1, "user", "", 0)
require.NoError(t, err)
claims, err := ParseToken(token)
require.NoError(t, err)
assert.Equal(t, "", claims.Role)
}

23
backend/model/menu_entity.go

@ -0,0 +1,23 @@
package model
import "time"
// Menu 菜单模型
type Menu struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ParentId int64 `gorm:"column:parent_id;default:0;index" json:"parentId"`
Name string `gorm:"column:name;type:varchar(50);not null" json:"name"`
Path string `gorm:"column:path;type:varchar(200);default:''" json:"path"`
Icon string `gorm:"column:icon;type:varchar(50);default:''" json:"icon"`
Component string `gorm:"column:component;type:varchar(200);default:''" json:"component"`
Type string `gorm:"column:type;type:varchar(20);default:'config'" json:"type"` // default or config
SortOrder int `gorm:"column:sort_order;default:0" json:"sortOrder"`
Visible bool `gorm:"column:visible;default:true" json:"visible"`
Status int `gorm:"column:status;default:1" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"`
}
func (Menu) TableName() string {
return "menu"
}

64
backend/model/menu_model.go

@ -0,0 +1,64 @@
package model
import (
"context"
"errors"
"gorm.io/gorm"
)
// MenuInsert 插入菜单
func MenuInsert(ctx context.Context, db *gorm.DB, menu *Menu) (int64, error) {
result := db.WithContext(ctx).Create(menu)
if result.Error != nil {
return 0, result.Error
}
return menu.Id, nil
}
// MenuFindOne 根据ID查询菜单
func MenuFindOne(ctx context.Context, db *gorm.DB, id int64) (*Menu, error) {
var menu Menu
result := db.WithContext(ctx).First(&menu, id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &menu, nil
}
// MenuFindAll 查询所有启用的菜单(排序)
func MenuFindAll(ctx context.Context, db *gorm.DB) ([]Menu, error) {
var menus []Menu
err := db.WithContext(ctx).Where("status = 1").Order("sort_order ASC, id ASC").Find(&menus).Error
return menus, err
}
// MenuFindByIds 根据ID列表查询菜单
func MenuFindByIds(ctx context.Context, db *gorm.DB, ids []int64) ([]Menu, error) {
var menus []Menu
if len(ids) == 0 {
return menus, nil
}
err := db.WithContext(ctx).Where("id IN ? AND status = 1", ids).Order("sort_order ASC, id ASC").Find(&menus).Error
return menus, err
}
// MenuUpdate 更新菜单
func MenuUpdate(ctx context.Context, db *gorm.DB, menu *Menu) error {
return db.WithContext(ctx).Save(menu).Error
}
// MenuDelete 删除菜单
func MenuDelete(ctx context.Context, db *gorm.DB, id int64) error {
return db.WithContext(ctx).Delete(&Menu{}, id).Error
}
// MenuHasChildren 检查菜单是否有子菜单
func MenuHasChildren(ctx context.Context, db *gorm.DB, parentId int64) (bool, error) {
var count int64
err := db.WithContext(ctx).Model(&Menu{}).Where("parent_id = ?", parentId).Count(&count).Error
return count > 0, err
}

22
backend/model/organization_entity.go

@ -0,0 +1,22 @@
package model
import "time"
// Organization 机构模型(树形)
type Organization struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ParentId int64 `gorm:"column:parent_id;default:0;index" json:"parentId"`
Name string `gorm:"column:name;type:varchar(100);not null" json:"name"`
Code string `gorm:"column:code;type:varchar(50);uniqueIndex" json:"code"`
Leader string `gorm:"column:leader;type:varchar(50);default:''" json:"leader"`
Phone string `gorm:"column:phone;type:varchar(20);default:''" json:"phone"`
Email string `gorm:"column:email;type:varchar(100);default:''" json:"email"`
SortOrder int `gorm:"column:sort_order;default:0" json:"sortOrder"`
Status int `gorm:"column:status;default:1" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"`
}
func (Organization) TableName() string {
return "organization"
}

67
backend/model/organization_model.go

@ -0,0 +1,67 @@
package model
import (
"context"
"errors"
"gorm.io/gorm"
)
// OrgInsert 插入机构
func OrgInsert(ctx context.Context, db *gorm.DB, org *Organization) (int64, error) {
result := db.WithContext(ctx).Create(org)
if result.Error != nil {
return 0, result.Error
}
return org.Id, nil
}
// OrgFindOne 根据ID查询机构
func OrgFindOne(ctx context.Context, db *gorm.DB, id int64) (*Organization, error) {
var org Organization
result := db.WithContext(ctx).First(&org, id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &org, nil
}
// OrgFindAll 查询所有启用的机构
func OrgFindAll(ctx context.Context, db *gorm.DB) ([]Organization, error) {
var orgs []Organization
err := db.WithContext(ctx).Where("status = 1").Order("sort_order ASC, id ASC").Find(&orgs).Error
return orgs, err
}
// OrgUpdate 更新机构
func OrgUpdate(ctx context.Context, db *gorm.DB, org *Organization) error {
return db.WithContext(ctx).Save(org).Error
}
// OrgDelete 删除机构
func OrgDelete(ctx context.Context, db *gorm.DB, id int64) error {
return db.WithContext(ctx).Delete(&Organization{}, id).Error
}
// OrgHasChildren 检查是否有子机构
func OrgHasChildren(ctx context.Context, db *gorm.DB, parentId int64) (bool, error) {
var count int64
err := db.WithContext(ctx).Model(&Organization{}).Where("parent_id = ? AND status = 1", parentId).Count(&count).Error
return count > 0, err
}
// OrgFindOneByCode 根据编码查询机构
func OrgFindOneByCode(ctx context.Context, db *gorm.DB, code string) (*Organization, error) {
var org Organization
result := db.WithContext(ctx).Where("code = ?", code).First(&org)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &org, nil
}

20
backend/model/role_entity.go

@ -0,0 +1,20 @@
package model
import "time"
// Role 角色模型
type Role struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Name string `gorm:"column:name;type:varchar(50);not null" json:"name"`
Code string `gorm:"column:code;type:varchar(50);uniqueIndex;not null" json:"code"`
Description string `gorm:"column:description;type:varchar(255);default:''" json:"description"`
IsSystem bool `gorm:"column:is_system;default:false" json:"isSystem"`
SortOrder int `gorm:"column:sort_order;default:0" json:"sortOrder"`
Status int `gorm:"column:status;default:1" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"`
}
func (Role) TableName() string {
return "role"
}

59
backend/model/role_menu_model.go

@ -0,0 +1,59 @@
package model
import (
"context"
"gorm.io/gorm"
)
// RoleMenu 角色-菜单关联
type RoleMenu struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
RoleId int64 `gorm:"column:role_id;index" json:"roleId"`
MenuId int64 `gorm:"column:menu_id;index" json:"menuId"`
}
func (RoleMenu) TableName() string {
return "role_menu"
}
// RoleMenuFindByRoleId 获取角色的菜单ID列表
func RoleMenuFindByRoleId(ctx context.Context, db *gorm.DB, roleId int64) ([]int64, error) {
var menuIds []int64
err := db.WithContext(ctx).Model(&RoleMenu{}).Where("role_id = ?", roleId).Pluck("menu_id", &menuIds).Error
return menuIds, err
}
// RoleMenuFindByRoleIds 获取多个角色的菜单ID列表(去重)
func RoleMenuFindByRoleIds(ctx context.Context, db *gorm.DB, roleIds []int64) ([]int64, error) {
var menuIds []int64
if len(roleIds) == 0 {
return menuIds, nil
}
err := db.WithContext(ctx).Model(&RoleMenu{}).Where("role_id IN ?", roleIds).Distinct("menu_id").Pluck("menu_id", &menuIds).Error
return menuIds, err
}
// RoleMenuSetForRole 全量设置角色的菜单(先删后插)
func RoleMenuSetForRole(ctx context.Context, db *gorm.DB, roleId int64, menuIds []int64) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 删除旧关联
if err := tx.Where("role_id = ?", roleId).Delete(&RoleMenu{}).Error; err != nil {
return err
}
// 插入新关联
if len(menuIds) == 0 {
return nil
}
records := make([]RoleMenu, 0, len(menuIds))
for _, menuId := range menuIds {
records = append(records, RoleMenu{RoleId: roleId, MenuId: menuId})
}
return tx.Create(&records).Error
})
}
// RoleMenuDeleteByRoleId 删除角色的所有菜单关联
func RoleMenuDeleteByRoleId(ctx context.Context, db *gorm.DB, roleId int64) error {
return db.WithContext(ctx).Where("role_id = ?", roleId).Delete(&RoleMenu{}).Error
}

60
backend/model/role_model.go

@ -0,0 +1,60 @@
package model
import (
"context"
"errors"
"gorm.io/gorm"
)
// RoleInsert 插入角色
func RoleInsert(ctx context.Context, db *gorm.DB, role *Role) (int64, error) {
result := db.WithContext(ctx).Create(role)
if result.Error != nil {
return 0, result.Error
}
return role.Id, nil
}
// RoleFindOne 根据ID查询角色
func RoleFindOne(ctx context.Context, db *gorm.DB, id int64) (*Role, error) {
var role Role
result := db.WithContext(ctx).First(&role, id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &role, nil
}
// RoleFindOneByCode 根据编码查询角色
func RoleFindOneByCode(ctx context.Context, db *gorm.DB, code string) (*Role, error) {
var role Role
result := db.WithContext(ctx).Where("code = ?", code).First(&role)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &role, nil
}
// RoleFindAll 查询所有启用的角色
func RoleFindAll(ctx context.Context, db *gorm.DB) ([]Role, error) {
var roles []Role
err := db.WithContext(ctx).Where("status = 1").Order("sort_order ASC, id ASC").Find(&roles).Error
return roles, err
}
// RoleUpdate 更新角色
func RoleUpdate(ctx context.Context, db *gorm.DB, role *Role) error {
return db.WithContext(ctx).Save(role).Error
}
// RoleDelete 删除角色
func RoleDelete(ctx context.Context, db *gorm.DB, id int64) error {
return db.WithContext(ctx).Delete(&Role{}, id).Error
}

6
backend/model/user_entity.go

@ -11,6 +11,12 @@ type User struct {
Email string `gorm:"column:email;type:varchar(128);not null" json:"email"` Email string `gorm:"column:email;type:varchar(128);not null" json:"email"`
Password string `gorm:"column:password;type:varbinary(64);not null" json:"-"` Password string `gorm:"column:password;type:varbinary(64);not null" json:"-"`
Phone string `gorm:"column:phone;type:varchar(20);default:''" json:"phone"` Phone string `gorm:"column:phone;type:varchar(20);default:''" json:"phone"`
CasdoorId string `gorm:"column:casdoor_id;type:varchar(128);default:''" json:"casdoorId"`
UserType string `gorm:"column:user_type;type:varchar(20);default:'local'" json:"userType"`
Role string `gorm:"column:role;type:varchar(20);default:'user'" json:"role"`
Source string `gorm:"column:source;type:varchar(20);default:'register'" json:"source"`
Remark string `gorm:"column:remark;type:varchar(255);default:''" json:"remark"`
CurrentOrgId int64 `gorm:"column:current_org_id;default:0" json:"currentOrgId"`
Status int64 `gorm:"column:status;type:tinyint;default:1" json:"status"` Status int64 `gorm:"column:status;type:tinyint;default:1" json:"status"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"`

52
backend/model/user_model.go

@ -32,6 +32,19 @@ func FindOne(ctx context.Context, db *gorm.DB, id int64) (*User, error) {
return &user, nil return &user, nil
} }
// FindOneByCasdoorId 根据 Casdoor ID 查询用户
func FindOneByCasdoorId(ctx context.Context, db *gorm.DB, casdoorId string) (*User, error) {
var user User
result := db.WithContext(ctx).Where("casdoor_id = ?", casdoorId).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &user, nil
}
// FindOneByEmail 根据邮箱查询用户 // FindOneByEmail 根据邮箱查询用户
func FindOneByEmail(ctx context.Context, db *gorm.DB, email string) (*User, error) { func FindOneByEmail(ctx context.Context, db *gorm.DB, email string) (*User, error) {
var user User var user User
@ -45,6 +58,32 @@ func FindOneByEmail(ctx context.Context, db *gorm.DB, email string) (*User, erro
return &user, nil return &user, nil
} }
// FindOneByPhone 根据手机号查询用户
func FindOneByPhone(ctx context.Context, db *gorm.DB, phone string) (*User, error) {
var user User
result := db.WithContext(ctx).Where("phone = ?", phone).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &user, nil
}
// FindOneByUsername 根据用户名查询用户
func FindOneByUsername(ctx context.Context, db *gorm.DB, username string) (*User, error) {
var user User
result := db.WithContext(ctx).Where("username = ?", username).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &user, nil
}
// Update 更新用户 // Update 更新用户
func Update(ctx context.Context, db *gorm.DB, user *User) error { func Update(ctx context.Context, db *gorm.DB, user *User) error {
result := db.WithContext(ctx).Save(user) result := db.WithContext(ctx).Save(user)
@ -57,6 +96,19 @@ func Delete(ctx context.Context, db *gorm.DB, id int64) error {
return result.Error return result.Error
} }
// FindOneByRole 根据角色查询第一个用户
func FindOneByRole(ctx context.Context, db *gorm.DB, role string) (*User, error) {
var user User
result := db.WithContext(ctx).Where("role = ?", role).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &user, nil
}
// FindList 查询用户列表 // FindList 查询用户列表
func FindList(ctx context.Context, db *gorm.DB, page, pageSize int64, keyword string, status int64) ([]User, int64, error) { func FindList(ctx context.Context, db *gorm.DB, page, pageSize int64, keyword string, status int64) ([]User, int64, error) {
var users []User var users []User

16
backend/model/user_organization_entity.go

@ -0,0 +1,16 @@
package model
import "time"
// UserOrganization 用户-机构-角色关联
type UserOrganization struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserId int64 `gorm:"column:user_id;index" json:"userId"`
OrgId int64 `gorm:"column:org_id;index" json:"orgId"`
RoleId int64 `gorm:"column:role_id" json:"roleId"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"`
}
func (UserOrganization) TableName() string {
return "user_organization"
}

66
backend/model/user_organization_model.go

@ -0,0 +1,66 @@
package model
import (
"context"
"errors"
"gorm.io/gorm"
)
// UserOrgInsert 添加用户-机构关联
func UserOrgInsert(ctx context.Context, db *gorm.DB, uo *UserOrganization) (int64, error) {
result := db.WithContext(ctx).Create(uo)
if result.Error != nil {
return 0, result.Error
}
return uo.Id, nil
}
// UserOrgFindOne 查询单条关联
func UserOrgFindOne(ctx context.Context, db *gorm.DB, userId, orgId int64) (*UserOrganization, error) {
var uo UserOrganization
result := db.WithContext(ctx).Where("user_id = ? AND org_id = ?", userId, orgId).First(&uo)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &uo, nil
}
// UserOrgFindByUserId 获取用户的所有机构关联
func UserOrgFindByUserId(ctx context.Context, db *gorm.DB, userId int64) ([]UserOrganization, error) {
var list []UserOrganization
err := db.WithContext(ctx).Where("user_id = ?", userId).Find(&list).Error
return list, err
}
// UserOrgFindByOrgId 获取机构的所有成员关联
func UserOrgFindByOrgId(ctx context.Context, db *gorm.DB, orgId int64) ([]UserOrganization, error) {
var list []UserOrganization
err := db.WithContext(ctx).Where("org_id = ?", orgId).Find(&list).Error
return list, err
}
// UserOrgUpdate 更新关联(如改角色)
func UserOrgUpdate(ctx context.Context, db *gorm.DB, uo *UserOrganization) error {
return db.WithContext(ctx).Save(uo).Error
}
// UserOrgDelete 删除关联
func UserOrgDelete(ctx context.Context, db *gorm.DB, userId, orgId int64) error {
return db.WithContext(ctx).Where("user_id = ? AND org_id = ?", userId, orgId).Delete(&UserOrganization{}).Error
}
// UserOrgDeleteByOrgId 删除机构的所有成员关联
func UserOrgDeleteByOrgId(ctx context.Context, db *gorm.DB, orgId int64) error {
return db.WithContext(ctx).Where("org_id = ?", orgId).Delete(&UserOrganization{}).Error
}
// UserOrgCountByOrgId 统计机构成员数
func UserOrgCountByOrgId(ctx context.Context, db *gorm.DB, orgId int64) (int64, error) {
var count int64
err := db.WithContext(ctx).Model(&UserOrganization{}).Where("org_id = ?", orgId).Count(&count).Error
return count, err
}

1972
docs/plans/2026-02-13-detailed-playwright-tests.md

File diff suppressed because it is too large

1011
docs/plans/2026-02-13-frontend-api-integration.md

File diff suppressed because it is too large

1416
docs/plans/2026-02-13-playwright-mcp-tests.md

File diff suppressed because it is too large

854
docs/plans/2026-02-14-hot-reload-login-redesign-impl.md

@ -0,0 +1,854 @@
# 后端热重载 + 登录方式改造 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add Air hot reload for backend development, and change login from email to phone/username with JWT email removal.
**Architecture:** Two independent changes — (1) Air config file + .gitignore, zero code changes; (2) LoginRequest.Email→Account, new Model queries FindOneByPhone/FindOneByUsername, backend regex auto-detect, JWT Claims remove Email, frontend login form adapt.
**Tech Stack:** Air (hot reload), go-zero, GORM, React, TypeScript
---
### Task 1: Air 热重载配置
**Files:**
- Create: `backend/.air.toml`
- Create: `backend/.gitignore`
**Step 1: Install Air**
Run: `go install github.com/air-verse/air@latest`
Expected: Binary installed to `$GOPATH/bin/air`
**Step 2: Create `.air.toml`**
Create `backend/.air.toml`:
```toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/base.exe base.go"
bin = "./tmp/base.exe -f etc/base-api.yaml"
include_ext = ["go", "yaml"]
exclude_dir = ["tmp", "vendor", "tests"]
delay = 1000
[log]
time = false
[misc]
clean_on_exit = true
```
**Step 3: Create `backend/.gitignore`**
Create `backend/.gitignore`:
```
tmp/
```
**Step 4: Verify Air works**
Run: `cd backend && air`
Expected: Compiles and starts server on :8888, logs show "Starting server"
Kill with Ctrl+C after confirming.
**Step 5: Commit**
```bash
git add backend/.air.toml backend/.gitignore
git commit -m "feat: add Air hot reload config for backend development"
```
---
### Task 2: Model 层 — 新增 FindOneByPhone 和 FindOneByUsername
**Files:**
- Modify: `backend/model/user_model.go`
**Step 1: Add FindOneByPhone and FindOneByUsername**
Add to `backend/model/user_model.go` after `FindOneByEmail` (after line 59):
```go
// FindOneByPhone 根据手机号查询用户
func FindOneByPhone(ctx context.Context, db *gorm.DB, phone string) (*User, error) {
var user User
result := db.WithContext(ctx).Where("phone = ?", phone).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &user, nil
}
// FindOneByUsername 根据用户名查询用户
func FindOneByUsername(ctx context.Context, db *gorm.DB, username string) (*User, error) {
var user User
result := db.WithContext(ctx).Where("username = ?", username).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, result.Error
}
return &user, nil
}
```
**Step 2: Verify build**
Run: `cd backend && go build ./...`
Expected: No errors
**Step 3: Commit**
```bash
git add backend/model/user_model.go
git commit -m "feat: add FindOneByPhone and FindOneByUsername model queries"
```
---
### Task 3: JWT — 移除 Email 字段
**Files:**
- Modify: `backend/internal/util/jwt/jwt.go`
- Modify: `backend/internal/util/jwt/jwt_test.go`
**Step 1: Update Claims struct and GenerateToken**
In `backend/internal/util/jwt/jwt.go`:
Replace Claims struct (lines 18-24):
```go
type Claims struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
```
Replace GenerateToken signature (line 27):
```go
func GenerateToken(userId int64, username, role string) (string, error) {
```
Replace Claims initialization (lines 28-32):
```go
claims := Claims{
UserID: userId,
Username: username,
Role: role,
```
**Step 2: Update jwt_test.go**
Replace entire `backend/internal/util/jwt/jwt_test.go`:
```go
package jwt
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateToken_ContainsRole(t *testing.T) {
token, err := GenerateToken(1, "testuser", "admin")
require.NoError(t, err)
require.NotEmpty(t, token)
claims, err := ParseToken(token)
require.NoError(t, err)
assert.Equal(t, int64(1), claims.UserID)
assert.Equal(t, "testuser", claims.Username)
assert.Equal(t, "admin", claims.Role)
}
func TestGenerateToken_SuperAdminRole(t *testing.T) {
token, err := GenerateToken(99, "admin", "super_admin")
require.NoError(t, err)
claims, err := ParseToken(token)
require.NoError(t, err)
assert.Equal(t, "super_admin", claims.Role)
}
func TestGenerateToken_EmptyRole(t *testing.T) {
token, err := GenerateToken(1, "user", "")
require.NoError(t, err)
claims, err := ParseToken(token)
require.NoError(t, err)
assert.Equal(t, "", claims.Role)
}
```
**Step 3: Run JWT tests**
Run: `cd backend && go test -v ./internal/util/jwt/...`
Expected: 3/3 PASS
**Step 4: Commit**
```bash
git add backend/internal/util/jwt/jwt.go backend/internal/util/jwt/jwt_test.go
git commit -m "refactor: remove email from JWT Claims"
```
---
### Task 4: Auth Middleware — 移除 email context
**Files:**
- Modify: `backend/internal/middleware/authmiddleware.go`
**Step 1: Remove email context line**
In `backend/internal/middleware/authmiddleware.go`, delete line 43:
```go
ctx = context.WithValue(ctx, "email", claims.Email)
```
**Step 2: Verify build**
Run: `cd backend && go build ./...`
Expected: No errors
**Step 3: Commit**
```bash
git add backend/internal/middleware/authmiddleware.go
git commit -m "refactor: remove email from auth middleware context"
```
---
### Task 5: API 定义 — LoginRequest 和 RegisterRequest 改造
**Files:**
- Modify: `backend/base.api` (lines 37-40)
- Modify: `backend/internal/types/types.go` (auto-generated)
- Modify: `backend/internal/handler/routes.go` (auto-generated)
**Step 1: Update LoginRequest in base.api**
In `backend/base.api`, replace lines 37-40:
```
// 登录请求
LoginRequest {
Account string `json:"account" validate:"required"` // 手机号或用户名
Password string `json:"password" validate:"required,min=6,max=32"` // 密码
}
```
**Step 2: Update RegisterRequest in base.api**
In `backend/base.api`, replace lines 30-35:
```
// 注册请求
RegisterRequest {
Username string `json:"username" validate:"required,min=3,max=32"` // 用户名
Password string `json:"password" validate:"required,min=6,max=32"` // 密码
Phone string `json:"phone" validate:"required"` // 手机号(必填)
Email string `json:"email,optional"` // 邮箱(可选)
}
```
**Step 3: Regenerate types and routes**
Run: `cd backend && goctl api go -api base.api -dir .`
Expected: types.go and routes.go regenerated
**Step 4: Verify build**
Run: `cd backend && go build ./...`
Expected: Compile errors in loginlogic.go (req.Email → req.Account) — expected, will fix in next task
**Step 5: Commit**
```bash
git add backend/base.api backend/internal/types/types.go backend/internal/handler/routes.go
git commit -m "refactor: change LoginRequest to account field, RegisterRequest phone required"
```
---
### Task 6: Login Logic — 手机号/用户名自动识别
**Files:**
- Modify: `backend/internal/logic/auth/loginlogic.go`
**Step 1: Rewrite loginlogic.go**
Replace entire `backend/internal/logic/auth/loginlogic.go`:
```go
package auth
import (
"context"
"crypto/md5"
"fmt"
"regexp"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/internal/util/jwt"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
var phoneRegex = regexp.MustCompile(`^\d{11}$`)
type LoginLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 用户登录
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
return &LoginLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *LoginLogic) Login(req *types.LoginRequest) (resp *types.LoginResponse, err error) {
// 根据输入自动识别:11位数字为手机号,否则为用户名
var user *model.User
if phoneRegex.MatchString(req.Account) {
user, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Account)
} else {
user, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Account)
}
if err != nil {
if err == model.ErrNotFound {
return &types.LoginResponse{
Code: 404,
Message: "用户不存在或密码错误",
Success: false,
}, nil
}
return nil, fmt.Errorf("查询用户失败: %v", err)
}
// SSO 用户不允许使用密码登录
if user.UserType == "casdoor" {
return &types.LoginResponse{
Code: 400,
Message: "该账号已绑定 SSO,请使用 SSO 方式登录",
Success: false,
}, nil
}
// 加密输入的密码并与数据库密码对比
inputPassword := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
if user.Password != inputPassword {
return &types.LoginResponse{
Code: 400,
Message: "用户不存在或密码错误",
Success: false,
}, nil
}
// 生成 Token
token, err := jwt.GenerateToken(user.Id, user.Username, user.Role)
if err != nil {
return nil, fmt.Errorf("生成Token失败: %v", err)
}
l.Infof("登录成功,userId=%d", user.Id)
return &types.LoginResponse{
Code: 200,
Message: "登录成功",
Success: true,
Token: token,
}, nil
}
```
**Step 2: Verify build**
Run: `cd backend && go build ./...`
Expected: May still have errors in other logic files — continue to next tasks
**Step 3: Commit**
```bash
git add backend/internal/logic/auth/loginlogic.go
git commit -m "feat: login by phone or username with auto-detection"
```
---
### Task 7: Register Logic — 移除邮箱必填,手机号唯一性检查
**Files:**
- Modify: `backend/internal/logic/auth/registerlogic.go`
**Step 1: Rewrite registerlogic.go**
Replace entire `backend/internal/logic/auth/registerlogic.go`:
```go
package auth
import (
"context"
"crypto/md5"
"fmt"
"github.com/youruser/base/internal/svc"
"github.com/youruser/base/internal/types"
"github.com/youruser/base/model"
"github.com/zeromicro/go-zero/core/logx"
)
type RegisterLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// 用户注册
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
return &RegisterLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RegisterLogic) Register(req *types.RegisterRequest) (resp *types.UserInfo, err error) {
// 检查用户名是否已存在
_, err = model.FindOneByUsername(l.ctx, l.svcCtx.DB, req.Username)
if err == nil {
return nil, fmt.Errorf("用户名已被注册")
}
if err != model.ErrNotFound {
return nil, fmt.Errorf("检查用户名失败: %v", err)
}
// 检查手机号是否已存在
_, err = model.FindOneByPhone(l.ctx, l.svcCtx.DB, req.Phone)
if err == nil {
return nil, fmt.Errorf("手机号已被注册")
}
if err != model.ErrNotFound {
return nil, fmt.Errorf("检查手机号失败: %v", err)
}
// 创建用户模型
user := &model.User{
Username: req.Username,
Email: req.Email,
Password: fmt.Sprintf("%x", md5.Sum([]byte(req.Password))),
Phone: req.Phone,
Role: model.RoleUser,
Source: model.SourceRegister,
Status: 1,
}
// 插入数据库
id, err := model.Insert(l.ctx, l.svcCtx.DB, user)
if err != nil {
return nil, fmt.Errorf("创建用户失败: %v", err)
}
// 查询创建的用户
user, err = model.FindOne(l.ctx, l.svcCtx.DB, id)
if err != nil {
return nil, fmt.Errorf("查询用户失败: %v", err)
}
resp = &types.UserInfo{
Id: user.Id,
Username: user.Username,
Email: user.Email,
Phone: user.Phone,
Role: user.Role,
Source: user.Source,
Remark: user.Remark,
Status: int(user.Status),
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"),
}
l.Infof("注册成功,userId=%d", user.Id)
return resp, nil
}
```
**Step 2: Commit**
```bash
git add backend/internal/logic/auth/registerlogic.go
git commit -m "refactor: register checks username+phone uniqueness, email optional"
```
---
### Task 8: RefreshToken + SSO Logic — 更新 GenerateToken 调用
**Files:**
- Modify: `backend/internal/logic/auth/refreshtokenlogic.go:55`
- Modify: `backend/internal/logic/auth/ssologic.go:179`
**Step 1: Fix refreshtokenlogic.go**
In `backend/internal/logic/auth/refreshtokenlogic.go`, line 55, change:
```go
newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Email, user.Role)
```
to:
```go
newToken, err := jwt.GenerateToken(user.Id, user.Username, user.Role)
```
**Step 2: Fix ssologic.go**
In `backend/internal/logic/auth/ssologic.go`, line 179, change:
```go
token, err := jwt.GenerateToken(localUser.Id, localUser.Username, localUser.Email, localUser.Role)
```
to:
```go
token, err := jwt.GenerateToken(localUser.Id, localUser.Username, localUser.Role)
```
**Step 3: Verify build**
Run: `cd backend && go build ./...`
Expected: No errors (all GenerateToken calls now match new signature)
**Step 4: Commit**
```bash
git add backend/internal/logic/auth/refreshtokenlogic.go backend/internal/logic/auth/ssologic.go
git commit -m "refactor: update GenerateToken calls to remove email param"
```
---
### Task 9: 种子超级管理员 — 改为用户名查找 + 手机号
**Files:**
- Modify: `backend/internal/svc/servicecontext.go` (seedSuperAdmin function)
**Step 1: Update seedSuperAdmin**
In `backend/internal/svc/servicecontext.go`, replace the `seedSuperAdmin` function:
```go
// seedSuperAdmin 首次启动创建超级管理员
func seedSuperAdmin(db *gorm.DB) {
ctx := context.Background()
// 先检查 admin 用户是否已存在
existing, err := model.FindOneByUsername(ctx, db, "admin")
if err == nil {
// 用户已存在,确保角色为 super_admin
if existing.Role != model.RoleSuperAdmin {
existing.Role = model.RoleSuperAdmin
existing.Source = model.SourceSystem
model.Update(ctx, db, existing)
log.Println("[Seed] Updated admin to super_admin role")
}
return
}
// 创建超级管理员
password := fmt.Sprintf("%x", md5.Sum([]byte("admin123")))
admin := &model.User{
Username: "admin",
Phone: "13800000000",
Email: "",
Password: password,
Role: model.RoleSuperAdmin,
Source: model.SourceSystem,
Remark: "系统自动创建的超级管理员",
Status: 1,
}
_, err = model.Insert(ctx, db, admin)
if err != nil {
log.Printf("[Seed] Failed to create super admin: %v", err)
return
}
log.Println("[Seed] Super admin created: admin / admin123")
}
```
**Step 2: Verify build**
Run: `cd backend && go build ./...`
Expected: No errors
**Step 3: Commit**
```bash
git add backend/internal/svc/servicecontext.go
git commit -m "refactor: seed super admin uses username lookup and phone number"
```
---
### Task 10: Go 单元测试修复 — rbac_test.go
**Files:**
- Modify: `backend/internal/logic/user/rbac_test.go`
**Step 1: Update test to not require email**
In `backend/internal/logic/user/rbac_test.go`, find all `CreateUserRequest` that have `Email` field and ensure they still work. The `CreateUserRequest` in `user.api` still has `Email` as a field (it's part of user management, not login). Check if the test needs any changes after the API type regeneration.
Run: `cd backend && go test -v ./internal/logic/user/...`
Expected: If tests pass, no changes needed. If they fail due to type changes, fix accordingly.
**Step 2: Commit (if changes needed)**
```bash
git add backend/internal/logic/user/rbac_test.go
git commit -m "fix: update RBAC tests for new type definitions"
```
---
### Task 11: 后端集成测试 — 启动并验证登录
**Step 1: Start backend**
Run: `cd backend && go run base.go -f etc/base-api.yaml`
**Step 2: Test login with username**
```bash
curl -s -X POST http://localhost:8888/api/v1/login \
-H "Content-Type: application/json" \
-d '{"account":"admin","password":"admin123"}'
```
Expected: `{"code":200,"message":"登录成功","success":true,"token":"..."}`
**Step 3: Test login with phone**
```bash
curl -s -X POST http://localhost:8888/api/v1/login \
-H "Content-Type: application/json" \
-d '{"account":"13800000000","password":"admin123"}'
```
Expected: `{"code":200,"message":"登录成功","success":true,"token":"..."}`
**Step 4: Verify JWT has no email**
Decode the token payload (base64 decode middle section) and confirm no `email` field.
**Step 5: Test register with phone**
```bash
curl -s -X POST http://localhost:8888/api/v1/register \
-H "Content-Type: application/json" \
-d '{"username":"testphone","password":"test123456","phone":"13900001111"}'
```
Expected: `{"id":...,"username":"testphone","phone":"13900001111",...}`
---
### Task 12: 前端 — TypeScript 类型更新
**Files:**
- Modify: `frontend/react-shadcn/pc/src/types/index.ts`
**Step 1: Update LoginRequest**
In `frontend/react-shadcn/pc/src/types/index.ts`, replace lines 33-36:
```typescript
export interface LoginRequest {
account: string
password: string
}
```
**Step 2: Update RegisterRequest**
Replace lines 38-43:
```typescript
export interface RegisterRequest {
username: string
password: string
phone: string
email?: string
}
```
**Step 3: Commit**
```bash
git add frontend/react-shadcn/pc/src/types/index.ts
git commit -m "refactor: frontend types - LoginRequest.account, RegisterRequest.phone required"
```
---
### Task 13: 前端 — API Client 和 AuthContext 更新
**Files:**
- Modify: `frontend/react-shadcn/pc/src/services/api.ts:70-80`
- Modify: `frontend/react-shadcn/pc/src/contexts/AuthContext.tsx`
**Step 1: Update api.ts login method**
In `frontend/react-shadcn/pc/src/services/api.ts`, the `login` method (line 70) accepts `LoginRequest` which now has `account` instead of `email`. No code change needed here since it just passes the object through — the type change in Task 12 handles it.
**Step 2: Update AuthContext login function**
In `frontend/react-shadcn/pc/src/contexts/AuthContext.tsx`:
Change interface (line 10):
```typescript
login: (account: string, password: string) => Promise<void>
```
Change login function (line 41):
```typescript
const login = async (account: string, password: string) => {
```
Change apiClient call (line 43):
```typescript
const response = await apiClient.login({ account, password })
```
Change JWT parsing fallback username (line 52, inside the if block):
```typescript
username: payload.username || account,
```
Remove email from userData (line 53-54):
```typescript
email: payload.email || '',
```
And in the catch block fallback (lines 62-66):
```typescript
const userData: User = {
id: 0,
username: account,
email: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
```
**Step 3: Commit**
```bash
git add frontend/react-shadcn/pc/src/services/api.ts frontend/react-shadcn/pc/src/contexts/AuthContext.tsx
git commit -m "refactor: frontend auth uses account field, remove email from JWT parsing"
```
---
### Task 14: 前端 — LoginPage UI 改造
**Files:**
- Modify: `frontend/react-shadcn/pc/src/pages/LoginPage.tsx`
**Step 1: Update imports**
Replace `Mail` import with `User` (or `UserRound`):
```typescript
import { UserRound, Lock, AlertCircle, Eye, EyeOff, ArrowRight, Shield, Cpu, BarChart3 } from 'lucide-react'
```
**Step 2: Update state variable**
Line 10, change:
```typescript
const [account, setAccount] = useState('')
```
**Step 3: Update handleSubmit**
Line 53, change:
```typescript
await login(account, password)
```
**Step 4: Update email input to account input**
Replace the email Input component (lines 198-206):
```tsx
<Input
type="text"
label="手机号 / 用户名"
placeholder="请输入手机号或用户名"
value={account}
onChange={(e) => setAccount(e.target.value)}
required
leftIcon={<UserRound className="h-4 w-4" />}
disabled={isLoading}
/>
```
**Step 5: Verify frontend compiles**
Run: `cd frontend/react-shadcn/pc && npm run build`
Expected: No TypeScript errors
**Step 6: Commit**
```bash
git add frontend/react-shadcn/pc/src/pages/LoginPage.tsx
git commit -m "feat: login page accepts phone/username instead of email"
```
---
### Task 15: E2E 验证 — 全流程测试
**Step 1: Start backend and frontend**
```bash
# Terminal 1 (use air if installed, otherwise go run)
cd backend && air
# Terminal 2
cd frontend/react-shadcn/pc && npm run dev
```
**Step 2: Test login via browser**
Navigate to http://localhost:5173/login
- Enter "admin" + "admin123" → should redirect to dashboard
- Logout, enter "13800000000" + "admin123" → should redirect to dashboard
**Step 3: Test hot reload**
Edit any .go file (e.g., add a comment), save, verify Air rebuilds and restarts automatically.
**Step 4: Final commit with test config update**
Update `frontend/react-shadcn/pc/tests/config.ts` superAdmin credentials:
```typescript
superAdmin: {
account: 'admin',
password: 'admin123',
},
```
```bash
git add frontend/react-shadcn/pc/tests/config.ts
git commit -m "chore: update test config for account-based login"
```

125
docs/plans/2026-02-14-hot-reload-login-redesign.md

@ -0,0 +1,125 @@
# 后端热重载 + 登录方式改造 设计文档
**日期**: 2026-02-14
**状态**: 已批准
## 概述
两项改造:
1. 后端引入 Air 热重载,保存文件自动重启
2. 登录从邮箱改为手机号/用户名,注册移除邮箱必填,JWT Claims 移除 email
---
## 设计一:Air 热重载
### 方案
使用 [Air](https://github.com/air-verse/air) 监听 `.go``.yaml` 文件变化,自动编译重启。
### 新增文件
`backend/.air.toml`:
- 监听 `.go`, `.yaml` 文件
- 排除 `tmp/`, `vendor/`, `tests/`
- 编译到 `tmp/base.exe`
- 延迟 1000ms 防抖
### 启动方式
```bash
# 安装(一次性)
go install github.com/air-verse/air@latest
# 开发启动
cd backend && air
```
### 影响范围
- 新增 `backend/.air.toml`
- `backend/.gitignore``tmp/`
- 零侵入现有代码
---
## 设计二:登录方式改造
### API 变更
**LoginRequest**:
```
Account string `json:"account" validate:"required"` // 手机号或用户名
Password string `json:"password" validate:"required,min=6,max=32"`
```
**RegisterRequest**:
```
Username string `json:"username" validate:"required,min=3,max=32"`
Password string `json:"password" validate:"required,min=6,max=32"`
Phone string `json:"phone" validate:"required"` // 必填
Email string `json:"email,optional"` // 可选
```
### 后端 Login 逻辑
单一输入框,后端正则自动识别:
- `^\d{11}$` → 手机号 → `FindOneByPhone`
- 否则 → 用户名 → `FindOneByUsername`
### Model 层
新增:
- `FindOneByPhone(ctx, db, phone) (*User, error)`
- `FindOneByUsername(ctx, db, username) (*User, error)`
### JWT Claims 变更
移除 `Email` 字段:
```go
type Claims struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userId int64, username, role string) (string, error)
```
影响调用点:loginlogic, registerlogic, refreshtokenlogic, ssologic, authmiddleware
### Register 逻辑
- 检查 username 唯一性(FindOneByUsername)
- 检查 phone 唯一性(FindOneByPhone)
- email 可选,不做唯一性检查
### 种子超级管理员
```go
admin := &model.User{
Username: "admin",
Phone: "13800000000",
Email: "",
Password: md5("admin123"),
Role: "super_admin",
Source: "system",
}
// 查找改为 FindOneByUsername(ctx, db, "admin")
```
### 前端变更
| 文件 | 变更 |
|------|------|
| `types/index.ts` | `LoginRequest.email``.account`; `RegisterRequest.email` 可选, `.phone` 必填 |
| `services/api.ts` | `login()``account` |
| `contexts/AuthContext.tsx` | `login(email, password)``login(account, password)`; JWT 解析移除 email |
| `pages/LoginPage.tsx` | 输入框: Mail 图标 → User 图标, placeholder "手机号 / 用户名", `type="text"` |
### 不变的部分
- SSO 登录流程(Casdoor 自有账号体系)
- 用户管理 CRUD、Profile 接口
- `user_entity.go` 的 email 字段保留,仅改为非必填

287
docs/plans/2026-02-14-menu-role-org-design.md

@ -0,0 +1,287 @@
# 菜单管理 + 角色管理 + 机构管理 设计文档
## 需求概述
为 BASE 管理面板添加三大功能模块:
1. **菜单管理**:动态菜单替代硬编码侧边栏,菜单分"默认"和"配置"两种类型,按角色分配
2. **角色管理**:支持自定义角色,角色按机构分配(同一用户在不同机构可有不同角色)
3. **机构管理**:树形结构的组织架构,用户与机构多对多关系
## 核心设计决策
| 决策点 | 选择 | 说明 |
|--------|------|------|
| 菜单分配方式 | 按角色分配 | 角色绑定菜单,用户通过角色获得菜单权限 |
| 角色类型 | 支持自定义角色 | 除系统内置角色外,可创建新角色 |
| 机构结构 | 树形(多级父子) | 支持 总公司→分公司→部门→小组 |
| 用户-机构关系 | 多对多 | 用户可属于多个机构 |
| 角色作用域 | 按机构分配 | 同一用户在不同机构可有不同角色 |
| 机构上下文 | 当前机构切换器 | 用户登录后选择工作机构,菜单和权限随之变化 |
## 数据模型
### 新增表
#### menus(菜单表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | uint, PK | 自增主键 |
| parent_id | uint | 父菜单ID,0为顶级 |
| name | varchar(50) | 菜单名称 |
| path | varchar(200) | 前端路由路径 |
| icon | varchar(50) | 图标名称(lucide-react) |
| component | varchar(200) | 前端组件路径 |
| type | varchar(20) | 'default' 或 'config' |
| sort_order | int | 排序序号 |
| visible | bool | 是否在侧边栏显示 |
| status | int | 1=启用, 0=禁用 |
| created_at | datetime | |
| updated_at | datetime | |
#### roles(角色表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | uint, PK | 自增主键 |
| name | varchar(50) | 角色显示名称 |
| code | varchar(50), unique | 角色编码,如 super_admin |
| description | varchar(255) | 角色描述 |
| is_system | bool | 系统内置角色不可删除 |
| sort_order | int | 排序 |
| status | int | 1=启用, 0=禁用 |
| created_at | datetime | |
| updated_at | datetime | |
#### role_menus(角色-菜单关联表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | uint, PK | |
| role_id | uint | 角色ID |
| menu_id | uint | 菜单ID |
#### organizations(机构表,树形)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | uint, PK | |
| parent_id | uint | 父机构ID,0为顶级 |
| name | varchar(100) | 机构名称 |
| code | varchar(50) | 机构编码 |
| leader | varchar(50) | 负责人 |
| phone | varchar(20) | 联系电话 |
| email | varchar(100) | 联系邮箱 |
| sort_order | int | 排序 |
| status | int | 1=启用, 0=禁用 |
| created_at | datetime | |
| updated_at | datetime | |
#### user_organizations(用户-机构-角色关联表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | uint, PK | |
| user_id | uint | 用户ID |
| org_id | uint | 机构ID |
| role_id | uint | 该用户在该机构的角色ID |
| created_at | datetime | |
### 现有表改动
**users 表**:
- 保留 `role` 字段作为无机构时的默认角色
- 新增 `current_org_id` (uint):记住用户上次选择的机构
**JWT Claims**:
- 新增 `currentOrgId` (uint64)
- `role` 字段值来源改为:有当前机构时用机构角色,否则用默认角色
### 种子数据
**系统内置角色**(is_system=true):
| code | name | sort_order |
|------|------|------------|
| super_admin | 超级管理员 | 1 |
| admin | 管理员 | 2 |
| user | 普通用户 | 3 |
| guest | 访客 | 4 |
**默认菜单**:
| name | path | icon | type | sort_order |
|------|------|------|------|------------|
| 我的 | /my | User | default | 1 |
| 仪表盘 | /dashboard | LayoutDashboard | config | 2 |
| 用户管理 | /users | Users | config | 3 |
| 文件管理 | /files | FolderOpen | config | 4 |
| 角色管理 | /roles | Shield | config | 5 |
| 菜单管理 | /menus | Menu | config | 6 |
| 机构管理 | /organizations | Building2 | config | 7 |
| 设置 | /settings | Settings | default | 8 |
**super_admin 角色**自动绑定所有菜单(default + config)。
**user 角色**只绑定 default 类型菜单(我的 + 设置)。
## 核心流程
### 登录流程
```
用户登录
→ JWT签发 (userId, username, email, role=默认角色, currentOrgId=user.current_org_id)
→ 前端拿到 token
→ GET /profile/orgs 获取用户机构列表
→ 如果有机构 && current_org_id > 0 → 使用该机构
→ 如果有机构 && current_org_id == 0 → 使用第一个机构
→ 如果无机构 → 使用默认角色
→ GET /menus/current 获取当前角色的菜单
→ 渲染动态侧边栏
```
### 机构切换
```
用户点击机构切换器选择新机构
→ PUT /profile/current-org { orgId: xxx }
→ 后端:更新 user.current_org_id,查 user_organizations 获取新角色
→ 后端:重新签发 JWT (含新角色和新orgId)
→ 前端:更新 token,刷新菜单,刷新页面
```
### 权限校验
```
请求到达
→ Auth middleware: 解析 JWT,注入 userId, role, currentOrgId 到 context
→ Authz middleware: Casbin enforce(role, path, method)
→ 通过 → handler
→ 拒绝 → 403
```
## API 设计
### 菜单管理
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/menus/current | 当前用户可见菜单(树形) | 所有登录用户 |
| GET | /api/v1/menus | 全部菜单列表(树形) | admin+ |
| POST | /api/v1/menu | 创建菜单 | super_admin |
| PUT | /api/v1/menu/:id | 更新菜单 | super_admin |
| DELETE | /api/v1/menu/:id | 删除菜单 | super_admin |
### 角色管理
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/roles | 角色列表 | admin+ |
| POST | /api/v1/role | 创建角色 | super_admin |
| PUT | /api/v1/role/:id | 更新角色 | super_admin |
| DELETE | /api/v1/role/:id | 删除角色(非系统角色) | super_admin |
| GET | /api/v1/role/:id/menus | 获取角色的菜单ID列表 | admin+ |
| PUT | /api/v1/role/:id/menus | 设置角色的菜单(全量替换) | super_admin |
### 机构管理
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/organizations | 机构列表(树形) | admin+ |
| POST | /api/v1/organization | 创建机构 | super_admin |
| PUT | /api/v1/organization/:id | 更新机构 | admin+ |
| DELETE | /api/v1/organization/:id | 删除机构(无子机构) | super_admin |
| GET | /api/v1/organization/:id/members | 机构成员列表 | admin+ |
| POST | /api/v1/organization/:id/member | 添加成员(含角色) | admin+ |
| PUT | /api/v1/organization/:id/member/:userId | 更新成员角色 | admin+ |
| DELETE | /api/v1/organization/:id/member/:userId | 移除成员 | admin+ |
### 用户上下文
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/profile/orgs | 我的机构列表(含各机构角色) | 所有登录用户 |
| PUT | /api/v1/profile/current-org | 切换当前机构(返回新token) | 所有登录用户 |
## 前端设计
### 新页面
1. **我的** (`/my`):个人信息面板,显示所属机构列表、当前角色
2. **菜单管理** (`/menus`):树形表格展示菜单,支持拖拽排序、启用/禁用
3. **角色管理** (`/roles`):角色列表 + 菜单分配弹窗(树形勾选)
4. **机构管理** (`/organizations`):树形表格 + 成员管理抽屉(添加/移除成员、分配角色)
### 侧边栏改造
- **当前**:`Sidebar.tsx` 中硬编码 `navItems` 数组
- **改造后**
- 登录后从 `GET /menus/current` 获取菜单树
- `AuthContext` 存储 `userMenus` state
- `Sidebar` 组件从 context 读取菜单动态渲染
- 顶部 Logo 区域下方增加机构切换下拉框
### AuthContext 扩展
新增 state:
- `currentOrg: { id, name } | null`
- `userOrgs: Array<{ orgId, orgName, roleId, roleName }>`
- `userMenus: MenuTree[]`
新增方法:
- `switchOrg(orgId: number): Promise<void>` — 切换机构,刷新 token 和菜单
- `refreshMenus(): Promise<void>` — 重新获取菜单
### 机构切换器
位置:侧边栏 Logo 区域下方
样式:下拉选择框,显示当前机构名称
行为:切换时调用 `switchOrg()`,自动刷新菜单和页面数据
## Casbin 策略更新
新增资源的 Casbin 策略需要同步更新,自定义角色创建时由管理员配置具体权限。
系统内置角色默认策略:
- **super_admin**:所有 API 端点的所有方法
- **admin**:菜单/角色/机构的读取 + 机构成员管理
- **user**:仅 /menus/current, /profile/orgs, /profile/current-org
- **guest**:仅 /menus/current
## 文件变更预估
### 新建文件(后端 ~20 个)
- `model/menu_entity.go`, `model/menu_model.go`
- `model/role_entity.go`, `model/role_model.go`
- `model/role_menu_model.go`
- `model/organization_entity.go`, `model/organization_model.go`
- `model/user_organization_entity.go`, `model/user_organization_model.go`
- `api/menu.api`, `api/role.api`, `api/organization.api`
- `internal/logic/menu/*.go` (5 个 logic)
- `internal/logic/role/*.go` (6 个 logic)
- `internal/logic/organization/*.go` (7 个 logic)
- `internal/logic/profile/` (2 个新 logic)
### 修改文件(后端 ~8 个)
- `model/user_entity.go` — 增加 current_org_id
- `internal/util/jwt/jwt.go` — Claims 增加 currentOrgId
- `internal/svc/servicecontext.go` — 新模型注入 + 种子数据
- `base.api` — 新路由组
- `internal/middleware/authmiddleware.go` — 注入 currentOrgId
- `internal/middleware/authzmiddleware.go` — 适配新策略
- 各登录/注册 logic — GenerateToken 调用更新
### 新建文件(前端 ~6 个)
- `pages/MyPage.tsx`
- `pages/MenuManagementPage.tsx`
- `pages/RoleManagementPage.tsx`
- `pages/OrganizationManagementPage.tsx`
- `components/layout/OrgSwitcher.tsx`
### 修改文件(前端 ~5 个)
- `App.tsx` — 新路由
- `components/layout/Sidebar.tsx` — 动态菜单
- `contexts/AuthContext.tsx` — 机构/菜单 state
- `services/api.ts` — 新 API 方法
- `types/index.ts` — 新类型定义

2948
docs/plans/2026-02-14-menu-role-org-impl.md

File diff suppressed because it is too large

5
frontend/react-shadcn/pc/package.json

@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "tests/run-tests.bat",
"test:check": "node tests/check-services.cjs",
"verify:api": "node scripts/verify-api.cjs"
}, },
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

142
frontend/react-shadcn/pc/scripts/verify-api.cjs

@ -0,0 +1,142 @@
/**
* API 对接验证脚本
* 运行: node scripts/verify-api.cjs
*/
const http = require('http');
const API_BASE_URL = 'localhost';
const API_PORT = 8888;
function makeRequest(method, path, body = null, token = null) {
return new Promise((resolve) => {
const options = {
hostname: API_BASE_URL,
port: API_PORT,
path: `/api/v1${path}`,
method: method,
headers: {
'Content-Type': 'application/json',
},
};
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve({
success: res.statusCode === 200,
status: res.statusCode,
data: json,
});
} catch {
resolve({
success: res.statusCode === 200,
status: res.statusCode,
data: data,
});
}
});
});
req.on('error', (err) => {
resolve({
success: false,
error: err.message,
});
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function main() {
console.log('🔍 API 对接验证\n');
let token = null;
// 1. Login
console.log('POST /api/v1/login');
const loginResult = await makeRequest('POST', '/login', {
email: 'admin@example.com',
password: 'password123',
});
if (loginResult.success && loginResult.data.token) {
token = loginResult.data.token;
console.log(' ✅ 登录成功');
console.log(` 📝 Token: ${token.substring(0, 20)}...`);
} else {
console.log(' ❌ 登录失败');
console.log(` 错误: ${loginResult.data?.message || loginResult.error}`);
return;
}
// 2. Get Profile
console.log('\nGET /api/v1/profile/me');
const profileResult = await makeRequest('GET', '/profile/me', null, token);
console.log(
profileResult.success
? ' ✅ 获取个人资料成功'
: ` ❌ 失败: ${profileResult.data?.message || 'Unknown'}`
);
// 3. Update Profile
console.log('\nPUT /api/v1/profile/me');
const updateResult = await makeRequest(
'PUT',
'/profile/me',
{ username: 'admin', phone: '13800138000' },
token
);
console.log(
updateResult.success
? ' ✅ 更新个人资料成功'
: ` ❌ 失败: ${updateResult.data?.message || 'Unknown'}`
);
// 4. Get Users
console.log('\nGET /api/v1/users');
const usersResult = await makeRequest('GET', '/users', null, token);
console.log(
usersResult.success
? ` ✅ 获取用户列表成功 (${usersResult.data?.data?.users?.length || 0} 个用户)`
: ` ❌ 失败: ${usersResult.data?.message || 'Unknown'}`
);
// 5. Dashboard Stats (可能不存在)
console.log('\nGET /api/v1/dashboard/stats');
const statsResult = await makeRequest('GET', '/dashboard/stats', null, token);
console.log(
statsResult.success
? ' ✅ 获取仪表板统计成功'
: ' ⚠️ 端点不存在或需要后端实现'
);
// 6. Dashboard Activities (可能不存在)
console.log('\nGET /api/v1/dashboard/activities');
const activitiesResult = await makeRequest(
'GET',
'/dashboard/activities?limit=5',
null,
token
);
console.log(
activitiesResult.success
? ' ✅ 获取最近活动成功'
: ' ⚠️ 端点不存在或需要后端实现'
);
console.log('\n✨ 验证完成');
}
main();

12
frontend/react-shadcn/pc/src/App.tsx

@ -3,9 +3,15 @@ import { AuthProvider } from './contexts/AuthContext'
import { ProtectedRoute } from './components/layout/ProtectedRoute' import { ProtectedRoute } from './components/layout/ProtectedRoute'
import { MainLayout } from './components/layout/MainLayout' import { MainLayout } from './components/layout/MainLayout'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { SSOCallbackPage } from './pages/SSOCallbackPage'
import { DashboardPage } from './pages/DashboardPage' import { DashboardPage } from './pages/DashboardPage'
import { UserManagementPage } from './pages/UserManagementPage' import { UserManagementPage } from './pages/UserManagementPage'
import { SettingsPage } from './pages/SettingsPage' import { SettingsPage } from './pages/SettingsPage'
import { FileManagementPage } from './pages/FileManagementPage'
import { MyPage } from './pages/MyPage'
import { MenuManagementPage } from './pages/MenuManagementPage'
import { RoleManagementPage } from './pages/RoleManagementPage'
import { OrganizationManagementPage } from './pages/OrganizationManagementPage'
function App() { function App() {
return ( return (
@ -13,6 +19,7 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/sso/callback" element={<SSOCallbackPage />} />
<Route <Route
path="/" path="/"
element={ element={
@ -24,6 +31,11 @@ function App() {
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} /> <Route path="dashboard" element={<DashboardPage />} />
<Route path="users" element={<UserManagementPage />} /> <Route path="users" element={<UserManagementPage />} />
<Route path="files" element={<FileManagementPage />} />
<Route path="my" element={<MyPage />} />
<Route path="menus" element={<MenuManagementPage />} />
<Route path="roles" element={<RoleManagementPage />} />
<Route path="organizations" element={<OrganizationManagementPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
</Routes> </Routes>

9
frontend/react-shadcn/pc/src/components/layout/MainLayout.tsx

@ -1,15 +1,20 @@
import { Outlet } from 'react-router-dom' import { Outlet, useLocation } from 'react-router-dom'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { Header } from './Header' import { Header } from './Header'
const pageTitles: Record<string, { title: string; subtitle?: string }> = { const pageTitles: Record<string, { title: string; subtitle?: string }> = {
'/my': { title: '我的', subtitle: '个人信息与机构' },
'/dashboard': { title: '仪表盘', subtitle: '系统概览与数据统计' }, '/dashboard': { title: '仪表盘', subtitle: '系统概览与数据统计' },
'/users': { title: '用户管理', subtitle: '管理系统用户账号' }, '/users': { title: '用户管理', subtitle: '管理系统用户账号' },
'/files': { title: '文件管理', subtitle: '上传与管理系统文件' },
'/roles': { title: '角色管理', subtitle: '管理系统角色与权限' },
'/menus': { title: '菜单管理', subtitle: '配置系统导航菜单' },
'/organizations': { title: '机构管理', subtitle: '管理组织架构与成员' },
'/settings': { title: '系统设置', subtitle: '配置系统参数' }, '/settings': { title: '系统设置', subtitle: '配置系统参数' },
} }
export function MainLayout() { export function MainLayout() {
const pathname = window.location.pathname const { pathname } = useLocation()
const pageInfo = pageTitles[pathname] || { title: 'BASE', subtitle: '' } const pageInfo = pageTitles[pathname] || { title: 'BASE', subtitle: '' }
return ( return (

117
frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx

@ -1,15 +1,43 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Users, LogOut, Settings } from 'lucide-react' import {
LayoutDashboard, Users, LogOut, Settings, FolderOpen,
Shield, Menu as MenuIcon, Building2, User, ChevronDown,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { useState, useRef, useEffect } from 'react'
import type { MenuItem } from '@/types'
const navItems = [ const iconMap: Record<string, LucideIcon> = {
{ path: '/dashboard', icon: LayoutDashboard, label: '首页' }, User, LayoutDashboard, Users, FolderOpen, Shield,
{ path: '/users', icon: Users, label: '用户管理' }, Menu: MenuIcon, Building2, Settings,
{ path: '/settings', icon: Settings, label: '设置' }, }
]
function getIcon(iconName: string): LucideIcon {
return iconMap[iconName] || LayoutDashboard
}
export function Sidebar() { export function Sidebar() {
const { user, logout } = useAuth() const { user, logout, userMenus, currentOrg, userOrgs, switchOrg } = useAuth()
const [orgDropdownOpen, setOrgDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOrgDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSwitchOrg = async (orgId: number) => {
await switchOrg(orgId)
setOrgDropdownOpen(false)
}
const menuItems: MenuItem[] = userMenus.length > 0 ? userMenus : []
return ( return (
<aside className="w-64 h-screen bg-gray-900/80 backdrop-blur-xl border-r border-gray-800 flex flex-col fixed left-0 top-0 z-40"> <aside className="w-64 h-screen bg-gray-900/80 backdrop-blur-xl border-r border-gray-800 flex flex-col fixed left-0 top-0 z-40">
@ -26,26 +54,61 @@ export function Sidebar() {
</div> </div>
</div> </div>
{/* Navigation */} {/* Org Switcher */}
<nav className="flex-1 p-4 space-y-1"> {userOrgs.length > 0 && (
{navItems.map((item) => ( <div className="px-4 pt-4" ref={dropdownRef}>
<NavLink <button
key={item.path} onClick={() => setOrgDropdownOpen(!orgDropdownOpen)}
to={item.path} className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-sm text-gray-300 hover:bg-gray-800 transition-colors"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200',
'font-body text-sm font-medium',
isActive
? 'bg-gradient-to-r from-sky-500/20 to-blue-600/20 text-sky-400 border border-sky-500/30'
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
)
}
> >
<item.icon className="h-5 w-5" /> <span className="truncate">{currentOrg?.name || '选择机构'}</span>
{item.label} <ChevronDown className={cn('h-4 w-4 transition-transform', orgDropdownOpen && 'rotate-180')} />
</NavLink> </button>
))} {orgDropdownOpen && (
<div className="mt-1 py-1 rounded-lg bg-gray-800 border border-gray-700 shadow-xl">
{userOrgs.map((org) => (
<button
key={org.orgId}
onClick={() => handleSwitchOrg(org.orgId)}
className={cn(
'w-full text-left px-3 py-2 text-sm transition-colors',
currentOrg?.id === org.orgId
? 'text-sky-400 bg-sky-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50'
)}
>
<div className="truncate">{org.orgName}</div>
<div className="text-xs text-gray-500">{org.roleName}</div>
</button>
))}
</div>
)}
</div>
)}
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{menuItems.map((item) => {
const Icon = getIcon(item.icon)
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200',
'font-body text-sm font-medium',
isActive
? 'bg-gradient-to-r from-sky-500/20 to-blue-600/20 text-sky-400 border border-sky-500/30'
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
)
}
>
<Icon className="h-5 w-5" />
{item.name}
</NavLink>
)
})}
</nav> </nav>
{/* User Info */} {/* User Info */}
@ -61,7 +124,7 @@ export function Sidebar() {
{user?.username || 'User'} {user?.username || 'User'}
</p> </p>
<p className="text-xs text-gray-500 font-body truncate"> <p className="text-xs text-gray-500 font-body truncate">
{user?.email || ''} {user?.role || ''}
</p> </p>
</div> </div>
<button <button

182
frontend/react-shadcn/pc/src/contexts/AuthContext.tsx

@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import type { User } from '@/types' import type { User, MenuItem, UserOrgInfo } from '@/types'
import { apiClient } from '@/services/api' import { apiClient } from '@/services/api'
interface AuthContextType { interface AuthContextType {
@ -7,8 +7,15 @@ interface AuthContextType {
token: string | null token: string | null
isAuthenticated: boolean isAuthenticated: boolean
isLoading: boolean isLoading: boolean
login: (email: string, password: string) => Promise<void> menusLoaded: boolean
login: (account: string, password: string) => Promise<void>
loginWithToken: (token: string) => void
logout: () => void logout: () => void
currentOrg: { id: number; name: string } | null
userOrgs: UserOrgInfo[]
userMenus: MenuItem[]
switchOrg: (orgId: number) => Promise<void>
refreshMenus: () => Promise<void>
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined) const AuthContext = createContext<AuthContextType | undefined>(undefined)
@ -17,6 +24,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null) const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [currentOrg, setCurrentOrg] = useState<{ id: number; name: string } | null>(null)
const [userOrgs, setUserOrgs] = useState<UserOrgInfo[]>([])
const [userMenus, setUserMenus] = useState<MenuItem[]>([])
const [menusLoaded, setMenusLoaded] = useState(false)
useEffect(() => { useEffect(() => {
const storedToken = localStorage.getItem('token') const storedToken = localStorage.getItem('token')
@ -34,37 +45,173 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
} }
const savedOrg = localStorage.getItem('currentOrg')
if (savedOrg) {
try { setCurrentOrg(JSON.parse(savedOrg)) } catch {}
}
setIsLoading(false) setIsLoading(false)
}, []) }, [])
const login = async (email: string, password: string) => { const refreshMenus = async () => {
try { try {
const response = await apiClient.login({ email, password }) const data = await apiClient.getCurrentMenus()
setUserMenus(data.list || [])
} catch (e) {
console.error('Failed to fetch menus:', e)
} finally {
setMenusLoaded(true)
}
}
const loadUserContext = async () => {
try {
const orgsData = await apiClient.getUserOrgs()
const orgs = orgsData.list || []
setUserOrgs(orgs)
if (orgs.length > 0) {
const storedOrg = localStorage.getItem('currentOrg')
let selectedOrg = orgs[0]
if (storedOrg) {
try {
const parsed = JSON.parse(storedOrg)
const found = orgs.find((o: UserOrgInfo) => o.orgId === parsed.id)
if (found) selectedOrg = found
} catch {}
}
setCurrentOrg({ id: selectedOrg.orgId, name: selectedOrg.orgName })
localStorage.setItem('currentOrg', JSON.stringify({ id: selectedOrg.orgId, name: selectedOrg.orgName }))
}
await refreshMenus()
} catch (e) {
console.error('Failed to load user context:', e)
await refreshMenus()
}
}
const switchOrgFn = async (orgId: number) => {
try {
const data = await apiClient.switchOrg(orgId)
if (data.token) {
localStorage.setItem('token', data.token)
setToken(data.token)
try {
const payload = JSON.parse(atob(data.token.split('.')[1]))
const userData: User = {
id: payload.userId || 0,
username: payload.username || '',
email: '',
role: payload.role || 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} catch {}
const org = userOrgs.find((o: UserOrgInfo) => o.orgId === orgId)
if (org) {
setCurrentOrg({ id: org.orgId, name: org.orgName })
localStorage.setItem('currentOrg', JSON.stringify({ id: org.orgId, name: org.orgName }))
}
await refreshMenus()
}
} catch (e) {
console.error('Failed to switch org:', e)
throw e
}
}
useEffect(() => {
if (token && !isLoading) {
loadUserContext()
}
}, [token, isLoading])
const login = async (account: string, password: string) => {
try {
const response = await apiClient.login({ account, password })
if (response.success && response.token) { if (response.success && response.token) {
setToken(response.token) setToken(response.token)
// Mock user data - in real app, fetch from API // 解析 JWT 获取用户信息
const userData: User = { try {
id: 1, const payload = JSON.parse(atob(response.token.split('.')[1]))
username: email.split('@')[0], const userData: User = {
email, id: payload.userId || 0,
createdAt: new Date().toISOString(), username: payload.username || account,
updatedAt: new Date().toISOString(), email: '',
role: payload.role || 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} catch {
const userData: User = {
id: 0,
username: account,
email: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} }
setUser(userData) } else {
localStorage.setItem('user', JSON.stringify(userData)) throw new Error(response.message || '登录失败,请检查用户名和密码')
} }
} catch (error) { } catch (error) {
throw error throw error
} }
} }
const loginWithToken = useCallback((ssoToken: string) => {
localStorage.setItem('token', ssoToken)
setToken(ssoToken)
// 解析 JWT 获取用户信息
try {
const payload = JSON.parse(atob(ssoToken.split('.')[1]))
const userData: User = {
id: payload.userId || 0,
username: payload.username || '',
email: payload.email || '',
role: payload.role || 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} catch {
// JWT 解析失败,使用占位数据
const userData: User = {
id: 0,
username: 'SSO User',
email: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
}
}, [])
const logout = () => { const logout = () => {
apiClient.logout() apiClient.logout()
setToken(null) setToken(null)
setUser(null) setUser(null)
setCurrentOrg(null)
setUserOrgs([])
setUserMenus([])
setMenusLoaded(false)
localStorage.removeItem('user') localStorage.removeItem('user')
localStorage.removeItem('currentOrg')
} }
const value: AuthContextType = { const value: AuthContextType = {
@ -72,8 +219,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token, token,
isAuthenticated: !!token, isAuthenticated: !!token,
isLoading, isLoading,
menusLoaded,
login, login,
loginWithToken,
logout, logout,
currentOrg,
userOrgs,
userMenus,
switchOrg: switchOrgFn,
refreshMenus,
} }
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>

158
frontend/react-shadcn/pc/src/pages/DashboardPage.tsx

@ -1,38 +1,19 @@
import { Users, Zap, Activity, Database } from 'lucide-react' import { useState, useEffect } from 'react'
import { Users, Zap, Activity as ActivityIcon, Database, Loader2 } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
import { apiClient } from '@/services/api'
import type { DashboardStats, Activity } from '@/types'
const stats = [ // Fallback data when API is not available
{ const fallbackStats: DashboardStats = {
title: '总用户数', totalUsers: 1234,
value: '1,234', activeUsers: 856,
change: '+12%', systemLoad: 32,
icon: Users, dbStatus: '正常',
color: 'from-sky-500 to-blue-600', userGrowth: 65, // Single value for chart
}, }
{
title: '活跃用户',
value: '856',
change: '+8%',
icon: Activity,
color: 'from-green-500 to-emerald-600',
},
{
title: '系统负载',
value: '32%',
change: '-5%',
icon: Zap,
color: 'from-amber-500 to-orange-600',
},
{
title: '数据库状态',
value: '正常',
change: '稳定',
icon: Database,
color: 'from-purple-500 to-violet-600',
},
]
const recentActivity = [ const fallbackActivities: Activity[] = [
{ id: 1, user: 'john@example.com', action: '登录系统', time: '5 分钟前', status: 'success' }, { id: 1, user: 'john@example.com', action: '登录系统', time: '5 分钟前', status: 'success' },
{ id: 2, user: 'jane@example.com', action: '更新资料', time: '15 分钟前', status: 'success' }, { id: 2, user: 'jane@example.com', action: '更新资料', time: '15 分钟前', status: 'success' },
{ id: 3, user: 'admin@example.com', action: '创建用户', time: '1 小时前', status: 'success' }, { id: 3, user: 'admin@example.com', action: '创建用户', time: '1 小时前', status: 'success' },
@ -40,12 +21,115 @@ const recentActivity = [
{ id: 5, user: 'alice@example.com', action: '登录失败', time: '3 小时前', status: 'error' }, { id: 5, user: 'alice@example.com', action: '登录失败', time: '3 小时前', status: 'error' },
] ]
// Chart data (12 months)
const chartData = [65, 72, 68, 80, 75, 85, 82, 90, 88, 95, 92, 100]
export function DashboardPage() { export function DashboardPage() {
const [isLoading, setIsLoading] = useState(true)
const [stats, setStats] = useState<DashboardStats | null>(null)
const [activities, setActivities] = useState<Activity[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadDashboardData()
}, [])
const loadDashboardData = async () => {
try {
setIsLoading(true)
setError(null)
// 调用仪表盘 API 获取真实统计数据
const [statsResponse, activitiesResponse] = await Promise.all([
apiClient.getDashboardStats().catch(() => null),
apiClient.getRecentActivities(5).catch(() => null),
])
if (statsResponse?.success && statsResponse.data) {
setStats(statsResponse.data)
} else {
setStats(fallbackStats)
}
if (activitiesResponse?.success && activitiesResponse.data) {
setActivities(activitiesResponse.data)
} else {
setActivities(fallbackActivities)
}
} catch (err) {
console.error('加载仪表盘数据失败:', err)
setError('加载数据失败')
setStats(fallbackStats)
setActivities(fallbackActivities)
} finally {
setIsLoading(false)
}
}
// Format number with commas
const formatNumber = (num: number): string => {
return num.toLocaleString('zh-CN')
}
// Calculate growth percentage (mock calculation)
const calculateGrowth = (current: number): string => {
const growth = Math.floor(Math.random() * 20) - 5
return growth >= 0 ? `+${growth}%` : `${growth}%`
}
const statsConfig = [
{
title: '总用户数',
value: stats ? formatNumber(stats.totalUsers) : '-',
change: calculateGrowth(stats?.totalUsers || 0),
icon: Users,
color: 'from-sky-500 to-blue-600',
},
{
title: '活跃用户',
value: stats ? formatNumber(stats.activeUsers) : '-',
change: calculateGrowth(stats?.activeUsers || 0),
icon: ActivityIcon,
color: 'from-green-500 to-emerald-600',
},
{
title: '系统负载',
value: stats ? `${stats.systemLoad}%` : '-',
change: '-5%',
icon: Zap,
color: 'from-amber-500 to-orange-600',
},
{
title: '数据库状态',
value: stats?.dbStatus || '正常',
change: '稳定',
icon: Database,
color: 'from-purple-500 to-violet-600',
},
]
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-12 w-12 animate-spin text-sky-400" />
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{error && (
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center">
<span>{error}</span>
<button onClick={loadDashboardData} className="underline hover:text-red-300">
</button>
</div>
)}
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-fade-in" style={{ animationDelay: '0.1s' }}> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-fade-in" style={{ animationDelay: '0.1s' }}>
{stats.map((stat, index) => ( {statsConfig.map((stat, index) => (
<Card key={index} className="overflow-hidden hover:border-gray-700 transition-colors"> <Card key={index} className="overflow-hidden hover:border-gray-700 transition-colors">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -65,14 +149,14 @@ export function DashboardPage() {
{/* Main Content Grid */} {/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chart Placeholder */} {/* Chart */}
<Card className="lg:col-span-2 animate-fade-in" style={{ animationDelay: '0.2s' }}> <Card className="lg:col-span-2 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-64 flex items-end justify-between gap-2 px-4"> <div className="h-64 flex items-end justify-between gap-2 px-4">
{[65, 72, 68, 80, 75, 85, 82, 90, 88, 95, 92, 100].map((height, i) => ( {chartData.map((height, i) => (
<div <div
key={i} key={i}
className="flex-1 max-w-8 bg-gradient-to-t from-sky-600 to-sky-400 rounded-t-sm transition-all duration-300 hover:from-sky-500 hover:to-sky-300 relative group" className="flex-1 max-w-8 bg-gradient-to-t from-sky-600 to-sky-400 rounded-t-sm transition-all duration-300 hover:from-sky-500 hover:to-sky-300 relative group"
@ -108,7 +192,7 @@ export function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{recentActivity.map((activity) => ( {activities.map((activity) => (
<div <div
key={activity.id} key={activity.id}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors" className="flex items-start gap-3 p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors"
@ -145,7 +229,7 @@ export function DashboardPage() {
{ label: '添加用户', icon: Users, action: 'users' }, { label: '添加用户', icon: Users, action: 'users' },
{ label: '系统设置', icon: Zap, action: 'settings' }, { label: '系统设置', icon: Zap, action: 'settings' },
{ label: '数据备份', icon: Database, action: 'backup' }, { label: '数据备份', icon: Database, action: 'backup' },
{ label: '查看日志', icon: Activity, action: 'logs' }, { label: '查看日志', icon: ActivityIcon, action: 'logs' },
].map((item, index) => ( ].map((item, index) => (
<button <button
key={index} key={index}

531
frontend/react-shadcn/pc/src/pages/FileManagementPage.tsx

@ -0,0 +1,531 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Upload, Search, Eye, Edit2, Trash2, Download, Image, Film, FileText, File as FileIcon, ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
import { Modal } from '@/components/ui/Modal'
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/Table'
import type { FileInfo, UpdateFileRequest } from '@/types'
import { apiClient } from '@/services/api'
import { useAuth } from '@/contexts/AuthContext'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1'
const CATEGORY_OPTIONS = [
{ value: '', label: '全部分类' },
{ value: 'default', label: '默认' },
{ value: 'avatar', label: '头像' },
{ value: 'document', label: '文档' },
{ value: 'media', label: '媒体' },
]
const MIME_FILTER_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
{ value: 'application/pdf', label: 'PDF' },
]
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return <Image className="h-4 w-4 text-emerald-400" />
if (mimeType.startsWith('video/')) return <Film className="h-4 w-4 text-violet-400" />
if (mimeType === 'application/pdf') return <FileText className="h-4 w-4 text-rose-400" />
return <FileIcon className="h-4 w-4 text-text-secondary" />
}
function getPreviewUrl(file: FileInfo): string {
const token = localStorage.getItem('token')
const base = `${API_BASE_URL}/file/${file.id}/url`
return token ? `${base}?token=${token}` : base
}
export function FileManagementPage() {
const { user: currentUser } = useAuth()
const [files, setFiles] = useState<FileInfo[]>([])
const [total, setTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [mimeFilter, setMimeFilter] = useState('')
const [page, setPage] = useState(1)
const pageSize = 20
const [error, setError] = useState<string | null>(null)
// Upload
const [isUploading, setIsUploading] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [uploadCategory, setUploadCategory] = useState('default')
const [uploadIsPublic, setUploadIsPublic] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Edit
const [editModalOpen, setEditModalOpen] = useState(false)
const [editingFile, setEditingFile] = useState<FileInfo | null>(null)
const [editForm, setEditForm] = useState<UpdateFileRequest>({})
// Preview
const [previewFile, setPreviewFile] = useState<FileInfo | null>(null)
// Delete
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [fileToDelete, setFileToDelete] = useState<FileInfo | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin'
const fetchFiles = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await apiClient.getFiles({
page,
pageSize,
keyword: searchQuery || undefined,
category: categoryFilter || undefined,
mimeType: mimeFilter || undefined,
})
if (response.success && response.data) {
setFiles(response.data.list || [])
setTotal(response.data.total || 0)
} else {
setFiles([])
setTotal(0)
}
} catch {
setError('获取文件列表失败')
setFiles([])
} finally {
setIsLoading(false)
}
}, [page, searchQuery, categoryFilter, mimeFilter])
useEffect(() => {
fetchFiles()
}, [fetchFiles])
// Upload
const handleUpload = async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
setIsUploading(true)
try {
for (const f of Array.from(fileList)) {
await apiClient.uploadFile(f, uploadCategory, uploadIsPublic)
}
await fetchFiles()
} catch {
alert('上传失败')
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleUpload(e.dataTransfer.files)
}
// Preview
const handlePreview = (file: FileInfo) => {
setPreviewFile(file)
}
// Edit
const openEditModal = (file: FileInfo) => {
setEditingFile(file)
setEditForm({ name: file.name, category: file.category, isPublic: file.isPublic })
setEditModalOpen(true)
}
const handleUpdate = async () => {
if (!editingFile) return
try {
await apiClient.updateFile(editingFile.id, editForm)
setEditModalOpen(false)
setEditingFile(null)
await fetchFiles()
} catch {
alert('更新失败')
}
}
// Delete
const handleDelete = async () => {
if (!fileToDelete) return
try {
setIsDeleting(true)
await apiClient.deleteFile(fileToDelete.id)
setDeleteConfirmOpen(false)
setFileToDelete(null)
await fetchFiles()
} catch {
alert('删除失败')
} finally {
setIsDeleting(false)
}
}
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6 animate-fade-in">
{/* Upload Area */}
<Card>
<CardContent className="p-6">
<div
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-300 ${
isDragging
? 'border-sky-400 bg-sky-500/10 shadow-[0_0_30px_rgba(14,165,233,0.15)]'
: 'border-border-secondary/60 hover:border-border-secondary'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4 transition-all duration-300 ${
isDragging ? 'bg-sky-500/20 scale-110' : 'bg-card'
}`}>
<Upload className={`h-6 w-6 transition-colors ${isDragging ? 'text-sky-400' : 'text-text-muted'}`} />
</div>
<p className="text-foreground mb-1 font-medium">
{isUploading ? '正在上传...' : '拖拽文件到此处上传'}
</p>
<p className="text-text-muted text-sm mb-5">PDF </p>
<div className="flex items-center justify-center gap-4 mb-5">
<select
className="rounded-lg border border-border bg-muted px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-sky-500"
value={uploadCategory}
onChange={(e) => setUploadCategory(e.target.value)}
>
{CATEGORY_OPTIONS.filter(o => o.value).map(opt => (
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={uploadIsPublic}
onChange={(e) => setUploadIsPublic(e.target.checked)}
className="rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500"
/>
</label>
</div>
<Button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
<Upload className="h-4 w-4" />
{isUploading ? '上传中...' : '选择文件'}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
className="absolute w-0 h-0 overflow-hidden opacity-0"
tabIndex={-1}
aria-hidden="true"
onChange={(e) => handleUpload(e.target.files)}
/>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1 w-full sm:max-w-md">
<Input
placeholder="搜索文件名..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }}
leftIcon={<Search className="h-4 w-4" />}
/>
</div>
<select
className="rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-sky-500"
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
>
{CATEGORY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option>
))}
</select>
<select
className="rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-sky-500"
value={mimeFilter}
onChange={(e) => { setMimeFilter(e.target.value); setPage(1) }}
>
{MIME_FILTER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option>
))}
</select>
</div>
</CardContent>
</Card>
{/* Error */}
{error && (
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center">
<span>{error}</span>
<button onClick={fetchFiles} className="underline hover:text-red-300"></button>
</div>
)}
{/* File Table */}
<Card>
<CardHeader>
<CardTitle> ({total})</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow><TableCell colSpan={7} className="text-center text-text-muted py-12">...</TableCell></TableRow>
) : files.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center text-text-muted py-12"></TableCell></TableRow>
) : (
files.map((file) => (
<TableRow key={file.id}>
<TableCell>
<div className="flex items-center gap-2.5">
{getFileIcon(file.mimeType)}
<span className="font-medium text-foreground truncate max-w-[220px]" title={file.name}>
{file.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-400 border border-sky-500/25">
{file.category}
</span>
</TableCell>
<TableCell className="text-text-secondary tabular-nums">{formatFileSize(file.size)}</TableCell>
<TableCell className="text-text-muted text-xs font-mono">{file.mimeType.split('/')[1] || file.mimeType}</TableCell>
<TableCell>
<span className={`inline-flex items-center gap-1 text-xs ${file.isPublic ? 'text-emerald-400' : 'text-text-muted'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${file.isPublic ? 'bg-emerald-400' : 'bg-gray-600'}`} />
{file.isPublic ? '公开' : '私有'}
</span>
</TableCell>
<TableCell className="text-text-muted text-sm">
{new Date(file.createdAt).toLocaleDateString('zh-CN')}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)} title="预览">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEditModal(file)} title="编辑">
<Edit2 className="h-4 w-4" />
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
onClick={() => { setFileToDelete(file); setDeleteConfirmOpen(true) }}
className="text-red-400 hover:text-red-300"
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border">
<p className="text-sm text-text-muted">
{total}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 py-1.5 text-sm text-text-secondary tabular-nums">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Preview Modal */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
{previewFile && (
<div className="py-4">
{previewFile.mimeType.startsWith('image/') && (
<img
src={getPreviewUrl(previewFile)}
alt={previewFile.name}
className="max-w-full max-h-[65vh] mx-auto rounded-lg shadow-2xl"
crossOrigin="anonymous"
/>
)}
{previewFile.mimeType.startsWith('video/') && (
<video
src={getPreviewUrl(previewFile)}
controls
className="max-w-full max-h-[65vh] mx-auto rounded-lg"
>
</video>
)}
{previewFile.mimeType === 'application/pdf' && (
<iframe
src={getPreviewUrl(previewFile)}
className="w-full h-[65vh] rounded-lg border border-border-secondary"
title={previewFile.name}
/>
)}
{!previewFile.mimeType.startsWith('image/') &&
!previewFile.mimeType.startsWith('video/') &&
previewFile.mimeType !== 'application/pdf' && (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-card mb-5">
<FileIcon className="h-10 w-10 text-text-muted" />
</div>
<p className="text-foreground font-medium mb-1">{previewFile.name}</p>
<p className="text-sm text-text-muted mb-6">
{formatFileSize(previewFile.size)} · {previewFile.mimeType}
</p>
<a
href={getPreviewUrl(previewFile)}
download={previewFile.name}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-sky-500 text-foreground rounded-lg hover:bg-sky-600 transition font-medium text-sm"
>
<Download className="h-4 w-4" />
</a>
</div>
)}
<div className="mt-4 pt-4 border-t border-border grid grid-cols-2 gap-3 text-sm">
<div><span className="text-text-muted"></span><span className="text-foreground">{formatFileSize(previewFile.size)}</span></div>
<div><span className="text-text-muted"></span><span className="text-foreground">{previewFile.mimeType}</span></div>
<div><span className="text-text-muted"></span><span className="text-foreground">{previewFile.category}</span></div>
<div><span className="text-text-muted"></span><span className="text-foreground">{previewFile.createdAt}</span></div>
</div>
</div>
)}
</Modal>
{/* Edit Modal */}
<Modal
isOpen={editModalOpen}
onClose={() => { setEditModalOpen(false); setEditingFile(null) }}
title="编辑文件信息"
size="md"
footer={
<>
<Button variant="outline" onClick={() => { setEditModalOpen(false); setEditingFile(null) }}></Button>
<Button onClick={handleUpdate}></Button>
</>
}
>
<div className="space-y-4">
<Input
label="文件名"
value={editForm.name || ''}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5"></label>
<select
className="w-full rounded-lg border border-border bg-muted px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-sky-500"
value={editForm.category || ''}
onChange={(e) => setEditForm({ ...editForm, category: e.target.value })}
>
{CATEGORY_OPTIONS.filter(o => o.value).map(opt => (
<option key={opt.value} value={opt.value} className="bg-card">{opt.label}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={editForm.isPublic || false}
onChange={(e) => setEditForm({ ...editForm, isPublic: e.target.checked })}
className="rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500"
/>
</label>
</div>
</Modal>
{/* Delete Confirm Modal */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }}
title="确认删除"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setFileToDelete(null) }} disabled={isDeleting}></Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? '删除中...' : '确认删除'}
</Button>
</>
}
>
<div className="py-4">
<p className="text-foreground">
<span className="font-medium text-foreground">{fileToDelete?.name}</span>
</p>
<p className="text-sm text-text-muted mt-2"></p>
</div>
</Modal>
</div>
)
}

369
frontend/react-shadcn/pc/src/pages/LoginPage.tsx

@ -1,26 +1,56 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Mail, Lock, AlertCircle, Zap } from 'lucide-react' import { UserRound, Lock, AlertCircle, Eye, EyeOff, ArrowRight, Shield, Cpu, BarChart3 } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { apiClient } from '@/services/api'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
export function LoginPage() { export function LoginPage() {
const [email, setEmail] = useState('') const [account, setAccount] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [ssoLoading, setSsoLoading] = useState(false)
const [mounted, setMounted] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { login } = useAuth() const { login } = useAuth()
useEffect(() => {
setMounted(true)
// 检查 SSO 错误参数
const ssoError = searchParams.get('error')
if (ssoError === 'sso_failed') {
setError('SSO 登录失败,请重试')
}
}, [searchParams])
const handleSSOLogin = async () => {
setSsoLoading(true)
setError('')
try {
const res = await apiClient.getSSOLoginUrl()
if (res.login_url) {
window.location.href = res.login_url
} else {
setError('获取 SSO 登录链接失败')
setSsoLoading(false)
}
} catch {
setError('SSO 服务不可用')
setSsoLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setIsLoading(true) setIsLoading(true)
try { try {
await login(email, password) await login(account, password)
navigate('/dashboard') navigate('/dashboard')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '登录失败') setError(err instanceof Error ? err.message : '登录失败')
@ -30,106 +60,283 @@ export function LoginPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden grid-pattern"> <div className="min-h-screen flex bg-[oklch(0.10_0.01_240)]">
{/* Animated background elements */} {/* Left Panel - Brand Showcase (visible from md breakpoint) */}
<div className="absolute inset-0 overflow-hidden"> <div
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-sky-500/10 rounded-full blur-3xl animate-pulse" /> className={`hidden md:flex md:w-[45%] lg:w-[55%] relative overflow-hidden flex-col justify-between p-8 lg:p-12 transition-all duration-700 ease-out ${
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }} /> mounted ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-8'
</div> }`}
>
{/* Gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-blue-600 to-sky-500" />
{/* Decorative grid lines */} {/* Subtle pattern overlay */}
<div className="absolute inset-0 pointer-events-none"> <div
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-sky-500/30 to-transparent" /> className="absolute inset-0 opacity-[0.07]"
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-purple-500/30 to-transparent" /> style={{
<div className="absolute top-0 bottom-0 left-0 w-px bg-gradient-to-b from-transparent via-sky-500/30 to-transparent" /> backgroundImage:
<div className="absolute top-0 bottom-0 right-0 w-px bg-gradient-to-b from-transparent via-purple-500/30 to-transparent" /> 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)',
</div> backgroundSize: '32px 32px',
}}
/>
{/* Main content */} {/* Floating orbs */}
<div className="relative z-10 w-full max-w-md px-4 animate-scale-in"> <div className="absolute top-20 right-20 w-48 lg:w-72 h-48 lg:h-72 bg-white/10 rounded-full blur-3xl animate-login-float" />
{/* Logo and title */} <div
<div className="text-center mb-8"> className="absolute bottom-32 left-16 w-40 lg:w-56 h-40 lg:h-56 bg-sky-300/15 rounded-full blur-3xl animate-login-float"
<div className="inline-flex items-center justify-center w-20 h-20 mb-6 rounded-2xl bg-gradient-to-br from-sky-500 to-blue-600 glow-primary relative overflow-hidden"> style={{ animationDelay: '2s', animationDuration: '8s' }}
<div className="absolute inset-0 stripe-pattern" /> />
<Zap className="h-10 w-10 text-white relative z-10" /> <div
className="absolute top-1/2 right-1/3 w-32 lg:w-40 h-32 lg:h-40 bg-indigo-300/10 rounded-full blur-2xl animate-login-float"
style={{ animationDelay: '4s', animationDuration: '10s' }}
/>
{/* Content */}
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Cpu className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold text-white tracking-wider font-display">
BASE
</span>
</div> </div>
<h1 className="text-4xl font-bold text-white font-display tracking-wide mb-2"> </div>
BASE<span className="text-sky-400">.</span>
<div className="relative z-10 max-w-lg">
<h1
className={`text-3xl lg:text-5xl font-bold text-white leading-tight mb-4 lg:mb-6 font-display tracking-wide transition-all duration-700 delay-200 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
}`}
>
<br />
<span className="text-sky-200"></span>
</h1> </h1>
<p className="text-gray-500 font-body"></p> <p
className={`text-base lg:text-lg text-blue-100/80 leading-relaxed mb-6 lg:mb-10 font-body transition-all duration-700 delay-300 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
}`}
>
</p>
{/* Feature highlights */}
<div
className={`space-y-3 lg:space-y-4 transition-all duration-700 delay-500 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'
}`}
>
{[
{ icon: Shield, text: 'JWT 认证与权限管理' },
{ icon: BarChart3, text: '实时数据仪表盘' },
{ icon: Cpu, text: 'Go + React 全栈架构' },
].map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-white/10 backdrop-blur-sm flex items-center justify-center flex-shrink-0">
<Icon className="h-4 w-4 text-sky-200" />
</div>
<span className="text-blue-100/90 text-sm font-body">{text}</span>
</div>
))}
</div>
</div>
{/* Bottom */}
<div className="relative z-10">
<p className="text-blue-200/50 text-sm font-body">
&copy; 2026 Base System. All rights reserved.
</p>
</div>
</div>
{/* Right Panel - Login Form */}
<div className="flex-1 flex items-center justify-center px-4 py-8 sm:px-8 md:px-6 lg:px-8 relative overflow-hidden">
{/* Subtle background effects for right panel */}
<div className="absolute inset-0">
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-indigo-500/[0.03] via-transparent to-sky-500/[0.03]" />
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage:
'linear-gradient(rgba(99,102,241,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(99,102,241,0.3) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
</div> </div>
{/* Login form */} <div
<Card className="backdrop-blur-xl bg-gray-900/90 border-gray-800"> className={`relative z-10 w-full max-w-[420px] transition-all duration-700 delay-100 ease-out ${
<form onSubmit={handleSubmit} className="space-y-6"> mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
<div className="space-y-4"> }`}
>
{/* Mobile logo - only visible below md */}
<div className="md:hidden text-center mb-6">
<div className="inline-flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-sky-500 flex items-center justify-center">
<Cpu className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold text-white tracking-wider font-display">
BASE
</span>
</div>
</div>
{/* Form header */}
<div className="mb-6">
<h2 className="text-xl sm:text-2xl font-bold text-white font-display tracking-wide mb-1.5">
</h2>
<p className="text-gray-400 font-body text-sm">
</p>
</div>
{/* Login card */}
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] backdrop-blur-xl p-5 sm:p-6 lg:p-8 shadow-2xl shadow-black/20">
<form onSubmit={handleSubmit} className="space-y-4">
<Input <Input
type="email" type="text"
label="邮箱地址" label="手机号 / 用户名"
placeholder="user@example.com" placeholder="请输入手机号或用户名"
value={email} value={account}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setAccount(e.target.value)}
required required
leftIcon={<Mail className="h-4 w-4" />} leftIcon={<UserRound className="h-4 w-4" />}
disabled={isLoading} disabled={isLoading}
/> />
<Input <Input
type="password" type={showPassword ? 'text' : 'password'}
label="密码" label="密码"
placeholder="••••••••" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
leftIcon={<Lock className="h-4 w-4" />} leftIcon={<Lock className="h-4 w-4" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="cursor-pointer text-gray-500 hover:text-gray-300 transition-colors duration-200"
tabIndex={-1}
aria-label={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
}
disabled={isLoading} disabled={isLoading}
/> />
</div>
{error && ( {error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm font-body"> <div
<AlertCircle className="h-4 w-4 flex-shrink-0" /> className="flex items-center gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-body animate-scale-in"
<span>{error}</span> role="alert"
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isLoading}
>
</Button>
<div className="text-center">
<p className="text-sm text-gray-500 font-body">
{' '}
<button
type="button"
className="text-sky-400 hover:text-sky-300 transition-colors font-medium"
> >
<AlertCircle className="h-4 w-4 flex-shrink-0" />
</button> <span>{error}</span>
</p> </div>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full !bg-gradient-to-r !from-indigo-500 !to-sky-500 hover:!from-indigo-400 hover:!to-sky-400 !border-indigo-500/30 !shadow-lg !shadow-indigo-500/20 cursor-pointer group"
isLoading={isLoading}
>
<span></span>
{!isLoading && (
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
)}
</Button>
</form>
{/* Divider */}
<div className="relative my-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.06]" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-[oklch(0.12_0.01_240)] text-gray-500 font-body">
SSO
</span>
</div>
</div> </div>
</form>
</Card>
{/* Footer info */} {/* SSO Buttons */}
<div className="mt-8 text-center"> <div className="flex flex-col sm:flex-row gap-2.5">
<p className="text-xs text-gray-600 font-body"> <button
© 2026 Base System. All rights reserved. type="button"
</p> className="flex items-center justify-center gap-2.5 h-11 flex-1 rounded-xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.07] transition-colors duration-200 cursor-pointer group"
aria-label="使用 Google 登录"
>
<svg className="h-5 w-5 flex-shrink-0" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
<span className="text-sm text-gray-300 font-body group-hover:text-white transition-colors duration-200">Google</span>
</button>
<button
type="button"
className="flex items-center justify-center gap-2.5 h-11 flex-1 rounded-xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.07] transition-colors duration-200 cursor-pointer group"
aria-label="使用 GitHub 登录"
>
<svg className="h-5 w-5 flex-shrink-0 text-gray-300 group-hover:text-white transition-colors duration-200" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
</svg>
<span className="text-sm text-gray-300 font-body group-hover:text-white transition-colors duration-200">GitHub</span>
</button>
<button
type="button"
onClick={handleSSOLogin}
disabled={ssoLoading}
className="flex items-center justify-center gap-2.5 h-11 flex-1 rounded-xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.07] transition-colors duration-200 cursor-pointer group disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="使用 XHY.GROUP 登录"
>
<svg className="h-5 w-5 flex-shrink-0" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="xhy-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#6366F1" />
<stop offset="100%" stopColor="#06B6D4" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#xhy-grad)" />
<path d="M8 10L13.5 16L8 22H11.5L15.5 17.2L19.5 22H23L17.5 16L23 10H19.5L15.5 14.8L11.5 10H8Z" fill="white" fillOpacity="0.95" />
<rect x="6" y="25" width="20" height="1.5" rx="0.75" fill="white" fillOpacity="0.4" />
</svg>
<span className="text-sm text-gray-300 font-body group-hover:text-white transition-colors duration-200">XHY.GROUP</span>
</button>
</div>
{/* Register link */}
<p className="text-center text-sm text-gray-400 font-body mt-5">
{' '}
<button
type="button"
className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200 font-medium cursor-pointer"
>
</button>
</p>
</div>
{/* Mobile footer */}
<div className="md:hidden mt-6 text-center">
<p className="text-xs text-gray-600 font-body">
&copy; 2026 Base System. All rights reserved.
</p>
</div>
</div> </div>
</div> </div>
{/* Corner decorations */}
<div className="absolute top-0 left-0 w-24 h-24 border-l-2 border-t-2 border-sky-500/30 rounded-tl-3xl" />
<div className="absolute top-0 right-0 w-24 h-24 border-r-2 border-t-2 border-purple-500/30 rounded-tr-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 border-l-2 border-b-2 border-purple-500/30 rounded-bl-3xl" />
<div className="absolute bottom-0 right-0 w-24 h-24 border-r-2 border-b-2 border-sky-500/30 rounded-br-3xl" />
</div> </div>
) )
} }

796
frontend/react-shadcn/pc/src/pages/OrganizationManagementPage.tsx

@ -0,0 +1,796 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Search, Edit2, Trash2, Users, ChevronRight, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
import { Modal } from '@/components/ui/Modal'
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/Table'
import type { OrgInfo, CreateOrgRequest, UpdateOrgRequest, OrgMember, RoleInfo } from '@/types'
import { apiClient } from '@/services/api'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type FlatOrg = OrgInfo & { depth: number }
function flattenOrgTree(items: OrgInfo[], depth = 0): FlatOrg[] {
const result: FlatOrg[] = []
for (const item of items) {
result.push({ ...item, depth })
if (item.children && item.children.length > 0) {
result.push(...flattenOrgTree(item.children, depth + 1))
}
}
return result
}
function collectOrgOptions(items: OrgInfo[], depth = 0): { id: number; name: string; depth: number }[] {
const result: { id: number; name: string; depth: number }[] = []
for (const item of items) {
result.push({ id: item.id, name: item.name, depth })
if (item.children && item.children.length > 0) {
result.push(...collectOrgOptions(item.children, depth + 1))
}
}
return result
}
const STATUS_LABELS: Record<number, { text: string; className: string }> = {
1: { text: '正常', className: 'bg-green-500/20 text-green-400 border border-green-500/30' },
0: { text: '停用', className: 'bg-red-500/20 text-red-400 border border-red-500/30' },
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function OrganizationManagementPage() {
// --- Org tree state ---
const [orgTree, setOrgTree] = useState<OrgInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set())
// --- Create / Edit modal ---
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingOrg, setEditingOrg] = useState<OrgInfo | null>(null)
const [formData, setFormData] = useState<Partial<CreateOrgRequest>>({
parentId: 0,
name: '',
code: '',
leader: '',
phone: '',
email: '',
sortOrder: 0,
})
// --- Delete confirmation ---
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [orgToDelete, setOrgToDelete] = useState<FlatOrg | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
// --- Member management modal ---
const [memberModalOpen, setMemberModalOpen] = useState(false)
const [memberOrg, setMemberOrg] = useState<FlatOrg | null>(null)
const [members, setMembers] = useState<OrgMember[]>([])
const [isMembersLoading, setIsMembersLoading] = useState(false)
const [roles, setRoles] = useState<RoleInfo[]>([])
const [newMemberUserId, setNewMemberUserId] = useState('')
const [newMemberRoleId, setNewMemberRoleId] = useState<number>(0)
const [editingMemberUserId, setEditingMemberUserId] = useState<number | null>(null)
const [editingMemberRoleId, setEditingMemberRoleId] = useState<number>(0)
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const fetchOrganizations = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await apiClient.getOrganizations()
setOrgTree(response.list || [])
// Expand all by default on first load
const allIds = flattenOrgTree(response.list || []).map((o) => o.id)
setExpandedIds(new Set(allIds))
} catch (err) {
console.error('Failed to fetch organizations:', err)
setError('获取组织列表失败,请稍后重试')
setOrgTree([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchOrganizations()
}, [fetchOrganizations])
const fetchMembers = async (orgId: number) => {
try {
setIsMembersLoading(true)
const response = await apiClient.getOrgMembers(orgId)
setMembers(response.list || [])
} catch (err) {
console.error('Failed to fetch members:', err)
setMembers([])
} finally {
setIsMembersLoading(false)
}
}
const fetchRoles = async () => {
try {
const response = await apiClient.getRoles()
setRoles(response.list || [])
} catch (err) {
console.error('Failed to fetch roles:', err)
setRoles([])
}
}
// ---------------------------------------------------------------------------
// Org CRUD handlers
// ---------------------------------------------------------------------------
const resetForm = () => {
setFormData({ parentId: 0, name: '', code: '', leader: '', phone: '', email: '', sortOrder: 0 })
setEditingOrg(null)
}
const openModal = (org?: FlatOrg) => {
if (org) {
setEditingOrg(org)
setFormData({
parentId: org.parentId,
name: org.name,
code: org.code,
leader: org.leader,
phone: org.phone,
email: org.email,
sortOrder: org.sortOrder,
})
} else {
resetForm()
}
setIsModalOpen(true)
}
const handleCreateOrg = async () => {
try {
await apiClient.createOrganization(formData as CreateOrgRequest)
await fetchOrganizations()
setIsModalOpen(false)
resetForm()
} catch (err) {
console.error('Failed to create organization:', err)
alert('创建组织失败')
}
}
const handleUpdateOrg = async () => {
if (!editingOrg) return
try {
const data: UpdateOrgRequest = { ...formData }
await apiClient.updateOrganization(editingOrg.id, data)
await fetchOrganizations()
setIsModalOpen(false)
resetForm()
} catch (err) {
console.error('Failed to update organization:', err)
alert('更新组织失败')
}
}
const openDeleteConfirm = (org: FlatOrg) => {
setDeleteError(null)
// Reject if has children
if (org.children && org.children.length > 0) {
setDeleteError('该组织包含子组织,无法删除。请先删除或转移子组织。')
setOrgToDelete(org)
setDeleteConfirmOpen(true)
return
}
// Reject if has members
if (org.memberCount > 0) {
setDeleteError('该组织包含成员,无法删除。请先移除所有成员。')
setOrgToDelete(org)
setDeleteConfirmOpen(true)
return
}
setOrgToDelete(org)
setDeleteConfirmOpen(true)
}
const handleDeleteOrg = async () => {
if (!orgToDelete || deleteError) return
try {
setIsDeleting(true)
await apiClient.deleteOrganization(orgToDelete.id)
setDeleteConfirmOpen(false)
setOrgToDelete(null)
await fetchOrganizations()
} catch (err) {
console.error('Failed to delete organization:', err)
alert('删除组织失败')
} finally {
setIsDeleting(false)
}
}
// ---------------------------------------------------------------------------
// Member management handlers
// ---------------------------------------------------------------------------
const openMemberModal = async (org: FlatOrg) => {
setMemberOrg(org)
setMemberModalOpen(true)
setNewMemberUserId('')
setNewMemberRoleId(0)
setEditingMemberUserId(null)
await Promise.all([fetchMembers(org.id), fetchRoles()])
}
const handleAddMember = async () => {
if (!memberOrg || !newMemberUserId || !newMemberRoleId) return
try {
await apiClient.addOrgMember(memberOrg.id, Number(newMemberUserId), newMemberRoleId)
setNewMemberUserId('')
setNewMemberRoleId(0)
await fetchMembers(memberOrg.id)
await fetchOrganizations()
} catch (err) {
console.error('Failed to add member:', err)
alert('添加成员失败')
}
}
const handleUpdateMemberRole = async (userId: number) => {
if (!memberOrg || !editingMemberRoleId) return
try {
await apiClient.updateOrgMember(memberOrg.id, userId, editingMemberRoleId)
setEditingMemberUserId(null)
await fetchMembers(memberOrg.id)
} catch (err) {
console.error('Failed to update member role:', err)
alert('更新成员角色失败')
}
}
const handleRemoveMember = async (userId: number) => {
if (!memberOrg) return
try {
await apiClient.removeOrgMember(memberOrg.id, userId)
await fetchMembers(memberOrg.id)
await fetchOrganizations()
} catch (err) {
console.error('Failed to remove member:', err)
alert('移除成员失败')
}
}
// ---------------------------------------------------------------------------
// Tree expand / collapse
// ---------------------------------------------------------------------------
const toggleExpand = (id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
// ---------------------------------------------------------------------------
// Derived data
// ---------------------------------------------------------------------------
const flatOrgs = flattenOrgTree(orgTree)
const orgOptions = collectOrgOptions(orgTree)
// Filter: show rows whose name or code matches, plus their ancestors
const filteredOrgs = searchQuery
? flatOrgs.filter(
(o) =>
o.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
o.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
o.leader.toLowerCase().includes(searchQuery.toLowerCase())
)
: flatOrgs.filter((_) => {
return true
})
// For collapse filtering: determine visible rows considering expanded state
const visibleOrgs = (() => {
if (searchQuery) return filteredOrgs
const visible: FlatOrg[] = []
const depthStack: number[] = [] // tracks the depth of hidden subtrees
for (const org of flatOrgs) {
// If this org is at a depth deeper than a collapsed parent, skip it
if (depthStack.length > 0 && org.depth > depthStack[depthStack.length - 1]) {
continue
}
// Clean up the depth stack
while (depthStack.length > 0 && org.depth <= depthStack[depthStack.length - 1]) {
depthStack.pop()
}
visible.push(org)
// If this org has children and is not expanded, push its depth to mark subtree as hidden
if (org.children && org.children.length > 0 && !expandedIds.has(org.id)) {
depthStack.push(org.depth)
}
}
return visible
})()
// ---------------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------------
const renderStatusBadge = (status: number) => {
const config = STATUS_LABELS[status] || { text: '未知', className: 'bg-gray-500/20 text-gray-400' }
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
{config.text}
</span>
)
}
// ---------------------------------------------------------------------------
// JSX
// ---------------------------------------------------------------------------
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex-1 w-full sm:max-w-md">
<Input
placeholder="搜索组织名称、编码、负责人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
leftIcon={<Search className="h-4 w-4" />}
/>
</div>
<Button onClick={() => openModal()} className="whitespace-nowrap">
<Plus className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Error */}
{error && (
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center">
<span>{error}</span>
<button onClick={fetchOrganizations} className="underline hover:text-red-300">
</button>
</div>
)}
{/* Organizations Table */}
<Card>
<CardHeader>
<CardTitle> ({flatOrgs.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell>...</TableCell>
</TableRow>
) : visibleOrgs.length === 0 ? (
<TableRow>
<TableCell></TableCell>
</TableRow>
) : (
visibleOrgs.map((org) => {
const hasChildren = org.children && org.children.length > 0
const isExpanded = expandedIds.has(org.id)
return (
<TableRow key={org.id}>
<TableCell>
<div className="flex items-center" style={{ paddingLeft: `${org.depth * 24}px` }}>
{hasChildren ? (
<button
onClick={() => toggleExpand(org.id)}
className="mr-2 p-0.5 rounded hover:bg-gray-700/50 text-gray-400 hover:text-gray-200 transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="mr-2 w-5 inline-block" />
)}
<span className="font-medium text-white">{org.name}</span>
</div>
</TableCell>
<TableCell className="text-gray-400">{org.code}</TableCell>
<TableCell className="text-gray-300">{org.leader || '-'}</TableCell>
<TableCell className="text-gray-400">{org.phone || '-'}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-500/20 text-sky-400 border border-sky-500/30">
{org.memberCount}
</span>
</TableCell>
<TableCell>{renderStatusBadge(org.status)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openMemberModal(org)}
title="成员管理"
>
<Users className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openModal(org)}
title="编辑"
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteConfirm(org)}
className="text-red-400 hover:text-red-300"
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Create / Edit Modal */}
<Modal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
resetForm()
}}
title={editingOrg ? '编辑组织' : '新增组织'}
size="md"
footer={
<>
<Button
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
>
</Button>
<Button onClick={editingOrg ? handleUpdateOrg : handleCreateOrg}>
{editingOrg ? '保存' : '创建'}
</Button>
</>
}
>
<div className="space-y-4">
{/* Parent Org */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5"></label>
<select
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-500/50"
value={formData.parentId || 0}
onChange={(e) => setFormData({ ...formData, parentId: Number(e.target.value) })}
>
<option value={0} className="bg-gray-800"></option>
{orgOptions
.filter((o) => !editingOrg || o.id !== editingOrg.id)
.map((o) => (
<option key={o.id} value={o.id} className="bg-gray-800">
{' '.repeat(o.depth)}{o.name}
</option>
))}
</select>
</div>
<Input
label="组织名称"
placeholder="请输入组织名称"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<Input
label="组织编码"
placeholder="请输入组织编码"
value={formData.code || ''}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
/>
<Input
label="负责人"
placeholder="请输入负责人"
value={formData.leader || ''}
onChange={(e) => setFormData({ ...formData, leader: e.target.value })}
/>
<Input
label="联系电话"
placeholder="请输入联系电话"
value={formData.phone || ''}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<Input
label="邮箱"
type="email"
placeholder="请输入邮箱"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<Input
label="排序"
type="number"
placeholder="排序号(数字越小越靠前)"
value={formData.sortOrder?.toString() || '0'}
onChange={(e) => setFormData({ ...formData, sortOrder: Number(e.target.value) })}
/>
</div>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false)
setOrgToDelete(null)
setDeleteError(null)
}}
title="确认删除"
size="sm"
footer={
<>
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false)
setOrgToDelete(null)
setDeleteError(null)
}}
disabled={isDeleting}
>
</Button>
{!deleteError && (
<Button
variant="destructive"
onClick={handleDeleteOrg}
disabled={isDeleting}
>
{isDeleting ? '删除中...' : '确认删除'}
</Button>
)}
</>
}
>
<div className="py-4">
{deleteError ? (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-red-400 text-sm">{deleteError}</p>
</div>
) : (
<>
<p className="text-gray-300">
<span className="font-medium text-white">{orgToDelete?.name}</span>
</p>
<p className="text-sm text-gray-500 mt-2"></p>
</>
)}
</div>
</Modal>
{/* Member Management Modal */}
<Modal
isOpen={memberModalOpen}
onClose={() => {
setMemberModalOpen(false)
setMemberOrg(null)
setMembers([])
setEditingMemberUserId(null)
}}
title={`成员管理 — ${memberOrg?.name || ''}`}
size="lg"
>
<div className="space-y-6">
{/* Add Member Section */}
<div className="p-4 rounded-lg border border-gray-800 bg-gray-800/30">
<h4 className="text-sm font-medium text-gray-300 mb-3"></h4>
<div className="flex items-end gap-3">
<div className="flex-1">
<Input
label="用户 ID"
placeholder="请输入用户 ID"
value={newMemberUserId}
onChange={(e) => setNewMemberUserId(e.target.value)}
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-300 mb-1.5"></label>
<select
className="w-full h-11 rounded-lg border border-white/10 bg-gray-900/80 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-500/50"
value={newMemberRoleId}
onChange={(e) => setNewMemberRoleId(Number(e.target.value))}
>
<option value={0} className="bg-gray-800"></option>
{roles.map((role) => (
<option key={role.id} value={role.id} className="bg-gray-800">
{role.name}
</option>
))}
</select>
</div>
<Button
onClick={handleAddMember}
disabled={!newMemberUserId || !newMemberRoleId}
className="shrink-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Members List */}
<div>
<h4 className="text-sm font-medium text-gray-300 mb-3">
({members.length})
</h4>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isMembersLoading ? (
<TableRow>
<TableCell>...</TableCell>
</TableRow>
) : members.length === 0 ? (
<TableRow>
<TableCell></TableCell>
</TableRow>
) : (
members.map((member) => (
<TableRow key={member.userId}>
<TableCell className="font-medium text-white">{member.username}</TableCell>
<TableCell>
{editingMemberUserId === member.userId ? (
<select
className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-500/50"
value={editingMemberRoleId}
onChange={(e) => setEditingMemberRoleId(Number(e.target.value))}
>
{roles.map((role) => (
<option key={role.id} value={role.id} className="bg-gray-800">
{role.name}
</option>
))}
</select>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/20 text-blue-400 border border-blue-500/30">
{member.roleName}
</span>
)}
</TableCell>
<TableCell className="text-gray-400">
{new Date(member.createdAt).toLocaleDateString('zh-CN')}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{editingMemberUserId === member.userId ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleUpdateMemberRole(member.userId)}
className="text-green-400 hover:text-green-300"
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEditingMemberUserId(null)}
>
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingMemberUserId(member.userId)
setEditingMemberRoleId(member.roleId)
}}
title="修改角色"
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveMember(member.userId)}
className="text-red-400 hover:text-red-300"
title="移除成员"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</Modal>
</div>
)
}

222
frontend/react-shadcn/pc/src/pages/SettingsPage.tsx

@ -1,9 +1,124 @@
import { useState, useEffect } from 'react'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Settings, Save, Bell, Lock, Palette } from 'lucide-react' import { Settings, Save, Bell, Lock, Palette, Loader2 } from 'lucide-react'
import { apiClient } from '@/services/api'
import type { Profile, UpdateProfileRequest, ChangePasswordRequest } from '@/types'
export function SettingsPage() { export function SettingsPage() {
// Profile form state
const [profile, setProfile] = useState<Profile | null>(null)
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
// Password form state
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
// Loading states
const [isLoadingProfile, setIsLoadingProfile] = useState(false)
const [isSavingProfile, setIsSavingProfile] = useState(false)
const [isChangingPassword, setIsChangingPassword] = useState(false)
// Message states
const [profileMessage, setProfileMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [passwordMessage, setPasswordMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Fetch profile on component mount
useEffect(() => {
fetchProfile()
}, [])
const fetchProfile = async () => {
setIsLoadingProfile(true)
try {
const response = await apiClient.getProfile()
if (response.success && response.data) {
setProfile(response.data)
setUsername(response.data.username)
setEmail(response.data.email)
setPhone(response.data.phone || '')
} else {
setProfileMessage({ type: 'error', text: response.message || '获取个人资料失败' })
}
} catch (error) {
setProfileMessage({ type: 'error', text: error instanceof Error ? error.message : '获取个人资料失败' })
} finally {
setIsLoadingProfile(false)
}
}
const handleSaveProfile = async () => {
setIsSavingProfile(true)
setProfileMessage(null)
const data: UpdateProfileRequest = {
username: username || undefined,
phone: phone || undefined,
}
try {
const response = await apiClient.updateProfile(data)
if (response.success) {
setProfileMessage({ type: 'success', text: '个人资料保存成功' })
if (response.data) {
setProfile(response.data)
}
} else {
setProfileMessage({ type: 'error', text: response.message || '保存失败' })
}
} catch (error) {
setProfileMessage({ type: 'error', text: error instanceof Error ? error.message : '保存失败' })
} finally {
setIsSavingProfile(false)
}
}
const handleChangePassword = async () => {
setPasswordMessage(null)
// Validate passwords
if (!oldPassword) {
setPasswordMessage({ type: 'error', text: '请输入当前密码' })
return
}
if (!newPassword) {
setPasswordMessage({ type: 'error', text: '请输入新密码' })
return
}
if (newPassword !== confirmPassword) {
setPasswordMessage({ type: 'error', text: '新密码和确认密码不一致' })
return
}
setIsChangingPassword(true)
const data: ChangePasswordRequest = {
oldPassword,
newPassword,
}
try {
const response = await apiClient.changePassword(data)
if (response.success) {
setPasswordMessage({ type: 'success', text: '密码修改成功' })
// Clear password fields
setOldPassword('')
setNewPassword('')
setConfirmPassword('')
} else {
setPasswordMessage({ type: 'error', text: response.message || '修改密码失败' })
}
} catch (error) {
setPasswordMessage({ type: 'error', text: error instanceof Error ? error.message : '修改密码失败' })
} finally {
setIsChangingPassword(false)
}
}
return ( return (
<div className="space-y-6 animate-fade-in max-w-2xl"> <div className="space-y-6 animate-fade-in max-w-2xl">
{/* Profile Settings */} {/* Profile Settings */}
@ -15,15 +130,58 @@ export function SettingsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Input label="用户名" placeholder="请输入用户名" /> {isLoadingProfile ? (
<Input label="邮箱" type="email" placeholder="请输入邮箱" /> <div className="flex items-center justify-center py-8">
<Input label="手机号" placeholder="请输入手机号" /> <Loader2 className="h-8 w-8 animate-spin text-sky-400" />
<div className="pt-4"> </div>
<Button variant="primary"> ) : (
<Save className="h-4 w-4" /> <>
<Input
</Button> label="用户名"
</div> placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
label="邮箱"
type="email"
placeholder="请输入邮箱"
value={email}
disabled
/>
<Input
label="手机号"
placeholder="请输入手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
{profileMessage && (
<div
className={`p-3 rounded-lg text-sm ${
profileMessage.type === 'success'
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'
}`}
>
{profileMessage.text}
</div>
)}
<div className="pt-4">
<Button
variant="primary"
onClick={handleSaveProfile}
disabled={isSavingProfile}
>
{isSavingProfile ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button>
</div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -68,11 +226,47 @@ export function SettingsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Input label="当前密码" type="password" placeholder="请输入当前密码" /> <Input
<Input label="新密码" type="password" placeholder="请输入新密码" /> label="当前密码"
<Input label="确认密码" type="password" placeholder="请确认新密码" /> type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
/>
<Input
label="新密码"
type="password"
placeholder="请输入新密码"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
label="确认密码"
type="password"
placeholder="请确认新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{passwordMessage && (
<div
className={`p-3 rounded-lg text-sm ${
passwordMessage.type === 'success'
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'
}`}
>
{passwordMessage.text}
</div>
)}
<div className="pt-4"> <div className="pt-4">
<Button variant="primary"> <Button
variant="primary"
onClick={handleChangePassword}
disabled={isChangingPassword}
>
{isChangingPassword ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
</Button> </Button>
</div> </div>

86
frontend/react-shadcn/pc/src/pages/UserManagementPage.tsx

@ -32,21 +32,26 @@ export function UserManagementPage() {
fetchUsers() fetchUsers()
}, []) }, [])
const [error, setError] = useState<string | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [userToDelete, setUserToDelete] = useState<User | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
setIsLoading(true) setIsLoading(true)
setError(null)
const response = await apiClient.getUsers({ page: 1, pageSize: 100 }) const response = await apiClient.getUsers({ page: 1, pageSize: 100 })
if (response.success && response.data) { if (response.success && response.data) {
setUsers(response.data.users) setUsers(response.data.list)
} else {
setError(response.message || '获取用户列表失败')
setUsers([])
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch users:', error) console.error('Failed to fetch users:', error)
// Use mock data if API fails setError('获取用户列表失败,请稍后重试')
setUsers([ setUsers([])
{ id: 1, username: 'admin', email: 'admin@example.com', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 2, username: 'user1', email: 'user1@example.com', phone: '13800138000', createdAt: '2024-01-02', updatedAt: '2024-01-02' },
{ id: 3, username: 'user2', email: 'user2@example.com', phone: '13900139000', createdAt: '2024-01-03', updatedAt: '2024-01-03' },
])
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -78,14 +83,24 @@ export function UserManagementPage() {
} }
} }
const handleDeleteUser = async (id: number) => { const openDeleteConfirm = (user: User) => {
if (!confirm('确定要删除该用户吗?')) return setUserToDelete(user)
setDeleteConfirmOpen(true)
}
const handleDeleteUser = async () => {
if (!userToDelete) return
try { try {
await apiClient.deleteUser(id) setIsDeleting(true)
await apiClient.deleteUser(userToDelete.id)
setDeleteConfirmOpen(false)
setUserToDelete(null)
await fetchUsers() await fetchUsers()
} catch (error) { } catch (error) {
console.error('Failed to delete user:', error) console.error('Failed to delete user:', error)
alert('删除用户失败') alert('删除用户失败')
} finally {
setIsDeleting(false)
} }
} }
@ -150,6 +165,16 @@ export function UserManagementPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center">
<span>{error}</span>
<button onClick={fetchUsers} className="underline hover:text-red-300">
</button>
</div>
)}
{/* Users Table */} {/* Users Table */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -199,7 +224,7 @@ export function UserManagementPage() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleDeleteUser(user.id)} onClick={() => openDeleteConfirm(user)}
className="text-red-400 hover:text-red-300" className="text-red-400 hover:text-red-300"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@ -274,6 +299,45 @@ export function UserManagementPage() {
/> />
</div> </div>
</Modal> </Modal>
{/* Delete Confirmation Modal */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false)
setUserToDelete(null)
}}
title="确认删除"
size="sm"
footer={
<>
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false)
setUserToDelete(null)
}}
disabled={isDeleting}
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteUser}
disabled={isDeleting}
>
{isDeleting ? '删除中...' : '确认删除'}
</Button>
</>
}
>
<div className="py-4">
<p className="text-gray-300">
<span className="font-medium text-white">{userToDelete?.username}</span>
</p>
<p className="text-sm text-gray-500 mt-2"></p>
</div>
</Modal>
</div> </div>
) )
} }

289
frontend/react-shadcn/pc/src/services/api.ts

@ -7,6 +7,26 @@ import type {
UserListResponse, UserListResponse,
CreateUserRequest, CreateUserRequest,
UpdateUserRequest, UpdateUserRequest,
Profile,
DashboardStats,
Activity,
UpdateProfileRequest,
ChangePasswordRequest,
FileInfo,
FileListRequest,
FileListResponse,
UpdateFileRequest,
MenuItem,
CreateMenuRequest,
UpdateMenuRequest,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
OrgInfo,
CreateOrgRequest,
UpdateOrgRequest,
OrgMember,
UserOrgInfo,
} from '@/types' } from '@/types'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1'
@ -63,7 +83,7 @@ class ApiClient {
// Auth // Auth
async login(credentials: LoginRequest): Promise<LoginResponse> { async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await this.request<LoginResponse>('/auth/login', { const response = await this.request<LoginResponse>('/login', {
method: 'POST', method: 'POST',
body: JSON.stringify(credentials), body: JSON.stringify(credentials),
}) })
@ -79,8 +99,13 @@ class ApiClient {
this.token = null this.token = null
} }
// SSO
async getSSOLoginUrl(): Promise<{ login_url: string }> {
return this.request<{ login_url: string }>('/auth/sso/login-url')
}
async refreshToken(refreshToken: string): Promise<LoginResponse> { async refreshToken(refreshToken: string): Promise<LoginResponse> {
return this.request<LoginResponse>('/auth/refresh', { return this.request<LoginResponse>('/refresh', {
method: 'POST', method: 'POST',
body: JSON.stringify({ token: refreshToken }), body: JSON.stringify({ token: refreshToken }),
}) })
@ -93,36 +118,282 @@ class ApiClient {
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString()) if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
if (params.keyword) queryParams.append('keyword', params.keyword) if (params.keyword) queryParams.append('keyword', params.keyword)
return this.request<UserListResponse>(`/user/users?${queryParams}`) // 后端返回裸数据格式,需要包装成标准格式
const rawData = await this.request<{ total: number; list: any[] }>(`/users?${queryParams}`)
// 如果后端已经返回标准格式,直接返回
if ('success' in rawData) {
return rawData as unknown as UserListResponse
}
// 包装成标准格式
return {
code: 200,
message: 'success',
success: true,
data: {
list: rawData.list || [],
total: rawData.total || 0,
page: params.page || 1,
pageSize: params.pageSize || 10,
},
}
} }
async getUser(id: number): Promise<ApiResponse<User>> { async getUser(id: number): Promise<ApiResponse<User>> {
return this.request<ApiResponse<User>>(`/user/user/${id}`) return this.request<ApiResponse<User>>(`/user/${id}`)
} }
async createUser(data: CreateUserRequest): Promise<ApiResponse<User>> { async createUser(data: CreateUserRequest): Promise<ApiResponse<User>> {
return this.request<ApiResponse<User>>('/user/user', { return this.request<ApiResponse<User>>('/user', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
async updateUser(id: number, data: UpdateUserRequest): Promise<ApiResponse<User>> { async updateUser(id: number, data: UpdateUserRequest): Promise<ApiResponse<User>> {
return this.request<ApiResponse<User>>(`/user/user/${id}`, { return this.request<ApiResponse<User>>(`/user/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
async deleteUser(id: number): Promise<ApiResponse<void>> { async deleteUser(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/user/user/${id}`, { return this.request<ApiResponse<void>>(`/user/${id}`, {
method: 'DELETE', method: 'DELETE',
}) })
} }
// Profile // Profile
async getProfile(): Promise<ApiResponse> { async getProfile(): Promise<ApiResponse<Profile>> {
return this.request<ApiResponse>('/profile/me') return this.request<ApiResponse<Profile>>('/profile/me')
}
async updateProfile(data: UpdateProfileRequest): Promise<ApiResponse<Profile>> {
return this.request<ApiResponse<Profile>>('/profile/me', {
method: 'PUT',
body: JSON.stringify(data),
})
}
async changePassword(data: ChangePasswordRequest): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>('/profile/password', {
method: 'POST',
body: JSON.stringify(data),
})
}
// Dashboard
async getDashboardStats(): Promise<ApiResponse<DashboardStats>> {
const rawData = await this.request<DashboardStats>('/dashboard/stats')
// 如果后端已经返回标准格式,直接返回
if ('success' in rawData) {
return rawData as unknown as ApiResponse<DashboardStats>
}
// 包装成标准格式
return {
code: 200,
message: 'success',
success: true,
data: rawData,
}
}
async getRecentActivities(limit: number = 10): Promise<ApiResponse<Activity[]>> {
const rawData = await this.request<{ activities: Activity[] }>(`/dashboard/activities?limit=${limit}`)
// 如果后端已经返回标准格式,直接返回
if ('success' in rawData) {
return rawData as unknown as ApiResponse<Activity[]>
}
// 包装成标准格式
return {
code: 200,
message: 'success',
success: true,
data: rawData.activities || [],
}
}
// File Management
async uploadFile(file: File, category?: string, isPublic?: boolean): Promise<ApiResponse<FileInfo>> {
const url = `${API_BASE_URL}/file/upload`
const formData = new FormData()
formData.append('file', file)
if (category) formData.append('category', category)
if (isPublic !== undefined) formData.append('isPublic', isPublic ? 'true' : 'false')
const headers: Record<string, string> = {}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
const response = await fetch(url, { method: 'POST', headers, body: formData })
const data = await response.json()
if (!response.ok) throw new Error(data.message || 'Upload failed')
if ('id' in data) return { code: 200, message: 'success', success: true, data }
return data
}
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
const queryParams = new URLSearchParams()
if (params.page) queryParams.append('page', params.page.toString())
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
if (params.keyword) queryParams.append('keyword', params.keyword)
if (params.category) queryParams.append('category', params.category)
if (params.mimeType) queryParams.append('mimeType', params.mimeType)
const rawData = await this.request<{ total: number; list: FileInfo[] }>(`/files?${queryParams}`)
if ('success' in rawData) return rawData as unknown as FileListResponse
return {
code: 200, message: 'success', success: true,
data: { list: rawData.list || [], total: rawData.total || 0 },
}
}
async getFile(id: number): Promise<ApiResponse<FileInfo>> {
const rawData = await this.request<FileInfo>(`/file/${id}`)
if ('success' in rawData) return rawData as unknown as ApiResponse<FileInfo>
return { code: 200, message: 'success', success: true, data: rawData }
}
async getFileUrl(id: number): Promise<string> {
const url = `${API_BASE_URL}/file/${id}/url`
const headers: Record<string, string> = {}
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
// This endpoint either serves the file directly (local) or returns JSON with URL
// For simplicity, return the endpoint URL — browser can use it directly
return url
}
async updateFile(id: number, data: UpdateFileRequest): Promise<ApiResponse<FileInfo>> {
const rawData = await this.request<FileInfo>(`/file/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
if ('success' in rawData) return rawData as unknown as ApiResponse<FileInfo>
return { code: 200, message: 'success', success: true, data: rawData }
}
async deleteFile(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/file/${id}`, { method: 'DELETE' })
}
// Menu Management
async getCurrentMenus(): Promise<{ list: MenuItem[] }> {
const rawData = await this.request<{ list: MenuItem[] }>('/menus/current')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async getMenuList(): Promise<{ list: MenuItem[] }> {
const rawData = await this.request<{ list: MenuItem[] }>('/menus')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async createMenu(data: CreateMenuRequest): Promise<MenuItem> {
return this.request<MenuItem>('/menu', { method: 'POST', body: JSON.stringify(data) })
}
async updateMenu(id: number, data: UpdateMenuRequest): Promise<MenuItem> {
return this.request<MenuItem>(`/menu/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
async deleteMenu(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/menu/${id}`, { method: 'DELETE' })
}
// Role Management
async getRoles(): Promise<{ list: RoleInfo[] }> {
const rawData = await this.request<{ list: RoleInfo[] }>('/roles')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async createRole(data: CreateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('/role', { method: 'POST', body: JSON.stringify(data) })
}
async updateRole(id: number, data: UpdateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>(`/role/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
async deleteRole(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/role/${id}`, { method: 'DELETE' })
}
async getRoleMenus(roleId: number): Promise<{ menuIds: number[] }> {
const rawData = await this.request<{ menuIds: number[] }>(`/role/${roleId}/menus`)
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async setRoleMenus(roleId: number, menuIds: number[]): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/role/${roleId}/menus`, {
method: 'PUT', body: JSON.stringify({ menuIds }),
})
}
// Organization Management
async getOrganizations(): Promise<{ list: OrgInfo[] }> {
const rawData = await this.request<{ list: OrgInfo[] }>('/organizations')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async createOrganization(data: CreateOrgRequest): Promise<OrgInfo> {
return this.request<OrgInfo>('/organization', { method: 'POST', body: JSON.stringify(data) })
}
async updateOrganization(id: number, data: UpdateOrgRequest): Promise<OrgInfo> {
return this.request<OrgInfo>(`/organization/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
async deleteOrganization(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${id}`, { method: 'DELETE' })
}
async getOrgMembers(orgId: number): Promise<{ list: OrgMember[] }> {
const rawData = await this.request<{ list: OrgMember[] }>(`/organization/${orgId}/members`)
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async addOrgMember(orgId: number, userId: number, roleId: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${orgId}/member`, {
method: 'POST', body: JSON.stringify({ userId, roleId }),
})
}
async updateOrgMember(orgId: number, userId: number, roleId: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${orgId}/member/${userId}`, {
method: 'PUT', body: JSON.stringify({ roleId }),
})
}
async removeOrgMember(orgId: number, userId: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${orgId}/member/${userId}`, {
method: 'DELETE',
})
}
// User Org Context
async getUserOrgs(): Promise<{ list: UserOrgInfo[] }> {
const rawData = await this.request<{ list: UserOrgInfo[] }>('/profile/orgs')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async switchOrg(orgId: number): Promise<{ token: string }> {
const rawData = await this.request<{ token: string }>('/profile/current-org', {
method: 'PUT', body: JSON.stringify({ orgId }),
})
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
} }
// Health check // Health check

201
frontend/react-shadcn/pc/src/types/index.ts

@ -20,6 +20,9 @@ export interface User {
email: string email: string
phone?: string phone?: string
avatar?: string avatar?: string
role?: string
source?: string
remark?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@ -28,15 +31,15 @@ export interface UserInfo extends User {}
// Request Types // Request Types
export interface LoginRequest { export interface LoginRequest {
email: string account: string
password: string password: string
} }
export interface RegisterRequest { export interface RegisterRequest {
username: string username: string
email: string
password: string password: string
phone?: string phone: string
email?: string
} }
export interface CreateUserRequest { export interface CreateUserRequest {
@ -44,6 +47,8 @@ export interface CreateUserRequest {
email: string email: string
password: string password: string
phone?: string phone?: string
role?: string
remark?: string
} }
export interface UpdateUserRequest { export interface UpdateUserRequest {
@ -51,6 +56,8 @@ export interface UpdateUserRequest {
email?: string email?: string
phone?: string phone?: string
avatar?: string avatar?: string
role?: string
remark?: string
} }
export interface UserListRequest { export interface UserListRequest {
@ -64,7 +71,7 @@ export interface UserListResponse {
message: string message: string
success: boolean success: boolean
data: { data: {
users: User[] list: User[]
total: number total: number
page: number page: number
pageSize: number pageSize: number
@ -85,12 +92,194 @@ export interface Profile {
} }
export interface UpdateProfileRequest { export interface UpdateProfileRequest {
bio?: string username?: string
avatar?: string
phone?: string phone?: string
avatar?: string
bio?: string
} }
export interface ChangePasswordRequest { export interface ChangePasswordRequest {
oldPassword: string oldPassword: string
newPassword: string newPassword: string
} }
// Dashboard Types
export interface DashboardStats {
totalUsers: number
activeUsers: number
systemLoad: number
dbStatus: string
userGrowth: number
}
export interface Activity {
id: number
user: string
action: string
time: string
status: string
}
// File Types
export interface FileInfo {
id: number
name: string
key: string
size: number
mimeType: string
category: string
isPublic: boolean
userId: number
storageType: string
url: string
createdAt: string
updatedAt: string
}
export interface FileListRequest {
page?: number
pageSize?: number
keyword?: string
category?: string
mimeType?: string
}
export interface FileListResponse {
code: number
message: string
success: boolean
data: {
list: FileInfo[]
total: number
}
}
export interface UpdateFileRequest {
name?: string
category?: string
isPublic?: boolean
}
// Menu Types
export interface MenuItem {
id: number
parentId: number
name: string
path: string
icon: string
component: string
type: 'default' | 'config'
sortOrder: number
visible: boolean
status: number
children: MenuItem[]
createdAt: string
updatedAt: string
}
export interface CreateMenuRequest {
parentId?: number
name: string
path?: string
icon?: string
component?: string
type?: string
sortOrder?: number
visible?: boolean
}
export interface UpdateMenuRequest {
parentId?: number
name?: string
path?: string
icon?: string
component?: string
type?: string
sortOrder?: number
visible?: boolean
status?: number
}
// Role Types
export interface RoleInfo {
id: number
name: string
code: string
description: string
isSystem: boolean
sortOrder: number
status: number
createdAt: string
updatedAt: string
}
export interface CreateRoleRequest {
name: string
code: string
description?: string
sortOrder?: number
}
export interface UpdateRoleRequest {
name?: string
description?: string
sortOrder?: number
status?: number
}
// Organization Types
export interface OrgInfo {
id: number
parentId: number
name: string
code: string
leader: string
phone: string
email: string
sortOrder: number
status: number
memberCount: number
children: OrgInfo[]
createdAt: string
updatedAt: string
}
export interface CreateOrgRequest {
parentId?: number
name: string
code: string
leader?: string
phone?: string
email?: string
sortOrder?: number
}
export interface UpdateOrgRequest {
parentId?: number
name?: string
code?: string
leader?: string
phone?: string
email?: string
sortOrder?: number
status?: number
}
export interface OrgMember {
userId: number
username: string
email: string
phone: string
roleId: number
roleName: string
roleCode: string
createdAt: string
}
export interface UserOrgInfo {
orgId: number
orgName: string
roleId: number
roleName: string
roleCode: string
}

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

Loading…
Cancel
Save