Compare commits
35 Commits
3bcdb439c8
...
c35a337695
| Author | SHA1 | Date |
|---|---|---|
|
|
c35a337695 | 1 month ago |
|
|
dea4ae80b6 | 1 month ago |
|
|
b411ac169a | 1 month ago |
|
|
38f4e740fa | 1 month ago |
|
|
6df3f8795f | 1 month ago |
|
|
41b1e091ce | 1 month ago |
|
|
a29e593b06 | 1 month ago |
|
|
bbb7b09a3a | 1 month ago |
|
|
fd13bf9470 | 1 month ago |
|
|
68aa11fa64 | 1 month ago |
|
|
6326bd5970 | 1 month ago |
|
|
e31c274a64 | 1 month ago |
|
|
d2cb7fa8c8 | 1 month ago |
|
|
f7ab873ca7 | 1 month ago |
|
|
194b16c6ec | 1 month ago |
|
|
91d83e7f4a | 1 month ago |
|
|
fb56475faf | 1 month ago |
|
|
679a174d0e | 1 month ago |
|
|
d92aba8294 | 1 month ago |
|
|
e7df5f0d6f | 1 month ago |
|
|
ecc519e322 | 1 month ago |
|
|
ea819fa7e4 | 1 month ago |
|
|
c44c0a86c5 | 1 month ago |
|
|
d4ac14fba9 | 1 month ago |
|
|
89bc5f0a20 | 1 month ago |
|
|
8883b23e49 | 1 month ago |
|
|
02299dfaa1 | 1 month ago |
|
|
08730a8bfa | 1 month ago |
|
|
cf28600cfc | 1 month ago |
|
|
e57fb7088a | 1 month ago |
|
|
4ae8861481 | 1 month ago |
|
|
f80b2903fb | 1 month ago |
|
|
099ac92b88 | 1 month ago |
|
|
8e35094243 | 1 month ago |
|
|
9ae5c5b8ad | 1 month ago |
119 changed files with 18867 additions and 256 deletions
@ -0,0 +1,3 @@ |
|||
[submodule ".claude/skills/zero-skills"] |
|||
path = .claude/skills/zero-skills |
|||
url = https://github.com/zeromicro/zero-skills.git |
|||
@ -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 |
|||
@ -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 |
|||
@ -0,0 +1 @@ |
|||
tmp/ |
|||
@ -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"` // 活动列表 |
|||
} |
|||
) |
|||
@ -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"` |
|||
} |
|||
) |
|||
@ -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"` |
|||
} |
|||
) |
|||
@ -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"` |
|||
} |
|||
) |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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 |
|||
} |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -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" |
|||
``` |
|||
@ -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 字段保留,仅改为非必填 |
|||
@ -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` — 新类型定义 |
|||
File diff suppressed because it is too large
@ -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(); |
|||
@ -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> |
|||
) |
|||
} |
|||
@ -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> |
|||
) |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue