feat(role):权限管理功能
This commit is contained in:
1 parent
f129221942
commit
fdd1bc7653
11 files changed
+408
-157
No files matched your search
@@ -139,6 +139,14 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
|
||||
return nil, common.NewBadRequestError(err.Error())
|
||||
}
|
||||
|
||||
// 创建账号时附带角色属于角色分配,与角色管理同级:仅超管可用。
|
||||
// 否则拥有 user:create 的人可以造一个挂着高权限角色、密码由自己设定的账号,
|
||||
// 等价于给自己提权。这里明确报错而非静默忽略——静默会造出一个零权限账号,
|
||||
// 使用者以为分配成功了。
|
||||
if len(req.RoleIDs) > 0 && common.GetCtxUserRole(ctx) != "super_admin" {
|
||||
return nil, common.NewBusinessError(consts.CodeForbidden, "分配角色仅超级管理员可用,请先创建账号再由超级管理员分配角色")
|
||||
}
|
||||
|
||||
// 账号与角色关联同事务写入:此前新建的 admin 账号不带任何权限,登录后处处 403,
|
||||
// 是「系统缺乏角色管理」最直接的体感来源。
|
||||
var id int64
|
||||
|
||||
@@ -391,6 +391,9 @@ func replaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error
|
||||
|
||||
// AssignUserRoles 为管理员分配角色(全量覆盖)。
|
||||
func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := loadAdminUserRole(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -412,6 +415,9 @@ func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssig
|
||||
|
||||
// GetUserRoles 查询管理员已分配的角色。
|
||||
func (s *sAdmin) GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadAdminUserRole(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/model/do"
|
||||
@@ -81,8 +82,23 @@ var builtinRoleDefaults = map[string][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// assertSuperAdmin 校验调用者是超级管理员。
|
||||
//
|
||||
// 与 middleware 的 superAdminOnlyRules 是两道独立的闸门:中间件按路由拦截,这里按方法拦截。
|
||||
// 双重校验不是冗余 —— 路由表漏配、内部调用绕过 HTTP 层、将来有人给角色接口加了新路径,
|
||||
// 任何一种情况下都还有一道守住。权限体系的元操作值得这个代价。
|
||||
func assertSuperAdmin(ctx context.Context) error {
|
||||
if lcommon.GetCtxUserRole(ctx) == "super_admin" {
|
||||
return nil
|
||||
}
|
||||
return lcommon.NewBusinessError(consts.CodeForbidden, "角色权限管理仅超级管理员可用")
|
||||
}
|
||||
|
||||
// ListRoles 返回角色列表(角色数量少,不分页)。
|
||||
func (s *sAdmin) ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var roles []struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
@@ -144,6 +160,9 @@ func countByRole(ctx context.Context, model *gdb.Model) (map[int64]int, error) {
|
||||
|
||||
// GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。
|
||||
func (s *sAdmin) GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role, err := loadRole(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -191,6 +210,9 @@ func buildModuleTiers(perms []string) []v1.AdminRoleModuleTier {
|
||||
|
||||
// CreateRole 新建角色。权限可直接给出,也可从现有角色复制。
|
||||
func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := strings.ToLower(strings.TrimSpace(req.Code))
|
||||
if code == reservedRoleCode {
|
||||
return nil, lcommon.NewBadRequestError("super_admin 是保留标识,超级管理员由账号属性判定,不能作为角色")
|
||||
@@ -258,6 +280,9 @@ func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v
|
||||
|
||||
// UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。
|
||||
func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadRole(ctx, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -310,6 +335,9 @@ func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v
|
||||
|
||||
// UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。
|
||||
func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadRole(ctx, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -332,6 +360,9 @@ func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUp
|
||||
// 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权,
|
||||
// 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。
|
||||
func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadRole(ctx, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -361,6 +392,9 @@ func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v
|
||||
|
||||
// ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。
|
||||
func (s *sAdmin) ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role, err := loadRole(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -337,6 +337,46 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "POST", prefix: "/api/admin/usage-logs/cleanup/tasks/", suffix: "/cancel", perm: "system:edit"},
|
||||
}
|
||||
|
||||
// superAdminOnlyRules 列出仅超级管理员可访问的路由。
|
||||
//
|
||||
// 角色管理不走「权限点授权」而是硬性限定超管,理由是它是权限体系的元操作:
|
||||
// 谁能改角色,谁就能决定所有人的权限。一旦把它下放出去(哪怕只给 user:edit),
|
||||
// 被下放者就能给自己挂上更高权限的角色——权限体系当场失效。
|
||||
//
|
||||
// 这条限制让提权路径根本不存在,而不是靠「只能授予自己已有的权限」这类兜底去堵。
|
||||
// 普通账号管理(创建/禁用/改密/删除)不受影响,仍按 user:* 授权,可以正常下放。
|
||||
var superAdminOnlyRules = []permissionRule{
|
||||
// 角色的增删改查与启停
|
||||
{method: "GET", path: "/api/admin/roles"},
|
||||
{method: "POST", path: "/api/admin/roles"},
|
||||
{method: "GET", prefix: "/api/admin/roles/"},
|
||||
{method: "POST", prefix: "/api/admin/roles/"},
|
||||
{method: "PUT", prefix: "/api/admin/roles/"},
|
||||
{method: "DELETE", prefix: "/api/admin/roles/"},
|
||||
// 给账号分配角色 —— 这是最直接的提权入口
|
||||
{method: "GET", prefix: "/api/admin/users/", suffix: "/roles"},
|
||||
{method: "PUT", prefix: "/api/admin/users/", suffix: "/roles"},
|
||||
}
|
||||
|
||||
// isSuperAdminOnly 判断路由是否仅限超级管理员。
|
||||
// 任一规则命中即为真,不需要优先级判定。
|
||||
func isSuperAdminOnly(method, path string) bool {
|
||||
for _, rule := range superAdminOnlyRules {
|
||||
if rule.method != method {
|
||||
continue
|
||||
}
|
||||
if rule.path != "" && path == rule.path {
|
||||
return true
|
||||
}
|
||||
if rule.prefix != "" && strings.HasPrefix(path, rule.prefix) {
|
||||
if rule.suffix == "" || suffixMatches(path, rule.suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type permissionRule struct {
|
||||
method string // HTTP method
|
||||
path string // exact path match (mutually exclusive with prefix)
|
||||
@@ -367,6 +407,12 @@ func AdminPermissionGuard(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 角色管理是权限体系的元操作,硬性限定超管,不参与权限点授权
|
||||
if isSuperAdminOnly(r.Method, r.URL.Path) {
|
||||
response.ErrorMsg(r, consts.CodeForbidden, "角色权限管理仅超级管理员可用")
|
||||
return
|
||||
}
|
||||
|
||||
perm := matchPermission(r.Method, r.URL.Path)
|
||||
if perm == "" {
|
||||
// 默认拒绝:未配置权限规则的接口不放行(super_admin 已在上方短路)
|
||||
|
||||
@@ -219,3 +219,51 @@ func TestMatchedPermissionsAreGrantable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。
|
||||
//
|
||||
// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条
|
||||
// 限制,一个被下放了 user:edit 的账号就能给自己挂上更高权限的角色,整套角色体系当场失效。
|
||||
// 新增角色相关路由时,本测试会强制你同步登记 superAdminOnlyRules。
|
||||
func TestRoleManagementIsSuperAdminOnly(t *testing.T) {
|
||||
var leaked []string
|
||||
|
||||
for _, r := range collectAdminRoutes(t) {
|
||||
path := concreteFromPattern(r.Path)
|
||||
isRoleRoute := strings.HasPrefix(r.Path, "/api/admin/roles") ||
|
||||
strings.HasSuffix(r.Path, "/roles")
|
||||
if !isRoleRoute {
|
||||
continue
|
||||
}
|
||||
if !isSuperAdminOnly(r.Method, path) {
|
||||
leaked = append(leaked, r.Method+" "+r.Path+" ("+r.Source+")")
|
||||
}
|
||||
}
|
||||
|
||||
if len(leaked) > 0 {
|
||||
t.Fatalf("以下角色管理接口未限定为超级管理员专属(请补入 superAdminOnlyRules):\n %s",
|
||||
strings.Join(leaked, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuperAdminOnlyRulesDoNotOverreach 反向确认:超管专属清单不应误伤普通账号管理。
|
||||
//
|
||||
// 账号管理(创建/禁用/改密/删除)按 user:* 授权、可以正常下放,只有「分配角色」才是
|
||||
// 权限体系的元操作。两者路径相邻(都在 /api/admin/users/ 下),容易被前缀规则误伤。
|
||||
func TestSuperAdminOnlyRulesDoNotOverreach(t *testing.T) {
|
||||
shouldStayDelegatable := []struct{ method, path string }{
|
||||
{"GET", "/api/admin/users"},
|
||||
{"POST", "/api/admin/users"},
|
||||
{"PUT", "/api/admin/users/1"},
|
||||
{"PUT", "/api/admin/users/1/status"},
|
||||
{"PUT", "/api/admin/users/1/reset-password"},
|
||||
{"PUT", "/api/admin/users/1/unlock"},
|
||||
{"DELETE", "/api/admin/users/1"},
|
||||
{"GET", "/api/admin/users/1/permissions"},
|
||||
}
|
||||
for _, c := range shouldStayDelegatable {
|
||||
if isSuperAdminOnly(c.method, c.path) {
|
||||
t.Errorf("%s %s 属于普通账号管理,不应被限定为超管专属", c.method, c.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
path: 'permissions',
|
||||
name: 'AdminPermissions',
|
||||
component: () => import('@/views/security/PermissionsPage.vue'),
|
||||
meta: { title: '角色权限', perm: 'user:view' },
|
||||
meta: { title: '角色权限', superOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'login-history',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import adminRoutes from './admin'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { hasPermission, isSuperAdmin } from '@/utils/permission'
|
||||
import request, { shouldRefresh, getRefreshToken } from '@/utils/request'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
@@ -103,6 +103,9 @@ router.beforeEach(async (to) => {
|
||||
|
||||
// 页面级权限校验。菜单已按权限过滤,这里拦的是直接输 URL 的情况。
|
||||
// 接口层本就会 403,但没有这道守卫,用户会先看到一个空白页再看到一片报错。
|
||||
if (to.meta.superOnly && !isSuperAdmin()) {
|
||||
return { name: 'AdminForbidden' }
|
||||
}
|
||||
const requiredPerm = to.meta.perm as string | undefined
|
||||
if (requiredPerm && !hasPermission(requiredPerm)) {
|
||||
return { name: 'AdminForbidden' }
|
||||
|
||||
@@ -28,6 +28,18 @@ export function hasPermission(permission: string): boolean {
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否超级管理员。
|
||||
*
|
||||
* 用于「不参与权限点授权、硬性限定超管」的功能(如角色权限管理):
|
||||
* 这类功能是权限体系的元操作,不能通过授予权限点下放,否则被下放者
|
||||
* 能给自己挂上更高权限的角色。后端有对应的 superAdminOnlyRules 拦截,
|
||||
* 前端这份判断只用于隐藏入口,不构成安全边界。
|
||||
*/
|
||||
export function isSuperAdmin(): boolean {
|
||||
return hasRole(ADMIN_ROLES.SUPER_ADMIN)
|
||||
}
|
||||
|
||||
export function hasRole(role: string): boolean {
|
||||
const currentRole = getStoredString(ADMIN_ROLE_KEY)
|
||||
return currentRole === role
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
IconCheckCircleFill,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { hasPermission, isSuperAdmin } from '@/utils/permission'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -356,7 +356,6 @@ const ALL_MENU_GROUPS = [
|
||||
{ name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle, perm: 'audit:view' },
|
||||
{ name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle, perm: 'audit:view' },
|
||||
{ name: 'AdminSessions', label: '会话管理', icon: IconClockCircle },
|
||||
{ name: 'AdminPermissions', label: '角色权限', icon: IconSafe, perm: 'user:view' },
|
||||
{ name: 'AdminAudit', label: '操作日志', icon: IconFile, perm: 'audit:view' },
|
||||
],
|
||||
},
|
||||
@@ -398,6 +397,7 @@ const ALL_MENU_GROUPS = [
|
||||
icon: IconSettings,
|
||||
items: [
|
||||
{ name: 'AdminUsers', label: '用户管理', icon: IconUserGroup, perm: 'user:view' },
|
||||
{ name: 'AdminPermissions', label: '角色权限', icon: IconSafe, superOnly: true },
|
||||
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock, perm: 'system:plugin' },
|
||||
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings, perm: 'system:view' },
|
||||
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage, perm: 'file:view' },
|
||||
@@ -411,7 +411,12 @@ const menuGroups = computed(() =>
|
||||
ALL_MENU_GROUPS
|
||||
.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => !item.perm || hasPermission(item.perm)),
|
||||
items: group.items.filter(item => {
|
||||
// superOnly 的条目不参与权限点判定:它们是权限体系本身的元操作,
|
||||
// 无法通过授予权限点下放(后端 superAdminOnlyRules 同样硬性拦截)
|
||||
if ((item as any).superOnly) return isSuperAdmin()
|
||||
return !item.perm || hasPermission(item.perm)
|
||||
}),
|
||||
}))
|
||||
.filter(group => group.items.length > 0)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { ref, computed, onMounted, reactive, h } from 'vue'
|
||||
import { Message, Modal, Tag, Button, Space, Dropdown, Doption } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { isSuperAdmin } from '@/utils/permission'
|
||||
|
||||
interface TierOption {
|
||||
tier: string
|
||||
@@ -33,9 +36,10 @@ const roles = ref<RoleItem[]>([])
|
||||
const modules = ref<ModuleMeta[]>([])
|
||||
const dangerous = ref<Record<string, string>>({})
|
||||
|
||||
const canEdit = computed(() => hasPermission('user:edit'))
|
||||
const canCreate = computed(() => hasPermission('user:create'))
|
||||
const canDelete = computed(() => hasPermission('user:delete'))
|
||||
// 角色管理是权限体系的元操作,硬性限定超管,不参与权限点授权。
|
||||
// 后端 superAdminOnlyRules + assertSuperAdmin 两道闸门同样拦截,
|
||||
// 这里的判断只用于隐藏操作入口,不构成安全边界。
|
||||
const canManage = computed(() => isSuperAdmin())
|
||||
|
||||
// ── 编辑态 ─────────────────────────────────────────────────────────────────
|
||||
const editorVisible = ref(false)
|
||||
@@ -289,6 +293,117 @@ function confirmReset(row: RoleItem) {
|
||||
}
|
||||
|
||||
const selectedCount = computed(() => selected.value.size)
|
||||
|
||||
// 超级管理员不入角色表(由 sys_admin_users.role 判定),但它是事实上的一个身份,
|
||||
// 在列表里缺席反而让人以为漏了。作为固定首行呈现,标记为不可配置。
|
||||
const SUPER_ADMIN_ROW: any = {
|
||||
id: -1,
|
||||
code: 'super_admin',
|
||||
name: '超级管理员',
|
||||
description: '拥有全部权限,由账号属性直接判定,不通过角色授予',
|
||||
is_builtin: true,
|
||||
is_enabled: true,
|
||||
sort: -1,
|
||||
user_count: null,
|
||||
perm_count: null,
|
||||
__fixed: true,
|
||||
}
|
||||
|
||||
const tableData = computed<any[]>(() => [SUPER_ADMIN_ROW, ...roles.value])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'name',
|
||||
width: 200,
|
||||
render({ record }) {
|
||||
return h('div', {}, [
|
||||
h('div', { class: 'role-name' }, record.name),
|
||||
h('div', { class: 'role-code' }, record.code),
|
||||
])
|
||||
},
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true, tooltip: true },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'is_builtin',
|
||||
width: 100,
|
||||
render({ record }) {
|
||||
if (record.__fixed) return h(Tag, { color: 'red', size: 'small' }, () => '系统内置')
|
||||
return record.is_builtin
|
||||
? h(Tag, { color: 'arcoblue', size: 'small' }, () => '预置')
|
||||
: h(Tag, { size: 'small' }, () => '自定义')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
width: 90,
|
||||
render({ record }) {
|
||||
if (record.__fixed) return h('span', { class: 'text-muted' }, '—')
|
||||
return record.is_enabled
|
||||
? h(Tag, { color: 'green', size: 'small' }, () => '启用')
|
||||
: h(Tag, { color: 'gray', size: 'small' }, () => '已禁用')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '账号数',
|
||||
dataIndex: 'user_count',
|
||||
width: 90,
|
||||
render({ record }) {
|
||||
if (record.__fixed) return h('span', { class: 'text-muted' }, '—')
|
||||
return h('span', {}, String(record.user_count))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '权限数',
|
||||
dataIndex: 'perm_count',
|
||||
width: 90,
|
||||
render({ record }) {
|
||||
if (record.__fixed) return h('span', { class: 'text-muted' }, '全部')
|
||||
return h('span', {}, String(record.perm_count))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render({ record }) {
|
||||
// 超管行不可配置:它的权限不来自角色表,改不了也没有可改的东西
|
||||
if (record.__fixed) {
|
||||
return h('span', { class: 'text-muted' }, '不可配置')
|
||||
}
|
||||
|
||||
const primary = h(Button, { size: 'small', type: 'primary', onClick: () => openEdit(record) },
|
||||
() => canManage.value ? '配置权限' : '查看权限')
|
||||
if (!canManage.value) {
|
||||
return primary
|
||||
}
|
||||
|
||||
// 「配置权限」是日常操作,常驻;复制/启停/恢复默认/删除都是低频动作,收进「更多」,
|
||||
// 免得五个按钮把操作列撑到换行
|
||||
const items = [
|
||||
h(Doption, { onClick: () => openCreate(record) }, () => '复制'),
|
||||
h(Doption, { onClick: () => toggleStatus(record) },
|
||||
() => record.is_enabled ? '禁用' : '启用'),
|
||||
]
|
||||
if (record.is_builtin) {
|
||||
items.push(h(Doption, { onClick: () => confirmReset(record) }, () => '恢复默认'))
|
||||
}
|
||||
// 删除会连带清掉该角色下所有账号的权限,标红
|
||||
items.push(h(Doption, { class: 'doption-danger', onClick: () => confirmDelete(record) }, () => '删除'))
|
||||
|
||||
return h(Space, { size: 4 }, () => [
|
||||
primary,
|
||||
h(Dropdown, { trigger: 'click' }, {
|
||||
default: () => h(Button, { size: 'small' }, () => '更多'),
|
||||
content: () => items,
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -297,63 +412,31 @@ const selectedCount = computed(() => selected.value.size)
|
||||
<template #actions>
|
||||
<ASpace>
|
||||
<AButton size="small" @click="fetchRoles">刷新</AButton>
|
||||
<AButton v-if="canCreate" type="primary" size="small" @click="openCreate()">新建角色</AButton>
|
||||
<AButton v-if="canManage" type="primary" size="small" @click="openCreate()">新建角色</AButton>
|
||||
</ASpace>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<ASpin :loading="loading" style="display: block">
|
||||
<div class="role-grid">
|
||||
<!-- 超级管理员:由账号属性判定,不入角色表,因此不可配置 -->
|
||||
<ACard class="role-card role-card--super" :bordered="true">
|
||||
<div class="role-card__head">
|
||||
<div>
|
||||
<div class="role-card__name">超级管理员</div>
|
||||
<div class="role-card__code">super_admin</div>
|
||||
</div>
|
||||
<ATag color="red" size="small">系统内置</ATag>
|
||||
</div>
|
||||
<div class="role-card__desc">拥有全部权限,由账号属性直接判定,不通过角色授予</div>
|
||||
<div class="role-card__meta">
|
||||
<span>权限:全部</span>
|
||||
</div>
|
||||
<div class="role-card__actions">
|
||||
<span class="role-card__hint">不可配置</span>
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
<ACard v-for="row in roles" :key="row.id" class="role-card" :bordered="true">
|
||||
<div class="role-card__head">
|
||||
<div>
|
||||
<div class="role-card__name">{{ row.name }}</div>
|
||||
<div class="role-card__code">{{ row.code }}</div>
|
||||
</div>
|
||||
<ASpace size="mini">
|
||||
<ATag v-if="row.is_builtin" color="arcoblue" size="small">预置</ATag>
|
||||
<ATag v-if="!row.is_enabled" color="gray" size="small">已禁用</ATag>
|
||||
</ASpace>
|
||||
</div>
|
||||
<div class="role-card__desc">{{ row.description || '—' }}</div>
|
||||
<div class="role-card__meta">
|
||||
<span>{{ row.user_count }} 个账号</span>
|
||||
<span>{{ row.perm_count }} 项权限</span>
|
||||
</div>
|
||||
<div class="role-card__actions">
|
||||
<ASpace size="mini" wrap>
|
||||
<AButton size="mini" @click="openEdit(row)">
|
||||
{{ canEdit ? '配置权限' : '查看权限' }}
|
||||
</AButton>
|
||||
<AButton v-if="canCreate" size="mini" @click="openCreate(row)">复制</AButton>
|
||||
<AButton v-if="canEdit" size="mini" @click="toggleStatus(row)">
|
||||
{{ row.is_enabled ? '禁用' : '启用' }}
|
||||
</AButton>
|
||||
<AButton v-if="canEdit && row.is_builtin" size="mini" @click="confirmReset(row)">恢复默认</AButton>
|
||||
<AButton v-if="canDelete" size="mini" status="danger" @click="confirmDelete(row)">删除</AButton>
|
||||
</ASpace>
|
||||
</div>
|
||||
</ACard>
|
||||
<ACard :bordered="false">
|
||||
<ResponsiveTable
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 1100 }"
|
||||
:bordered="false"
|
||||
:stripe="true"
|
||||
row-key="id"
|
||||
card-title-key="name"
|
||||
card-subtitle-key="description"
|
||||
card-badge-key="is_enabled"
|
||||
:card-fields="['is_builtin', 'user_count', 'perm_count']"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="roles.length">
|
||||
<span>{{ roles.filter(r => r.is_enabled).length }} 个已启用</span>
|
||||
</TableStats>
|
||||
</div>
|
||||
</ASpin>
|
||||
</ACard>
|
||||
|
||||
<!-- 权限配置 -->
|
||||
<AModal
|
||||
@@ -361,7 +444,7 @@ const selectedCount = computed(() => selected.value.size)
|
||||
:title="isCreating ? '新建角色' : `配置角色:${form.name}`"
|
||||
:width="760"
|
||||
:ok-loading="saving"
|
||||
:ok-button-props="{ disabled: !canEdit }"
|
||||
:ok-button-props="{ disabled: !canManage }"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="save"
|
||||
@@ -382,12 +465,12 @@ const selectedCount = computed(() => selected.value.size)
|
||||
</ACol>
|
||||
<ACol :span="12">
|
||||
<AFormItem label="角色名称">
|
||||
<AInput v-model="form.name" placeholder="如 客户运营" :disabled="!canEdit" />
|
||||
<AInput v-model="form.name" placeholder="如 客户运营" :disabled="!canManage" />
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<AFormItem label="角色说明">
|
||||
<AInput v-model="form.description" placeholder="这个角色负责什么" :disabled="!canEdit" />
|
||||
<AInput v-model="form.description" placeholder="这个角色负责什么" :disabled="!canManage" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
|
||||
@@ -406,7 +489,7 @@ const selectedCount = computed(() => selected.value.size)
|
||||
<div class="tier-row__label">{{ mod.label }}</div>
|
||||
<ARadioGroup
|
||||
:model-value="currentTier(mod)"
|
||||
:disabled="!canEdit"
|
||||
:disabled="!canManage"
|
||||
@change="(v: any) => setTier(mod, v)"
|
||||
>
|
||||
<ARadio v-for="opt in mod.tiers" :key="opt.tier" :value="opt.tier">
|
||||
@@ -429,7 +512,7 @@ const selectedCount = computed(() => selected.value.size)
|
||||
v-for="perm in (mod.tiers[mod.tiers.length - 1]?.permissions || [])"
|
||||
:key="perm"
|
||||
:model-value="selected.has(perm)"
|
||||
:disabled="!canEdit"
|
||||
:disabled="!canManage"
|
||||
@change="(c: any) => togglePerm(perm, c)"
|
||||
>
|
||||
<span :class="{ 'perm-danger': isDangerous(perm) }">
|
||||
@@ -447,66 +530,28 @@ const selectedCount = computed(() => selected.value.size)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.role-card--super {
|
||||
border-color: rgb(var(--red-3));
|
||||
}
|
||||
|
||||
.role-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-card__name {
|
||||
font-size: 15px;
|
||||
.role-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.role-card__code {
|
||||
.role-code {
|
||||
margin-top: 2px;
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.role-card__desc {
|
||||
margin-top: 10px;
|
||||
min-height: 40px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-2);
|
||||
.text-muted {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.role-card__meta {
|
||||
.table-footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.role-card__actions {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
|
||||
.role-card__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
|
||||
.perm-toolbar {
|
||||
@@ -557,4 +602,9 @@ const selectedCount = computed(() => selected.value.size)
|
||||
margin-left: 2px;
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
|
||||
/* 下拉里的危险动作:颜色是唯一的视觉区分,必须标出来 */
|
||||
:deep(.doption-danger) {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import {
|
||||
Tag, Button, Space, Popconfirm, Message, Modal,
|
||||
Tag, Button, Space, Message, Modal, Dropdown, Doption,
|
||||
} from '@arco-design/web-vue'
|
||||
import type { TableColumnData, FormInstance } from '@arco-design/web-vue'
|
||||
import { IconSync } from '@arco-design/web-vue/es/icon'
|
||||
@@ -9,6 +9,7 @@ import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { isSuperAdmin } from '@/utils/permission'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -41,6 +42,10 @@ const form = reactive({
|
||||
role_ids: [] as number[],
|
||||
})
|
||||
|
||||
// 分配角色属于角色管理,仅超管可用(后端同样硬性拦截)。
|
||||
// 非超管仍可管理账号本身:创建、禁用、改密、删除。
|
||||
const canAssignRoles = computed(() => isSuperAdmin())
|
||||
|
||||
// 可分配的角色(来自 sys_admin_roles,用户可自由增删改)
|
||||
const availableRoles = ref<any[]>([])
|
||||
const roleSelectOptions = computed(() =>
|
||||
@@ -57,6 +62,8 @@ const roleAssignIds = ref<number[]>([])
|
||||
const roleAssignLoading = ref(false)
|
||||
|
||||
async function fetchRoles() {
|
||||
// 角色列表接口本身就是超管专属,非超管调用只会拿到 403
|
||||
if (!canAssignRoles.value) return
|
||||
try {
|
||||
const res: any = await request.get('/admin/roles')
|
||||
availableRoles.value = res.data?.data?.list || res.data?.list || []
|
||||
@@ -102,30 +109,17 @@ const columns: TableColumnData[] = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||
{ title: '邮箱', dataIndex: 'email', width: 200 },
|
||||
{
|
||||
title: '角色',
|
||||
// 特权标记(sys_admin_users.role),不是业务角色 —— 业务角色在下一列。
|
||||
// 与编辑表单的 label 保持一致,避免表格里出现两个「角色」列。
|
||||
title: '账号类型',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
width: 110,
|
||||
render({ record }) {
|
||||
const color = record.role === 'super_admin' ? 'red' : 'arcoblue'
|
||||
const label = record.role === 'super_admin' ? '超级管理员' : '管理员'
|
||||
const label = record.role === 'super_admin' ? '超级管理员' : '普通账号'
|
||||
return h(Tag, { color, size: 'small' }, () => label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render({ record }) {
|
||||
const locked = !!record.locked_until && new Date(record.locked_until) > new Date()
|
||||
if (locked) {
|
||||
return h(Tag, { color: 'red', size: 'small' }, () => '锁定')
|
||||
}
|
||||
const color = record.status === 'active' ? 'green' : undefined
|
||||
const label = record.status === 'active' ? '启用' : '禁用'
|
||||
return h(Tag, { color, size: 'small' }, () => label)
|
||||
},
|
||||
},
|
||||
{ title: '最后登录', dataIndex: 'last_login_at', width: 180 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
@@ -148,40 +142,80 @@ const columns: TableColumnData[] = [
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render({ record }) {
|
||||
const locked = !!record.locked_until && new Date(record.locked_until) > new Date()
|
||||
if (locked) {
|
||||
return h(Tag, { color: 'red', size: 'small' }, () => '锁定')
|
||||
}
|
||||
const color = record.status === 'active' ? 'green' : undefined
|
||||
const label = record.status === 'active' ? '启用' : '禁用'
|
||||
return h(Tag, { color, size: 'small' }, () => label)
|
||||
},
|
||||
},
|
||||
{ title: '最后登录', dataIndex: 'last_login_at', width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 330,
|
||||
width: 170,
|
||||
fixed: 'right',
|
||||
render({ record }) {
|
||||
const locked = !!record.locked_until && new Date(record.locked_until) > new Date()
|
||||
const actions = [
|
||||
h(Button, { size: 'small', type: 'primary', onClick: () => openEdit(record) }, () => '编辑'),
|
||||
h(Popconfirm, {
|
||||
content: `确定${record.status === 'active' ? '禁用' : '启用'}该用户?`,
|
||||
onOk: () => toggleStatus(record),
|
||||
}, () => h(Button, { size: 'small' }, () => record.status === 'active' ? '禁用' : '启用')),
|
||||
h(Button, { size: 'small', onClick: () => openResetPassword(record) }, () => '重置密码'),
|
||||
]
|
||||
if (record.role !== 'super_admin') {
|
||||
actions.splice(1, 0,
|
||||
h(Button, { size: 'small', onClick: () => openAssignRoles(record) }, () => '分配角色'))
|
||||
|
||||
// 只有「编辑」常驻,其余收进「更多」:这一列最多能有 6 个动作
|
||||
// (编辑/分配角色/禁用/重置密码/解锁/删除),平铺必然换行、把行高撑成两倍。
|
||||
const items = []
|
||||
if (record.role !== 'super_admin' && canAssignRoles.value) {
|
||||
items.push(h(Doption, { onClick: () => openAssignRoles(record) }, () => '分配角色'))
|
||||
}
|
||||
items.push(h(Doption, {
|
||||
onClick: () => confirmAction(
|
||||
`确定${record.status === 'active' ? '禁用' : '启用'}该用户?`,
|
||||
() => toggleStatus(record)),
|
||||
}, () => record.status === 'active' ? '禁用' : '启用'))
|
||||
items.push(h(Doption, { onClick: () => openResetPassword(record) }, () => '重置密码'))
|
||||
if (locked) {
|
||||
actions.push(h(Popconfirm, {
|
||||
content: '确定解除该用户的登录锁定?',
|
||||
onOk: () => handleUnlock(record),
|
||||
}, () => h(Button, { size: 'small', status: 'success' }, () => '解锁')))
|
||||
items.push(h(Doption, {
|
||||
onClick: () => confirmAction('确定解除该用户的登录锁定?', () => handleUnlock(record)),
|
||||
}, () => '解锁'))
|
||||
}
|
||||
actions.push(h(Popconfirm, {
|
||||
content: '确定删除该用户?此操作不可撤销。',
|
||||
onOk: () => deleteUser(record),
|
||||
}, () => h(Button, { size: 'small', status: 'danger' }, () => '删除')))
|
||||
return h(Space, { size: 4 }, () => actions)
|
||||
// 删除不可逆,在下拉里标红,避免顺手点到
|
||||
items.push(h(Doption, {
|
||||
class: 'doption-danger',
|
||||
onClick: () => confirmAction('确定删除该用户?此操作不可撤销。', () => deleteUser(record), true),
|
||||
}, () => '删除'))
|
||||
|
||||
return h(Space, { size: 4 }, () => [
|
||||
h(Button, { size: 'small', type: 'primary', onClick: () => openEdit(record) }, () => '编辑'),
|
||||
h(Dropdown, { trigger: 'click' }, {
|
||||
default: () => h(Button, { size: 'small' }, () => '更多'),
|
||||
content: () => items,
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 下拉菜单里的二次确认。
|
||||
*
|
||||
* 原先这些动作用 Popconfirm 包按钮,但 Popconfirm 依附于触发元素,
|
||||
* 放进下拉里会随菜单收起一起消失。改用 Modal.confirm,与页面其他确认框一致。
|
||||
*/
|
||||
function confirmAction(content: string, onOk: () => void, danger = false) {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
content,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okButtonProps: danger ? { status: 'danger' } : undefined,
|
||||
onOk,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -236,7 +270,7 @@ async function handleSubmit(done: () => void) {
|
||||
// 账号属性与角色关联是两个接口:前者改 sys_admin_users,后者改 sys_admin_user_roles
|
||||
const { role_ids, ...userForm } = form
|
||||
await request.put(`/admin/users/${editingId.value}`, userForm)
|
||||
if (form.role !== 'super_admin') {
|
||||
if (form.role !== 'super_admin' && canAssignRoles.value) {
|
||||
await request.put(`/admin/users/${editingId.value}/roles`, { role_ids })
|
||||
}
|
||||
Message.success('更新成功')
|
||||
@@ -413,7 +447,7 @@ const { exporting, exportFile } = useExport({
|
||||
<span v-if="form.role === 'super_admin'">超级管理员拥有全部权限,不需要也不使用角色</span>
|
||||
</template>
|
||||
</AFormItem>
|
||||
<AFormItem v-if="form.role !== 'super_admin'" field="role_ids" label="角色">
|
||||
<AFormItem v-if="form.role !== 'super_admin' && canAssignRoles" field="role_ids" label="角色">
|
||||
<ASelect
|
||||
v-model="form.role_ids"
|
||||
:options="roleSelectOptions"
|
||||
@@ -506,4 +540,9 @@ const { exporting, exportFile } = useExport({
|
||||
.role-empty-hint {
|
||||
color: rgb(var(--orange-6));
|
||||
}
|
||||
|
||||
/* 下拉里的危险动作:颜色是唯一的视觉区分,必须标出来 */
|
||||
:deep(.doption-danger) {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user