feat(role):权限管理功能

This commit is contained in:
chengqian committed 2026-09-01 20:32:35 +08:00
1 parent f129221942
commit fdd1bc7653
11 files changed
+408 -157

No files matched your search

+8
View File
@@ -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
+6
View File
@@ -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
}
+34
View File
@@ -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
+46
View File
@@ -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 已在上方短路)
+48
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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',
+4 -1
View File
@@ -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' }
+12
View File
@@ -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
+8 -3
View File
@@ -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)
)
+160 -110
View File
@@ -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>
+81 -42
View File
@@ -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>