增加权限系统

This commit is contained in:
yml2213
2026-05-27 05:41:59 +08:00
parent a04327fd05
commit 1458fc279c
29 changed files with 2276 additions and 46 deletions
+70
View File
@@ -0,0 +1,70 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RequirePermission 检查当前管理员是否拥有指定权限。
// 超级管理员(拥有 super_admin 角色的管理员)自动放行。
func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
value, ok := c.Get(ContextAdminID)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
c.Abort()
return
}
adminID, ok := value.(uint64)
if !ok {
response.Unauthorized(c, "管理员上下文无效")
c.Abort()
return
}
codes, err := getPermCodes(c, rdb, adminID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "perm_check_failed", "权限校验服务暂时不可用")
c.Abort()
return
}
for _, code := range codes {
if code == permCode || code == "*" {
c.Next()
return
}
}
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
c.Abort()
}
}
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
if rdb == nil {
return nil, errors.New("redis unavailable")
}
ctx := context.Background()
key := fmt.Sprintf("admin:perms:%d", adminID)
raw, err := rdb.Get(ctx, key).Result()
if errors.Is(err, redis.Nil) {
return nil, nil // 缓存未命中,视为无权限
}
if err != nil {
return nil, err
}
var codes []string
if err := json.Unmarshal([]byte(raw), &codes); err != nil {
return nil, err
}
return codes, nil
}
+44
View File
@@ -0,0 +1,44 @@
package model
import "time"
type Role struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:64;not null;uniqueIndex" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:255;not null;default:''" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Role) TableName() string { return "roles" }
type Permission struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:128;not null;uniqueIndex" json:"code"`
Name string `gorm:"size:64;not null" json:"name"`
Resource string `gorm:"size:128;not null" json:"resource"`
Action string `gorm:"size:64;not null" json:"action"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Permission) TableName() string { return "permissions" }
type AdminUserRole struct {
ID uint64 `gorm:"primaryKey" json:"id"`
AdminID uint64 `gorm:"column:admin_user_id;not null;uniqueIndex:uk_admin_user_roles_user_role" json:"admin_id"`
RoleID uint64 `gorm:"column:role_id;not null;uniqueIndex:uk_admin_user_roles_user_role" json:"role_id"`
CreatedAt time.Time `json:"created_at"`
}
func (AdminUserRole) TableName() string { return "admin_user_roles" }
type RolePermission struct {
ID uint64 `gorm:"primaryKey" json:"id"`
RoleID uint64 `gorm:"column:role_id;not null;uniqueIndex:uk_role_permissions_role_permission" json:"role_id"`
PermissionID uint64 `gorm:"column:permission_id;not null;uniqueIndex:uk_role_permissions_role_permission" json:"permission_id"`
CreatedAt time.Time `json:"created_at"`
}
func (RolePermission) TableName() string { return "role_permissions" }
@@ -11,9 +11,17 @@ type AdminDTO struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
Roles []RoleDTO `json:"roles"`
Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
}
type RoleDTO struct {
ID uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
@@ -87,7 +88,9 @@ func (r *Repository) Login(username string, password string, captchaID string, c
if err != nil {
return LoginResult{}, err
}
return LoginResult{Admin: toDTO(admin), Tokens: tokens}, nil
dto := toDTO(admin)
r.loadRolesAndPerms(&dto)
return LoginResult{Admin: dto, Tokens: tokens}, nil
}
func (r *Repository) verifyCaptcha(captchaID string, captchaCode string) error {
@@ -119,6 +122,7 @@ func (r *Repository) FindByID(id uint64) (*AdminDTO, error) {
return nil, ErrAdminDisabled
}
dto := toDTO(admin)
r.loadRolesAndPerms(&dto)
return &dto, nil
}
@@ -153,6 +157,37 @@ func toDTO(admin model.AdminUser) AdminDTO {
}
}
func (r *Repository) loadRolesAndPerms(dto *AdminDTO) {
if r.db == nil {
return
}
// 加载角色
var roles []RoleDTO
r.db.Table("roles").
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
Where("aur.admin_user_id = ?", dto.ID).
Find(&roles)
dto.Roles = roles
// 加载权限
var permCodes []string
r.db.Table("permissions").
Select("DISTINCT permissions.code").
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
Where("aur.admin_user_id = ?", dto.ID).
Pluck("code", &permCodes)
dto.Permissions = permCodes
// 缓存权限到 Redis
if r.redis != nil && len(permCodes) > 0 {
ctx := context.Background()
key := fmt.Sprintf("admin:perms:%d", dto.ID)
raw, _ := json.Marshal(permCodes)
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
}
}
func captchaKey(id string) string {
return "admin:captcha:" + id
}
+45
View File
@@ -0,0 +1,45 @@
package adminmgr
import (
"time"
"hfb_sys/backend/internal/modules/adminrole"
)
type AdminUserDTO struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
Roles []adminrole.RoleDTO `json:"roles"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PaginatedResult struct {
Items []AdminUserDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type CreateAdminRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Nickname string `json:"nickname"`
}
type UpdateAdminRequest struct {
Nickname string `json:"nickname"`
Status string `json:"status"`
}
type AssignRolesRequest struct {
RoleIDs []uint64 `json:"role_ids"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
@@ -0,0 +1,149 @@
package adminmgr
import (
"errors"
"net/http"
"strconv"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.service.List(page, pageSize)
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) FindByID(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindByID(id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Create(c *gin.Context) {
var req CreateAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "用户名和密码不能为空")
return
}
item, err := h.service.Create(req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) Update(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求格式不正确")
return
}
item, err := h.service.Update(id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
currentAdminID, _ := c.Get(middleware.ContextAdminID)
if err := h.service.Delete(id, currentAdminID.(uint64)); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) AssignRoles(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req AssignRolesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求格式不正确")
return
}
if err := h.service.AssignRoles(id, req); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) ChangePassword(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "密码不能为空")
return
}
if err := h.service.ChangePassword(id, req); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrCannotDeleteSelf):
response.BadRequest(c, "不能删除自己的账号")
case errors.Is(err, ErrLastSuperAdmin):
response.BadRequest(c, "不能删除最后一个超级管理员")
case errors.Is(err, ErrWrongPassword):
response.BadRequest(c, "原密码错误")
case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "管理员不存在")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "管理员管理服务暂时不可用")
}
}
@@ -0,0 +1,234 @@
package adminmgr
import (
"context"
"errors"
"fmt"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminrole"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
redis *redis.Client
}
func NewRepository(db *gorm.DB, redis *redis.Client) *Repository {
return &Repository{db: db, redis: redis}
}
func (r *Repository) List(page, pageSize int) (*PaginatedResult, error) {
var total int64
if err := r.db.Model(&model.AdminUser{}).Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var admins []model.AdminUser
if err := r.db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&admins).Error; err != nil {
return nil, err
}
items := make([]AdminUserDTO, 0, len(admins))
for _, admin := range admins {
roles, _ := r.getAdminRoles(admin.ID)
items = append(items, AdminUserDTO{
ID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Status: admin.Status,
Roles: roles,
LastLoginAt: admin.LastLoginAt,
CreatedAt: admin.CreatedAt,
UpdatedAt: admin.UpdatedAt,
})
}
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) FindByID(id uint64) (*AdminUserDTO, error) {
var admin model.AdminUser
if err := r.db.First(&admin, id).Error; err != nil {
return nil, err
}
roles, _ := r.getAdminRoles(admin.ID)
dto := AdminUserDTO{
ID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Status: admin.Status,
Roles: roles,
LastLoginAt: admin.LastLoginAt,
CreatedAt: admin.CreatedAt,
UpdatedAt: admin.UpdatedAt,
}
return &dto, nil
}
func (r *Repository) Create(req CreateAdminRequest) (*AdminUserDTO, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
admin := model.AdminUser{
Username: req.Username,
PasswordHash: string(hash),
Nickname: req.Nickname,
Status: "active",
}
if err := r.db.Create(&admin).Error; err != nil {
return nil, err
}
return r.FindByID(admin.ID)
}
func (r *Repository) Update(id uint64, req UpdateAdminRequest) (*AdminUserDTO, error) {
var admin model.AdminUser
if err := r.db.First(&admin, id).Error; err != nil {
return nil, err
}
if req.Nickname != "" {
admin.Nickname = req.Nickname
}
if req.Status != "" {
admin.Status = req.Status
}
if err := r.db.Save(&admin).Error; err != nil {
return nil, err
}
return r.FindByID(id)
}
func (r *Repository) Delete(id uint64, currentAdminID uint64) error {
if id == currentAdminID {
return ErrCannotDeleteSelf
}
var admin model.AdminUser
if err := r.db.First(&admin, id).Error; err != nil {
return err
}
// 检查是否是最后一个 super_admin
isLastSuper, err := r.isLastSuperAdmin(id)
if err != nil {
return err
}
if isLastSuper {
return ErrLastSuperAdmin
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("admin_user_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil {
return err
}
if err := tx.Delete(&model.AdminUser{}, id).Error; err != nil {
return err
}
r.invalidatePermCache(id)
return nil
})
}
func (r *Repository) AssignRoles(adminID uint64, roleIDs []uint64) error {
var admin model.AdminUser
if err := r.db.First(&admin, adminID).Error; err != nil {
return err
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("admin_user_id = ?", adminID).Delete(&model.AdminUserRole{}).Error; err != nil {
return err
}
for _, rid := range roleIDs {
aur := model.AdminUserRole{AdminID: adminID, RoleID: rid}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&aur).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
r.invalidatePermCache(adminID)
return nil
}
func (r *Repository) ChangePassword(id uint64, oldPwd, newPwd string) error {
var admin model.AdminUser
if err := r.db.First(&admin, id).Error; err != nil {
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPwd)); err != nil {
return ErrWrongPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
return r.db.Model(&admin).Update("password_hash", string(hash)).Error
}
func (r *Repository) GetPermissionCodes(adminID uint64) ([]string, error) {
var codes []string
err := r.db.Table("admin_user_roles aur").
Select("DISTINCT p.code").
Joins("JOIN role_permissions rp ON rp.role_id = aur.role_id").
Joins("JOIN permissions p ON p.id = rp.permission_id").
Where("aur.admin_user_id = ?", adminID).
Pluck("code", &codes).Error
return codes, err
}
func (r *Repository) getAdminRoles(adminID uint64) ([]adminrole.RoleDTO, error) {
var roles []model.Role
err := r.db.Table("roles").
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
Where("aur.admin_user_id = ?", adminID).
Find(&roles).Error
if err != nil {
return nil, err
}
result := make([]adminrole.RoleDTO, 0, len(roles))
for _, role := range roles {
result = append(result, adminrole.RoleDTO{
ID: role.ID, Code: role.Code, Name: role.Name, Description: role.Description,
})
}
return result, nil
}
func (r *Repository) isLastSuperAdmin(adminID uint64) (bool, error) {
var superRole model.Role
if err := r.db.Where("code = ?", "super_admin").First(&superRole).Error; err != nil {
return false, nil // 没有 super_admin 角色,不限制
}
var count int64
err := r.db.Model(&model.AdminUserRole{}).
Where("role_id = ? AND admin_user_id != ?", superRole.ID, adminID).
Count(&count).Error
return count == 0, err
}
func (r *Repository) invalidatePermCache(adminID uint64) {
if r.redis == nil {
return
}
ctx := context.Background()
r.redis.Del(ctx, permCacheKey(adminID))
}
func permCacheKey(adminID uint64) string {
return fmt.Sprintf("admin:perms:%d", adminID)
}
var (
ErrCannotDeleteSelf = errors.New("cannot delete self")
ErrLastSuperAdmin = errors.New("cannot delete the last super admin")
ErrWrongPassword = errors.New("wrong password")
)
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
@@ -0,0 +1,74 @@
package adminmgr
import "errors"
var ErrDependencyUnavailable = errors.New("dependency unavailable")
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(page, pageSize int) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
return s.repo.List(page, pageSize)
}
func (s *Service) FindByID(id uint64) (*AdminUserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindByID(id)
}
func (s *Service) Create(req CreateAdminRequest) (*AdminUserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if len(req.Password) < 6 {
return nil, errors.New("password too short")
}
return s.repo.Create(req)
}
func (s *Service) Update(id uint64, req UpdateAdminRequest) (*AdminUserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Update(id, req)
}
func (s *Service) Delete(id uint64, currentAdminID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.Delete(id, currentAdminID)
}
func (s *Service) AssignRoles(adminID uint64, req AssignRolesRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.AssignRoles(adminID, req.RoleIDs)
}
func (s *Service) ChangePassword(id uint64, req ChangePasswordRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if len(req.NewPassword) < 6 {
return errors.New("new password too short")
}
return s.repo.ChangePassword(id, req.OldPassword, req.NewPassword)
}
+37
View File
@@ -0,0 +1,37 @@
package adminrole
import "time"
type RoleDTO struct {
ID uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
PermCount int `json:"perm_count"`
Permissions []PermissionDTO `json:"permissions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PermissionDTO struct {
ID uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Resource string `json:"resource"`
Action string `json:"action"`
}
type CreateRoleRequest struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
type UpdateRoleRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
type AssignPermissionsRequest struct {
PermissionIDs []uint64 `json:"permission_ids"`
}
@@ -0,0 +1,133 @@
package adminrole
import (
"errors"
"net/http"
"strconv"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) List(c *gin.Context) {
items, err := h.service.List()
if err != nil {
writeError(c, err)
return
}
response.OK(c, items)
}
func (h *Handler) FindByID(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindByID(id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Create(c *gin.Context) {
var req CreateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "角色编码和名称不能为空")
return
}
item, err := h.service.Create(req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) Update(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "角色名称不能为空")
return
}
item, err := h.service.Update(id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.Delete(id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) AssignPermissions(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req AssignPermissionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求格式不正确")
return
}
if err := h.service.AssignPermissions(id, req); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) ListPermissions(c *gin.Context) {
items, err := h.service.ListPermissions()
if err != nil {
writeError(c, err)
return
}
response.OK(c, items)
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrProtectedRole):
response.BadRequest(c, "该角色为系统内置角色,不可删除")
case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "角色不存在")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "角色管理服务暂时不可用")
}
}
@@ -0,0 +1,170 @@
package adminrole
import (
"errors"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) List() ([]RoleDTO, error) {
var roles []model.Role
if err := r.db.Order("id ASC").Find(&roles).Error; err != nil {
return nil, err
}
result := make([]RoleDTO, 0, len(roles))
for _, role := range roles {
var count int64
r.db.Model(&model.RolePermission{}).Where("role_id = ?", role.ID).Count(&count)
result = append(result, RoleDTO{
ID: role.ID,
Code: role.Code,
Name: role.Name,
Description: role.Description,
PermCount: int(count),
CreatedAt: role.CreatedAt,
UpdatedAt: role.UpdatedAt,
})
}
return result, nil
}
func (r *Repository) FindByID(id uint64) (*RoleDTO, error) {
var role model.Role
if err := r.db.First(&role, id).Error; err != nil {
return nil, err
}
perms, err := r.getRolePermissions(id)
if err != nil {
return nil, err
}
dto := RoleDTO{
ID: role.ID,
Code: role.Code,
Name: role.Name,
Description: role.Description,
PermCount: len(perms),
Permissions: perms,
CreatedAt: role.CreatedAt,
UpdatedAt: role.UpdatedAt,
}
return &dto, nil
}
func (r *Repository) Create(req CreateRoleRequest) (*RoleDTO, error) {
role := model.Role{
Code: req.Code,
Name: req.Name,
Description: req.Description,
}
if err := r.db.Create(&role).Error; err != nil {
return nil, err
}
return r.FindByID(role.ID)
}
func (r *Repository) Update(id uint64, req UpdateRoleRequest) (*RoleDTO, error) {
var role model.Role
if err := r.db.First(&role, id).Error; err != nil {
return nil, err
}
role.Name = req.Name
role.Description = req.Description
if err := r.db.Save(&role).Error; err != nil {
return nil, err
}
return r.FindByID(id)
}
func (r *Repository) Delete(id uint64) error {
var role model.Role
if err := r.db.First(&role, id).Error; err != nil {
return err
}
if role.Code == "super_admin" {
return ErrProtectedRole
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("role_id = ?", id).Delete(&model.RolePermission{}).Error; err != nil {
return err
}
if err := tx.Where("role_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil {
return err
}
return tx.Delete(&model.Role{}, id).Error
})
}
func (r *Repository) AssignPermissions(roleID uint64, permIDs []uint64) error {
var role model.Role
if err := r.db.First(&role, roleID).Error; err != nil {
return err
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("role_id = ?", roleID).Delete(&model.RolePermission{}).Error; err != nil {
return err
}
for _, pid := range permIDs {
rp := model.RolePermission{RoleID: roleID, PermissionID: pid}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rp).Error; err != nil {
return err
}
}
return nil
})
}
func (r *Repository) ListPermissions() ([]PermissionDTO, error) {
var perms []model.Permission
if err := r.db.Order("resource ASC, action ASC").Find(&perms).Error; err != nil {
return nil, err
}
result := make([]PermissionDTO, 0, len(perms))
for _, p := range perms {
result = append(result, PermissionDTO{
ID: p.ID, Code: p.Code, Name: p.Name, Resource: p.Resource, Action: p.Action,
})
}
return result, nil
}
func (r *Repository) getRolePermissions(roleID uint64) ([]PermissionDTO, error) {
var rps []model.RolePermission
if err := r.db.Where("role_id = ?", roleID).Find(&rps).Error; err != nil {
return nil, err
}
if len(rps) == 0 {
return nil, nil
}
ids := make([]uint64, 0, len(rps))
for _, rp := range rps {
ids = append(ids, rp.PermissionID)
}
var perms []model.Permission
if err := r.db.Where("id IN ?", ids).Find(&perms).Error; err != nil {
return nil, err
}
result := make([]PermissionDTO, 0, len(perms))
for _, p := range perms {
result = append(result, PermissionDTO{
ID: p.ID, Code: p.Code, Name: p.Name, Resource: p.Resource, Action: p.Action,
})
}
return result, nil
}
var ErrProtectedRole = errors.New("protected role")
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
@@ -0,0 +1,65 @@
package adminrole
import "errors"
var ErrDependencyUnavailable = errors.New("dependency unavailable")
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List() ([]RoleDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.List()
}
func (s *Service) FindByID(id uint64) (*RoleDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindByID(id)
}
func (s *Service) Create(req CreateRoleRequest) (*RoleDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if req.Code == "" || req.Name == "" {
return nil, errors.New("code and name are required")
}
return s.repo.Create(req)
}
func (s *Service) Update(id uint64, req UpdateRoleRequest) (*RoleDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Update(id, req)
}
func (s *Service) Delete(id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.Delete(id)
}
func (s *Service) AssignPermissions(roleID uint64, req AssignPermissionsRequest) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.AssignPermissions(roleID, req.PermissionIDs)
}
func (s *Service) ListPermissions() ([]PermissionDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListPermissions()
}
+62 -27
View File
@@ -7,6 +7,8 @@ import (
"hfb_sys/backend/internal/modules/adminaudit"
"hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/modules/admindashboard"
"hfb_sys/backend/internal/modules/adminmgr"
"hfb_sys/backend/internal/modules/adminrole"
"hfb_sys/backend/internal/modules/adminuser"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/internal/modules/chat"
@@ -114,6 +116,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
}
systemConfigService := systemconfig.NewService(systemConfigRepo)
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
var adminRoleRepo *adminrole.Repository
if deps.DB != nil {
adminRoleRepo = adminrole.NewRepository(deps.DB)
}
adminRoleService := adminrole.NewService(adminRoleRepo)
adminRoleHandler := adminrole.NewHandler(adminRoleService)
var adminMgrRepo *adminmgr.Repository
if deps.DB != nil {
adminMgrRepo = adminmgr.NewRepository(deps.DB, deps.Redis)
}
adminMgrService := adminmgr.NewService(adminMgrRepo)
adminMgrHandler := adminmgr.NewHandler(adminMgrService)
var fileStorage *filemodule.Storage
if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" {
var err error
@@ -129,6 +143,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
requireAuth := middleware.Auth(jwtManager)
requireAdmin := middleware.AdminAuth(jwtManager)
requireRealname := middleware.RequireRealname(userRepo)
requirePerm := func(code string) gin.HandlerFunc {
return middleware.RequirePermission(code, deps.Redis)
}
api := engine.Group("/api")
{
@@ -240,34 +257,52 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/me", adminAuthHandler.Me)
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
adminRoutes.POST("/files/upload", fileHandler.Upload)
adminRoutes.GET("/dashboard", adminDashboardHandler.Summary)
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
adminRoutes.GET("/files/object", fileHandler.Object)
adminRoutes.GET("/users", adminUserHandler.List)
adminRoutes.POST("/users/:id/freeze", adminUserHandler.Freeze)
adminRoutes.POST("/users/:id/unfreeze", adminUserHandler.Unfreeze)
adminRoutes.GET("/orders", orderHandler.AdminList)
adminRoutes.GET("/orders/:id", orderHandler.AdminDetail)
adminRoutes.GET("/orders/:id/handoff-records", orderHandler.AdminHandoffRecords)
adminRoutes.POST("/orders/:id/close", orderHandler.AdminClose)
adminRoutes.POST("/orders/:id/mark-abnormal", orderHandler.AdminMarkAbnormal)
adminRoutes.GET("/listings", listingHandler.ListAdmin)
adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview)
adminRoutes.GET("/listings/:id", listingHandler.FindAdmin)
adminRoutes.POST("/listings/:id/approve", listingHandler.Approve)
adminRoutes.POST("/listings/:id/reject", listingHandler.Reject)
adminRoutes.POST("/listings/:id/offline", listingHandler.AdminOffline)
adminRoutes.POST("/listings/:id/mark-abnormal", listingHandler.AdminMarkAbnormal)
adminRoutes.GET("/disputes", disputeHandler.AdminList)
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger)
adminRoutes.GET("/system-configs", systemConfigHandler.List)
adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update)
adminRoutes.GET("/audit-logs", adminAuditHandler.List)
adminRoutes.GET("/chats", chatHandler.AdminList)
adminRoutes.GET("/chats/:id", chatHandler.AdminDetail)
adminRoutes.GET("/chats/:id/messages", chatHandler.AdminMessages)
adminRoutes.POST("/chats/:id/messages", chatHandler.AdminSend)
adminRoutes.POST("/chats/:id/read", chatHandler.AdminMarkRead)
adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List)
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
adminRoutes.POST("/users/:id/unfreeze", requirePerm("user:unfreeze"), adminUserHandler.Unfreeze)
adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList)
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose)
adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal)
adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin)
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve)
adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject)
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal)
adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList)
adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate)
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List)
adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update)
adminRoutes.GET("/audit-logs", requirePerm("audit_log:view"), adminAuditHandler.List)
adminRoutes.GET("/chats", requirePerm("chat:view"), chatHandler.AdminList)
adminRoutes.GET("/chats/:id", requirePerm("chat:view"), chatHandler.AdminDetail)
adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages)
adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend)
adminRoutes.POST("/chats/:id/read", requirePerm("chat:view"), chatHandler.AdminMarkRead)
// 角色管理
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID)
adminRoutes.POST("/roles", requirePerm("role:manage"), adminRoleHandler.Create)
adminRoutes.PUT("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Update)
adminRoutes.DELETE("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Delete)
adminRoutes.PUT("/roles/:id/permissions", requirePerm("role:manage"), adminRoleHandler.AssignPermissions)
adminRoutes.GET("/permissions", requirePerm("role:manage"), adminRoleHandler.ListPermissions)
// 管理员管理
adminRoutes.GET("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.List)
adminRoutes.GET("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.FindByID)
adminRoutes.POST("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.Create)
adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update)
adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete)
adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles)
adminRoutes.PUT("/admin-users/:id/password", adminMgrHandler.ChangePassword)
}
}
+58
View File
@@ -0,0 +1,58 @@
-- RBAC seed: 默认角色和权限
SET NAMES utf8mb4;
-- 插入权限
INSERT INTO permissions (code, name, resource, action) VALUES
('dashboard:view', '查看仪表盘', 'dashboard', 'view'),
('user:view', '查看用户', 'user', 'view'),
('user:freeze', '冻结用户', 'user', 'freeze'),
('user:unfreeze', '解冻用户', 'user', 'unfreeze'),
('order:view', '查看订单', 'order', 'view'),
('order:close', '关闭订单', 'order', 'close'),
('order:mark_abnormal', '标记异常订单', 'order', 'mark_abnormal'),
('listing:view', '查看商品', 'listing', 'view'),
('listing:approve', '审核通过商品', 'listing', 'approve'),
('listing:reject', '审核拒绝商品', 'listing', 'reject'),
('listing:offline', '下架商品', 'listing', 'offline'),
('dispute:view', '查看纠纷', 'dispute', 'view'),
('dispute:arbitrate', '仲裁纠纷', 'dispute', 'arbitrate'),
('chat:view', '查看客服群聊', 'chat', 'view'),
('chat:send', '发送客服消息', 'chat', 'send'),
('wallet:view', '查看资金流水', 'wallet', 'view'),
('admin_user:manage', '管理员管理', 'admin_user', 'manage'),
('role:manage', '角色管理', 'role', 'manage'),
('system_config:view', '查看系统配置', 'system_config', 'view'),
('system_config:update', '修改系统配置', 'system_config', 'update'),
('audit_log:view', '查看审计日志', 'audit_log', 'view')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- 插入角色
INSERT INTO roles (code, name, description) VALUES
('super_admin', '超级管理员', '拥有所有权限,不可被删除'),
('cs', '客服', '处理用户咨询、群聊、纠纷仲裁'),
('ops', '运营', '管理商品审核、订单管理、用户管理'),
('finance', '财务', '查看资金流水、订单信息')
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description);
-- super_admin 拥有所有权限
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.code = 'super_admin';
-- cs 角色权限
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.code = 'cs' AND p.code IN ('dashboard:view', 'user:view', 'dispute:view', 'dispute:arbitrate', 'chat:view', 'chat:send', 'order:view');
-- ops 角色权限
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.code = 'ops' AND p.code IN ('dashboard:view', 'user:view', 'user:freeze', 'user:unfreeze', 'order:view', 'order:close', 'order:mark_abnormal', 'listing:view', 'listing:approve', 'listing:reject', 'listing:offline', 'dispute:view', 'audit_log:view');
-- finance 角色权限
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.code = 'finance' AND p.code IN ('dashboard:view', 'wallet:view', 'order:view');
-- 默认 admin 用户自动绑定 super_admin 角色
INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id)
SELECT au.id, r.id FROM admin_users au, roles r WHERE au.username = 'admin' AND r.code = 'super_admin';
+2
View File
@@ -15,6 +15,8 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCarousel: typeof import('element-plus/es')['ElCarousel']
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
+8
View File
@@ -5,11 +5,19 @@ import type { ApiResponse } from './types'
import { getRefreshToken, setAuthTokens } from '@/utils/authStorage'
import type { UserStatus } from '@/types/status'
export interface AdminRole {
id: number
code: string
name: string
}
export interface AdminUser {
id: number
username: string
nickname: string
status: UserStatus
roles: AdminRole[]
permissions: string[]
last_login_at?: string
}
+76
View File
@@ -0,0 +1,76 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface AdminRole {
id: number
code: string
name: string
description: string
}
export interface AdminMgrUser {
id: number
username: string
nickname: string
status: string
roles: AdminRole[]
last_login_at?: string
created_at: string
updated_at: string
}
export interface CreateAdminRequest {
username: string
password: string
nickname?: string
}
export interface UpdateAdminRequest {
nickname?: string
status?: string
}
export interface ChangePasswordRequest {
old_password: string
new_password: string
}
export async function fetchAdminMgrUsers(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminMgrUser>>>('/admin/admin-users', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchAdminMgrUser(id: number) {
const { data } = await apiClient.get<ApiResponse<AdminMgrUser>>(`/admin/admin-users/${id}`)
return data.data
}
export async function createAdminMgrUser(req: CreateAdminRequest) {
const { data } = await apiClient.post<ApiResponse<AdminMgrUser>>('/admin/admin-users', req)
return data.data
}
export async function updateAdminMgrUser(id: number, req: UpdateAdminRequest) {
const { data } = await apiClient.put<ApiResponse<AdminMgrUser>>(`/admin/admin-users/${id}`, req)
return data.data
}
export async function deleteAdminMgrUser(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/admin-users/${id}`)
return data.data
}
export async function assignAdminRoles(id: number, roleIds: number[]) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/admin-users/${id}/roles`, {
role_ids: roleIds,
})
return data.data
}
export async function changeAdminPassword(id: number, req: ChangePasswordRequest) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/admin-users/${id}/password`, req)
return data.data
}
+70
View File
@@ -0,0 +1,70 @@
import { apiClient } from './client'
import type { ApiResponse } from './types'
export interface Permission {
id: number
code: string
name: string
resource: string
action: string
}
export interface Role {
id: number
code: string
name: string
description: string
perm_count: number
permissions?: Permission[]
created_at: string
updated_at: string
}
export interface CreateRoleRequest {
code: string
name: string
description?: string
}
export interface UpdateRoleRequest {
name: string
description?: string
}
export async function fetchRoles() {
const { data } = await apiClient.get<ApiResponse<Role[]>>('/admin/roles')
return data.data
}
export async function fetchRole(id: number) {
const { data } = await apiClient.get<ApiResponse<Role>>(`/admin/roles/${id}`)
return data.data
}
export async function createRole(req: CreateRoleRequest) {
const { data } = await apiClient.post<ApiResponse<Role>>('/admin/roles', req)
return data.data
}
export async function updateRole(id: number, req: UpdateRoleRequest) {
const { data } = await apiClient.put<ApiResponse<Role>>(`/admin/roles/${id}`, req)
return data.data
}
export async function deleteRole(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/admin/roles/${id}`)
return data.data
}
export async function assignRolePermissions(roleId: number, permissionIds: number[]) {
const { data } = await apiClient.put<ApiResponse<{ updated: boolean }>>(`/admin/roles/${roleId}/permissions`, {
permission_ids: permissionIds,
})
return data.data
}
export async function fetchPermissions() {
const { data } = await apiClient.get<ApiResponse<Permission[]>>('/admin/permissions')
return data.data
}
+31 -12
View File
@@ -12,7 +12,9 @@ import {
SwitchButton,
Tickets,
User,
Wallet
UserFilled,
Wallet,
Setting
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
@@ -25,19 +27,36 @@ const router = useRouter()
const adminSession = useAdminSessionStore()
const isCollapsed = ref(false)
const navItems = [
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine },
{ label: '用户管理', to: '/admin/users', icon: User },
{ label: '订单管理', to: '/admin/orders', icon: Tickets },
{ label: '商品管理', to: '/admin/listings', icon: Shop },
{ label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked },
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal },
{ label: '客服群聊', to: '/admin/chats', icon: ChatDotRound },
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet },
{ label: '系统配置', to: '/admin/system-configs', icon: Operation },
{ label: '审计日志', to: '/admin/audit-logs', icon: Document },
interface NavItem {
label: string
to: string
icon: any
permission?: string
}
const allNavItems: NavItem[] = [
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine, permission: 'dashboard:view' },
{ label: '用户管理', to: '/admin/users', icon: User, permission: 'user:view' },
{ label: '订单管理', to: '/admin/orders', icon: Tickets, permission: 'order:view' },
{ label: '商品管理', to: '/admin/listings', icon: Shop, permission: 'listing:view' },
{ label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked, permission: 'listing:approve' },
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal, permission: 'dispute:view' },
{ label: '客服群聊', to: '/admin/chats', icon: ChatDotRound, permission: 'chat:view' },
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' },
{ label: '系统配置', to: '/admin/system-configs', icon: Operation, permission: 'system_config:view' },
{ label: '审计日志', to: '/admin/audit-logs', icon: Document, permission: 'audit_log:view' },
{ label: '管理员管理', to: '/admin/admin-users', icon: UserFilled, permission: 'admin_user:manage' },
{ label: '角色管理', to: '/admin/roles', icon: Setting, permission: 'role:manage' },
]
const navItems = computed(() => {
if (adminSession.isSuperAdmin) return allNavItems
return allNavItems.filter((item) => {
if (!item.permission) return true
return adminSession.hasPermission(item.permission)
})
})
const adminName = computed(() => adminSession.nickname || adminSession.username || '管理员')
async function handleLogout() {
+12
View File
@@ -82,4 +82,16 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/admin/AdminAuditLogsView.vue'),
meta: adminMeta,
},
{
path: '/admin/admin-users',
name: 'admin-admin-users',
component: () => import('@/views/admin/AdminMgrUsersView.vue'),
meta: adminMeta,
},
{
path: '/admin/roles',
name: 'admin-roles',
component: () => import('@/views/admin/AdminRolesView.vue'),
meta: adminMeta,
},
]
+7
View File
@@ -84,6 +84,13 @@ router.beforeEach(async (to) => {
if (!adminSession.token) {
return '/admin/login'
}
if (adminSession.permissions.length === 0) {
try {
await adminSession.loadMe()
} catch {
// 权限加载失败,仍然允许访问(降级为无权限状态)
}
}
}
return true
})
+18 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { fetchAdminMe, loginAdmin, type AdminUser } from '@/api/adminAuth'
import { fetchAdminMe, loginAdmin, type AdminRole, type AdminUser } from '@/api/adminAuth'
import { clearAuthStorage, getAccessToken, getRefreshToken, setAuthTokens } from '@/utils/authStorage'
export const useAdminSessionStore = defineStore('adminSession', {
@@ -10,7 +10,20 @@ export const useAdminSessionStore = defineStore('adminSession', {
adminId: Number(localStorage.getItem('admin_id') || 0),
username: localStorage.getItem('admin_username') || '',
nickname: '',
roles: [] as AdminRole[],
permissions: [] as string[],
}),
getters: {
hasPermission: (state) => {
return (code: string) => state.permissions.includes(code) || state.permissions.includes('*')
},
hasRole: (state) => {
return (code: string) => state.roles.some((r) => r.code === code)
},
isSuperAdmin: (state) => {
return state.roles.some((r) => r.code === 'super_admin') || state.permissions.includes('*')
},
},
actions: {
async login(username: string, password: string, captchaId: string, captchaCode: string) {
const result = await loginAdmin(username, password, captchaId, captchaCode)
@@ -28,6 +41,8 @@ export const useAdminSessionStore = defineStore('adminSession', {
this.adminId = 0
this.username = ''
this.nickname = ''
this.roles = []
this.permissions = []
clearAuthStorage('admin')
},
syncFromStorage() {
@@ -49,6 +64,8 @@ export const useAdminSessionStore = defineStore('adminSession', {
this.adminId = admin.id
this.username = admin.username
this.nickname = admin.nickname
this.roles = admin.roles || []
this.permissions = admin.permissions || []
localStorage.setItem('admin_id', String(admin.id))
localStorage.setItem('admin_username', admin.username)
},
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchAdminMgrUsers, deleteAdminMgrUser, changeAdminPassword, type AdminMgrUser } from '@/api/adminMgr'
import { useAdminPaginatedTable } from '@/composables/useAdminPaginatedTable'
import { formatDateTime } from '@/utils/time'
import AdminUserDialog from './components/AdminUserDialog.vue'
import AssignRolesDialog from './components/AssignRolesDialog.vue'
const showDialog = ref(false)
const editingAdmin = ref<AdminMgrUser | null>(null)
const showRolesDialog = ref(false)
const rolesAdmin = ref<AdminMgrUser | null>(null)
const showPasswordDialog = ref(false)
const passwordAdmin = ref<AdminMgrUser | null>(null)
const passwordForm = ref({ old_password: '', new_password: '' })
const passwordSubmitting = ref(false)
const { loading, data: admins, total, currentPage, currentPageSize, load: loadAdmins, handleSizeChange } = useAdminPaginatedTable<AdminMgrUser>({
fetchFn: fetchAdminMgrUsers,
})
function openCreate() {
editingAdmin.value = null
showDialog.value = true
}
function openEdit(row: AdminMgrUser) {
editingAdmin.value = row
showDialog.value = true
}
function openRoles(row: AdminMgrUser) {
rolesAdmin.value = row
showRolesDialog.value = true
}
function openPassword(row: AdminMgrUser) {
passwordAdmin.value = row
passwordForm.value = { old_password: '', new_password: '' }
showPasswordDialog.value = true
}
async function handleDelete(row: AdminMgrUser) {
try {
await ElMessageBox.confirm(`确定要删除管理员「${row.username}」吗?此操作不可撤销。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteAdminMgrUser(row.id)
ElMessage.success('管理员已删除')
await loadAdmins()
} catch {
// 用户取消
}
}
async function handleChangePassword() {
if (!passwordAdmin.value) return
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
ElMessage.warning('请填写完整')
return
}
passwordSubmitting.value = true
try {
await changeAdminPassword(passwordAdmin.value.id, passwordForm.value)
ElMessage.success('密码已修改')
showPasswordDialog.value = false
} catch (error) {
ElMessage.error(readError(error, '修改失败'))
} finally {
passwordSubmitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
const statusLabel: Record<string, string> = {
active: '启用',
disabled: '禁用',
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Admin Users</p>
<h1>管理员管理</h1>
<p>管理后台管理员账号分配角色和权限</p>
</div>
<div class="toolbar-actions">
<el-button @click="loadAdmins">刷新</el-button>
<el-button type="primary" @click="openCreate">新建管理员</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="admins">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column label="角色" min-width="200">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role.id"
size="small"
style="margin-right: 4px; margin-bottom: 2px"
>
{{ role.name }}
</el-tag>
<span v-if="!row.roles?.length" style="color: #8f9bba">未分配</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ statusLabel[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ row.last_login_at ? formatDateTime(row.last_login_at) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openRoles(row)">角色</el-button>
<el-button size="small" @click="openPassword(row)">密码</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadAdmins"
@size-change="handleSizeChange"
/>
</div>
<!-- 新建/编辑对话框 -->
<AdminUserDialog
v-model="showDialog"
:admin="editingAdmin"
@saved="loadAdmins"
/>
<!-- 角色分配对话框 -->
<AssignRolesDialog
v-model="showRolesDialog"
:admin="rolesAdmin"
@saved="loadAdmins"
/>
<!-- 修改密码对话框 -->
<el-dialog
:model-value="showPasswordDialog"
:title="`修改密码 - ${passwordAdmin?.username || ''}`"
width="460px"
@update:model-value="showPasswordDialog = $event"
>
<div class="dialog-body">
<el-form-item label="原密码" class="full-control">
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码" class="full-control">
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="请输入新密码(至少6位)" />
</el-form-item>
</div>
<template #footer>
<el-button @click="showPasswordDialog = false">取消</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="handleChangePassword">确认修改</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.toolbar-actions {
display: flex;
gap: 8px;
}
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'
import { fetchRoles, deleteRole, type Role } from '@/api/adminRoles'
import { useAdminTable } from '@/composables/useAdminTable'
import AssignPermissionsDialog from './components/AssignPermissionsDialog.vue'
import RoleDialog from './components/RoleDialog.vue'
const showDialog = ref(false)
const editingRole = ref<Role | null>(null)
const showPermsDialog = ref(false)
const permsRole = ref<Role | null>(null)
const { loading, data: roles, load: loadRoles } = useAdminTable<Role[]>({
fetchFn: fetchRoles,
})
function openCreate() {
editingRole.value = null
showDialog.value = true
}
function openEdit(row: Role) {
editingRole.value = row
showDialog.value = true
}
function openPerms(row: Role) {
permsRole.value = row
showPermsDialog.value = true
}
async function handleDelete(row: Role) {
try {
await ElMessageBox.confirm(`确定要删除角色「${row.name}」吗?已分配该角色的管理员将失去对应权限。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteRole(row.id)
ElMessage.success('角色已删除')
await loadRoles()
} catch {
// 用户取消
}
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Roles</p>
<h1>角色管理</h1>
<p>管理系统角色及其权限配置</p>
</div>
<div class="toolbar-actions">
<el-button @click="loadRoles">刷新</el-button>
<el-button type="primary" @click="openCreate">新建角色</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="roles">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="code" label="编码" min-width="120" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="perm_count" label="权限数" width="90" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openPerms(row)">权限</el-button>
<el-button
v-if="row.code !== 'super_admin'"
size="small"
type="danger"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑对话框 -->
<RoleDialog
v-model="showDialog"
:role="editingRole"
@saved="loadRoles"
/>
<!-- 权限分配对话框 -->
<AssignPermissionsDialog
v-model="showPermsDialog"
:role="permsRole"
@saved="loadRoles"
/>
</section>
</template>
<style scoped>
.toolbar-actions {
display: flex;
gap: 8px;
}
</style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { createAdminMgrUser, updateAdminMgrUser, type AdminMgrUser, type CreateAdminRequest, type UpdateAdminRequest } from '@/api/adminMgr'
const props = defineProps<{
modelValue: boolean
admin?: AdminMgrUser | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const form = ref({
username: '',
password: '',
nickname: '',
status: 'active',
})
const isEdit = ref(false)
watch(
() => props.modelValue,
(val) => {
if (val) {
if (props.admin) {
isEdit.value = true
form.value = {
username: props.admin.username,
password: '',
nickname: props.admin.nickname,
status: props.admin.status,
}
} else {
isEdit.value = false
form.value = { username: '', password: '', nickname: '', status: 'active' }
}
}
},
{ immediate: true },
)
async function handleSave() {
submitting.value = true
try {
if (isEdit.value && props.admin) {
const req: UpdateAdminRequest = {
nickname: form.value.nickname,
status: form.value.status,
}
await updateAdminMgrUser(props.admin.id, req)
ElMessage.success('管理员已更新')
} else {
const req: CreateAdminRequest = {
username: form.value.username,
password: form.value.password,
nickname: form.value.nickname,
}
await createAdminMgrUser(req)
ElMessage.success('管理员已创建')
}
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑管理员' : '新建管理员'"
width="500px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<el-form-item label="用户名" class="full-control">
<el-input v-model="form.username" :disabled="isEdit" placeholder="请输入用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码" class="full-control">
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码(至少6位)" />
</el-form-item>
<el-form-item label="昵称" class="full-control">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item v-if="isEdit" label="状态" class="full-control">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="active" />
<el-option label="禁用" value="disabled" />
</el-select>
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { fetchPermissions, fetchRole, assignRolePermissions, type Permission, type Role } from '@/api/adminRoles'
const props = defineProps<{
modelValue: boolean
role: Role | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const loading = ref(false)
const allPermissions = ref<Permission[]>([])
const selectedPermIds = ref<number[]>([])
// 按 resource 分组
const groupedPermissions = ref<Record<string, Permission[]>>({})
watch(
() => props.modelValue,
async (val) => {
if (val && props.role) {
loading.value = true
try {
const [perms, roleDetail] = await Promise.all([fetchPermissions(), fetchRole(props.role.id)])
allPermissions.value = perms
selectedPermIds.value = (roleDetail.permissions || []).map((p) => p.id)
// 按 resource 分组
const grouped: Record<string, Permission[]> = {}
for (const p of perms) {
const arr = grouped[p.resource] ?? (grouped[p.resource] = [])
arr.push(p)
}
groupedPermissions.value = grouped
} catch {
ElMessage.error('加载权限列表失败')
} finally {
loading.value = false
}
}
},
)
async function handleSave() {
if (!props.role) return
submitting.value = true
try {
await assignRolePermissions(props.role.id, selectedPermIds.value)
ElMessage.success('权限已分配')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '分配失败'))
} finally {
submitting.value = false
}
}
function toggleGroup(perms: Permission[]) {
const ids = perms.map((p) => p.id)
const allSelected = ids.every((id) => selectedPermIds.value.includes(id))
if (allSelected) {
selectedPermIds.value = selectedPermIds.value.filter((id) => !ids.includes(id))
} else {
const newIds = [...selectedPermIds.value]
for (const id of ids) {
if (!newIds.includes(id)) newIds.push(id)
}
selectedPermIds.value = newIds
}
}
function isGroupAllSelected(perms: Permission[]) {
return perms.length > 0 && perms.every((p) => selectedPermIds.value.includes(p.id))
}
function isGroupPartial(perms: Permission[]) {
return perms.some((p) => selectedPermIds.value.includes(p.id)) && !isGroupAllSelected(perms)
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
const resourceLabels: Record<string, string> = {
dashboard: '仪表盘',
user: '用户管理',
order: '订单管理',
listing: '商品管理',
dispute: '仲裁中心',
chat: '客服群聊',
wallet: '资金流水',
admin_user: '管理员管理',
role: '角色管理',
system_config: '系统配置',
audit_log: '审计日志',
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="`分配权限 - ${role?.name || ''}`"
width="600px"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="dialog-body">
<p v-if="role" class="role-info">
<strong>{{ role.name }}</strong> · {{ role.description }}
</p>
<div v-for="(perms, resource) in groupedPermissions" :key="resource" class="perm-group">
<div class="perm-group-header">
<el-checkbox
:model-value="isGroupAllSelected(perms)"
:indeterminate="isGroupPartial(perms)"
@change="toggleGroup(perms)"
>
<strong>{{ resourceLabels[resource] || resource }}</strong>
</el-checkbox>
</div>
<div class="perm-group-items">
<el-checkbox
v-for="perm in perms"
:key="perm.id"
v-model="selectedPermIds"
:value="perm.id"
:label="perm.id"
>
{{ perm.name }}
</el-checkbox>
</div>
</div>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.role-info {
margin: 0;
color: #374151;
}
.perm-group {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
}
.perm-group-header {
margin-bottom: 8px;
}
.perm-group-items {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding-left: 24px;
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { fetchRoles, type Role } from '@/api/adminRoles'
import { assignAdminRoles, type AdminMgrUser } from '@/api/adminMgr'
const props = defineProps<{
modelValue: boolean
admin: AdminMgrUser | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const loading = ref(false)
const allRoles = ref<Role[]>([])
const selectedRoleIds = ref<number[]>([])
watch(
() => props.modelValue,
async (val) => {
if (val && props.admin) {
loading.value = true
try {
allRoles.value = await fetchRoles()
selectedRoleIds.value = props.admin.roles.map((r) => r.id)
} catch {
ElMessage.error('加载角色列表失败')
} finally {
loading.value = false
}
}
},
)
async function handleSave() {
if (!props.admin) return
submitting.value = true
try {
await assignAdminRoles(props.admin.id, selectedRoleIds.value)
ElMessage.success('角色已分配')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '分配失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="`分配角色 - ${admin?.username || ''}`"
width="500px"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="dialog-body">
<p v-if="admin" class="admin-info">
<strong>{{ admin.username }}</strong> · {{ admin.nickname }}
</p>
<el-checkbox-group v-model="selectedRoleIds">
<div v-for="role in allRoles" :key="role.id" class="role-option">
<el-checkbox :value="role.id" :label="role.id">
<span class="role-name">{{ role.name }}</span>
<span class="role-desc">{{ role.description }}</span>
</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.admin-info {
margin: 0;
color: #374151;
}
.role-option {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.role-option:last-child {
border-bottom: none;
}
.role-name {
font-weight: 500;
margin-right: 8px;
}
.role-desc {
color: #8f9bba;
font-size: 13px;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { createRole, updateRole, type Role, type CreateRoleRequest, type UpdateRoleRequest } from '@/api/adminRoles'
const props = defineProps<{
modelValue: boolean
role?: Role | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const form = ref({
code: '',
name: '',
description: '',
})
const isEdit = ref(false)
watch(
() => props.modelValue,
(val) => {
if (val) {
if (props.role) {
isEdit.value = true
form.value = {
code: props.role.code,
name: props.role.name,
description: props.role.description,
}
} else {
isEdit.value = false
form.value = { code: '', name: '', description: '' }
}
}
},
{ immediate: true },
)
async function handleSave() {
submitting.value = true
try {
if (isEdit.value && props.role) {
const req: UpdateRoleRequest = {
name: form.value.name,
description: form.value.description,
}
await updateRole(props.role.id, req)
ElMessage.success('角色已更新')
} else {
const req: CreateRoleRequest = {
code: form.value.code,
name: form.value.name,
description: form.value.description,
}
await createRole(req)
ElMessage.success('角色已创建')
}
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '操作失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑角色' : '新建角色'"
width="500px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<el-form-item label="角色编码" class="full-control">
<el-input v-model="form.code" :disabled="isEdit" placeholder="例如: cs, ops, finance" />
</el-form-item>
<el-form-item label="角色名称" class="full-control">
<el-input v-model="form.name" placeholder="例如: 客服, 运营, 财务" />
</el-form-item>
<el-form-item label="描述" class="full-control">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="角色描述" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
}
.full-control {
width: 100%;
}
</style>
+22 -5
View File
@@ -74,20 +74,37 @@ init_database() {
local migration
for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do
log "执行迁移:$(basename "${migration}")"
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${migration}"
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${migration}"
done
else
local chat_table_count
chat_table_count="$(
docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'chat_conversations';" 2>/dev/null
)"
)" || chat_table_count="0"
if [[ "${chat_table_count}" == "0" ]]; then
log "补充聊天表迁移:000002_chat.sql"
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_chat.sql"
else
log "数据库结构已存在,跳过迁移"
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_chat.sql"
fi
local role_count
role_count="$(
docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'roles';" 2>/dev/null
)" || role_count="0"
if [[ "${role_count}" != "0" ]]; then
local seed_count
seed_count="$(
docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \
"SELECT COUNT(*) FROM roles;" 2>/dev/null
)" || seed_count="0"
if [[ "${seed_count}" == "0" ]]; then
log "补充 RBAC 种子数据:000002_rbac_seed.sql"
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_rbac_seed.sql" || true
fi
fi
log "数据库迁移检查完成"
fi
}