前言:
虽然用户组可以实现大部分权限管理, 但是总会有几个特例的用户
1.比如, 张三是一级管理员组的, 一级管理员是可以看到文章管理菜单的, 现在我不希望他看到
2.又比如, 王五是普通用户组的, 普通用户组是不能点击创建文章的, 但是我希望他能
为了实现这种效果, 就是强调用户级权限 > 用户组权限, 但是有几个问题要考虑
1.例如上面王五的问题, 就算给王五显示了创建文章的按钮, 但是它并没有创建文章的权限(POST,后端鉴权)
所以这个可能只能是用于减少权限, 而不是用于增加权限
2.第二个问题就是强调用户级权限 > 用户组权限,会不会产生权限的漏洞
实现步骤
一.添加两个数据表
# 1.cc_user_menu 用户级权限菜单表(从菜单表拉出一些权限用来做可配置的, 而不是全部可配置,
# 比如我只把文章管理和添加文章拉出来作为可配置的菜单权限)
CREATE TABLE `cc_user_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`menu_rule_id` int(11) DEFAULT '0' COMMENT '关联菜单',
`type` tinyint(1) DEFAULT NULL COMMENT '类型',
`remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`created_at` timestamp NULL DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户级权限';
# 2.cc_user_menu_config (用户权限菜单配置表, 用于配置用户上面菜单的效果, user_menu_id表示上面的ID)
CREATE TABLE `cc_user_menu_config` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`uid` int(11) DEFAULT '0' COMMENT '用户ID',
`user_menu_id` int(11) DEFAULT NULL COMMENT '用户级菜单ID',
`value` tinyint(1) DEFAULT '0' COMMENT '效果:-1.禁用,0.跟随用户组,1.启用',
`created_at` timestamp NULL DEFAULT NULL COMMENT '时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户级权限菜单配置';
二.用户权限菜单模块 (userMenu)
- 1.curd创建好菜单
- 2.修改列表主页 web\src\views\backend\userMenu\index.vue
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('quick Search Placeholder', { fields: t('userMenu.quick Search Fields') })"
/>
<Table ref="tableRef" />
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { ref, provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
const { t } = useI18n()
const tableRef = ref()
const optButtons = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/userMenu/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('userMenu.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('userMenu.menu_rule_id'), prop: 'menu_rule_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('userMenu.type'), prop: 'type', align: 'center', operator: '=', sortable: false, replaceValue: { } },
{ label: t('userMenu.remark'), prop: 'remark', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE', sortable: false },
{ label: t('userMenu.created_at'), prop: 'created_at', align: 'center', operator: '=', sortable: 'custom', width: 160 },
{ label: t('operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { type: null, remark: null, created_at: null },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getIndex()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'userMenu',
})
</script>
- 3.修改自带的添加表单 (主要加上树形下拉用户列表, 和类型选择)
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['add', 'edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
width="50%"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
label-position="right"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
type="remoteSelect"
prop="menu_rule_id"
label="关联菜单"
v-model="baTable.form.items!.menu_rule_id"
:placeholder="t('Click Select')"
:input-attr="{
params: { isTree: true },
field: 'title',
'remote-url': remoteUrl,
}"
/>
<FormItem
:label="t('userMenu.type')"
type="radio"
v-model="baTable.form.items!.type" prop="type"
:data="{ content: { '1': '菜单', '2': '按钮' }}"
/>
<FormItem :label="t('userMenu.remark')" type="string" v-model="baTable.form.items!.remark" prop="remark" :placeholder="t('Please input field', { field: t('userMenu.remark') })" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { ElForm, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { remoteMenuUrl } from '/@/api/backend/adminapi'
const formRef = ref<InstanceType<typeof ElForm>>()
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
created_at: [buildValidatorData({ name: 'date', title: t('userMenu.created_at') })],
})
let remoteUrl = remoteMenuUrl()
</script>
export function remoteMenuUrl() {
const adminInfo = useAdminInfo()
let url = getUrl() + '/admin/api/remoteMenuUrl'
return url;
}
- 5.后端中获取树形菜单列表 (app\admin\controller\Api.php)
public function remoteMenuUrl()
{
$where = [
['type', 'in', ['menu_dir', 'menu']],
['status', '=', '1']
];
$model = new \app\admin\model\MenuRule();
$rules = $model
->where($where)
->order('weigh desc,id asc')
->select()->toArray();
$data = $this->tree->assembleChild($rules);
$data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
$this->success('', [
'options' => $data
]);
}
<?php
namespace app\admin\controller;
use app\admin\model\MenuRule;
use app\common\controller\Backend;
class UserMenu extends Backend
{
protected $model = null;
protected $preExcludeFields = ['id'];
protected $quickSearchField = ['id'];
public function initialize()
{
parent::initialize();
$this->model = new \app\admin\model\UserMenu;
}
public function index()
{
$this->request->filter(['strip_tags', 'trim']);
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$list = $res->items();
$menuRuleModel = new MenuRule();
foreach ($list as &$item) {
$item['type'] = $this->model->type[$item['type']];
$item['menu_rule_id'] = $menuRuleModel->getFullMenuTitle($item['menu_rule_id']);
}
$this->success('', [
'list' => $list,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}
public function getFullMenuTitle($id)
{
$second = $this->where("id", $id)->value('title');
$pid = $this->where("id", $id)->value('pid');
$first = $this->where("id", $pid)->value('title');
return $first . '-'. $second;
}
<?php
namespace app\admin\model;
use think\Model;
class UserMenu extends Model
{
protected $name = 'user_menu';
protected $autoWriteTimestamp = false;
const TYPE_MENU = 1;
const TYPE_BUTTON = 2;
public $type = [
self::TYPE_MENU => '目录',
self::TYPE_BUTTON => '按钮'
];
}
三.用户权限菜单配置模块 (userMenuConfig)
- 1.添加上面的用户表
- 2.生成CURD代码
- 3.编辑自带的添加表单, 下拉选项中, 添加1.用户级菜单, 2.用户, 3.设置值
1.用户菜单配置-列表
http://localhost:1818/#/admin/userMenuConfig
web\src\views\backend\userMenuConfig\index.vue
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('quick Search Placeholder', { fields: t('userMenuConfig.quick Search Fields') })"
/>
<Table ref="tableRef" />
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { ref, provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
const { t } = useI18n()
const tableRef = ref()
const optButtons = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/userMenuConfig/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('userMenuConfig.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('userMenuConfig.uid'), prop: 'uid', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('userMenuConfig.user_menu_id'), prop: 'user_menu_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('userMenuConfig.value'), prop: 'value', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('userMenuConfig.created_at'), prop: 'created_at', align: 'center', operator: '=', sortable: 'custom', width: 160 },
{ label: t('operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { uid: 0, user_menu_id: null, value: 0, created_at: null },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getIndex()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'userMenuConfig',
})
</script>
<style scoped lang="scss"></style>
2.用户菜单配置-添加
web\src\views\backend\userMenuConfig\popupForm.vue
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['add', 'edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
width="50%"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
label-position="right"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
type="remoteSelect"
prop="uid"
label="用户"
v-model="baTable.form.items!.uid"
:placeholder="t('Click Select')"
:input-attr="{
params: { isTree: false },
field: 'title',
'remote-url': remoteUrlUserLocal,
}"
/>
<FormItem
type="remoteSelect"
prop="menu_rule_id"
label="用户级菜单"
v-model="baTable.form.items!.user_menu_id"
:placeholder="t('Click Select')"
:input-attr="{
params: { isTree: false },
field: 'title',
'remote-url': remoteUrlUserMenuLocal,
}"
/>
<FormItem
:label="方式"
type="radio"
v-model="baTable.form.items!.value"
prop="value"
:data="{ content: { '-1': '禁用','0': '跟随用户组', '1': '启用' }}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { ElForm, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { remoteUrlUser,remoteUrlUserMenu } from '/@/api/backend/adminapi'
const formRef = ref<InstanceType<typeof ElForm>>()
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
let remoteUrlUserLocal = remoteUrlUser()
let remoteUrlUserMenuLocal = remoteUrlUserMenu()
</script>
<style scoped lang="scss"></style>
3.封装URL(获取用户列表和用户菜单列表[后面改为可定义菜单列表吧])
export function remoteUrlUser() {
const adminInfo = useAdminInfo()
let url = getUrl() + '/admin/api/remoteUrlUser'
return url;
}
export function remoteUrlUserMenu() {
const adminInfo = useAdminInfo()
let url = getUrl() + '/admin/api/remoteUrlUserMenu'
return url;
}
4.编辑获取权限的方法 app\admin\controller\Index.php
<?php
declare (strict_types=1);
namespace app\admin\controller;
use app\common\facade\Token;
use ba\ClickCaptcha;
use think\facade\Config;
use think\facade\Validate;
use app\common\controller\Backend;
use app\admin\model\AdminLog;
use app\admin\model\UserMenuConfig;
class Index extends Backend
{
public function index()
{
$adminInfo = $this->auth->getInfo();
$adminInfo['super'] = $this->auth->isSuperAdmin();
unset($adminInfo['token'], $adminInfo['refreshToken']);
$menus = $this->auth->getMenus();
$menus = $this->_filterMenu($menus, $this->auth->getAdmin()->id);
if (!$menus) {
$this->error(__('No background menu, please contact super administrator!'));
}
$this->success('', [
'adminInfo' => $adminInfo,
'menus' => $menus,
'siteConfig' => [
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'cdnUrl' => full_url(),
'apiUrl' => Config::get('buildadmin.api_url'),
'upload' => get_upload_config(),
],
'terminal' => [
'installServicePort' => Config::get('terminal.install_service_port'),
'npmPackageManager' => Config::get('terminal.npm_package_manager'),
]
]);
}
public function _filterMenu($menus, $uid)
{
$userMenuConfigModel = new UserMenuConfig();
$disableList = $userMenuConfigModel->getUserDisableMenu($uid);
foreach ($menus as $key => $item) {
if (isset($item['children'])) {
$menus[$key]['children'] = $this->_filterMenu($item['children'], $uid);
}
if (in_array($item['id'], $disableList)) {
unset($menus[$key]);
}
}
return $menus;
}
}
5.可定义菜单模型(app\admin\model\UserMenuConfig.php)
<?php
namespace app\admin\model;
use think\Model;
class UserMenuConfig extends Model
{
protected $name = 'user_menu_config';
protected $autoWriteTimestamp = false;
const VALUE_DISABLE = -1;
const VALUE_GROUP = 0;
const VALUE_ABLE = 1;
public $value = [
self::VALUE_DISABLE => '禁用',
self::VALUE_GROUP => '跟随用户组',
self::VALUE_ABLE => '强制启用',
];
public function getUserDisableMenu($uid)
{
$userMenuModel = new UserMenu();
$list = $this->field(['user_menu_id'])->where('uid', $uid)
->where('value', self::VALUE_DISABLE)
->select()->toArray();
$list = array_column($list, 'user_menu_id');
$result = $userMenuModel->field('menu_rule_id')->whereIn('id', $list)->select()->toArray();
$result = array_column($result, 'menu_rule_id');
return $result;
}
}
6.增加共用的用户列表, 用户菜单列表获取
public function remoteUrlUser()
{
$model = new \app\admin\model\AdminUser();
$data = $model->getUserList();
$this->success('', [
'options' => $data
]);
}
public function remoteUrlUserMenu()
{
$model = new UserMenu();
$data = $model->getRemoteSelect();
$this->success('', [
'options' => $data
]);
}
其他.用户菜单配置模型
<?php
namespace app\admin\controller;
use app\admin\model\UserMenu;
use app\common\controller\Backend;
class UserMenuConfig extends Backend
{
protected $model = null;
protected $preExcludeFields = ['id'];
protected $quickSearchField = ['id'];
public function initialize()
{
parent::initialize();
$this->model = new \app\admin\model\UserMenuConfig;
}
public function index()
{
$this->request->filter(['strip_tags', 'trim']);
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$userModel = new \app\admin\model\AdminUser();
$query = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias);
$res = $query->where($where)
->order($order)
->paginate($limit);
$list = $res->items();
$userMenuModel = new UserMenu();
foreach ($list as &$item) {
$item['uid'] = $userModel->getNickname($item['uid']);
$item['user_menu_id'] = $userMenuModel->getMenuName($item['user_menu_id']);
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}
目前一级菜单可以隐藏了, 不过隐藏二级菜单会导致一级菜单也隐藏, 继续...