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