feat: 新增角色管理模块,优化API与交互体验
Some checks are pending
Release pipeline / publish (push) Waiting to run
Release pipeline / Always run job (push) Waiting to run

1.  新增角色管理后台页面、路由与国际化文案
2.  重构API请求错误处理逻辑,统一拦截业务与HTTP错误
3.  新增确认弹窗组合式函数,区分取消与请求错误场景
4.  完善表格按钮权限与显示控制逻辑
5.  更新API参数规范与文档说明
6.  修复部分页面分页数据解析问题
This commit is contained in:
sheng
2026-05-28 19:16:05 +08:00
parent ba43de8f4b
commit a61036e5dc
14 changed files with 999 additions and 45 deletions

View File

@@ -17,7 +17,8 @@
7. [路由配置](#7-路由配置)
8. [API 文件写法](#8-api-文件写法)
9. [旧代码迁移对照](#9-旧代码迁移对照)
10. [常见问题排查](#10-常见问题排查)
10. [接口请求错误处理规范](#10-接口请求错误处理规范)
11. [常见问题排查](#11-常见问题排查)
---
@@ -963,7 +964,215 @@ export function deleteFactoryArea (data) {
---
## 10. 常见问题排查
## 10. 接口请求错误处理规范
> 错误处理采用 **三层架构**:拦截器全局兜底 → confirmMixin 区分取消/错误 → 页面标准模式。
### 10.1 错误处理总流程
```
┌─────────────────────┐
│ 页面发起 API 请求 │
└──────────┬──────────┘
┌──────────▼──────────┐
│ 是否需 confirm ? │
└─────┬────────┬──────┘
│ 否 │ 是
│ │
│ ┌────▼───────────┐
│ │ $confirmAction() │
│ │ (confirmMixin) │
│ └────┬──────┬─────┘
│ 取消 │ │ 确认
│ ┌────▼──┐ │
│ │return │ │
│ │ true │ │
│ └───────┘ │
│ │
┌──────────▼────────────────▼──┐
│ axios 请求 │
└──────────┬───────────────────┘
┌───────────▼────────────┐
│ HTTP 状态码是什么? │
└──┬──────────────┬──────┘
200 │ │ 非 200
│ │
┌───────────▼──────┐ ┌───▼─────────────┐
│ response.data. │ │ HTTP 拦截器 │
│ code 是什么? │ │ error.message = │
└──┬───────────┬───┘ │ '未授权/超时/...' │
0 │ │≠0 └───┬──────────────┘
│ │ │
┌────▼──┐ ┌─────▼──────┐ │
│resolve│ │handleError()│◄─┘
│ data │ │Message.error│
└───────┘ └─────┬──────┘
┌────▼────┐
│ throw │
│ error │
└────┬────┘
┌─────────▼─────────┐
│ 页面层 catch 到了吗?│
└────┬──────────┬───┘
没写 │ │ 写了
│ │
┌─────────▼──┐ ┌────▼─────────────┐
│ 什么都不发生 │ │ finally 关锁 │
│(依赖拦截器 │ │ loading/submitting│
│ Message │ │ = false │
└────────────┘ └──────────────────┘
```
### 10.2 三层架构详图
```
┌─────────────────────────────────────────────────┐
│ 第一层:全局拦截器 │
│ src/api/_service.js │
│ │
│ 拦截所有 axios 响应,统一处理两类错误: │
│ │
│ HTTP 错误 (4xx/5xx) │
│ ├─ 400 → error.message = '请求错误' │
│ ├─ 401 → error.message = '未授权,请登录' │
│ ├─ 403 → error.message = '拒绝访问' │
│ ├─ 404 → error.message = '请求地址出错' │
│ ├─ 408 → error.message = '请求超时' │
│ ├─ 500 → error.message = '服务器内部错误' │
│ ├─ 502 → error.message = '网关错误' │
│ ├─ ... 其它状态码 │
│ │ │
│ └─ handleError(error) │
│ ├─ store.dispatch('d2admin/log/push', ...) │
│ ├─ console.log(error) (dev only) │
│ └─ Message.error(error.message) ← 弹红色提示 │
│ │
│ 业务错误 (code ≠ 0, 如 code=500 "参数错误") │
│ ├─ 取出 response.data.msg 或 '请求失败' │
│ ├─ const err = new Error(`${msg}: ${url}`) │
│ ├─ handleError(err) │
│ └─ throw err ← 继续向上抛给页面层 │
└──────────────────────┬──────────────────────────┘
┌──────────────────────▼──────────────────────────┐
│ 第二层confirmMixin │
│ src/composables/useConfirmHandle.js │
│ │
│ $confirmAction(confirmOpts, action) │
│ ├─ 第一层 try/catch │
│ │ await this.$confirm(...) │
│ │ ├─ 用户点"确定" → 继续 │
│ │ └─ 用户点"取消" → catch → return true │
│ │ │
│ └─ 第二层 try/catch │
│ await action() ← 执行 API 调用 │
│ ├─ 成功 → return false │
│ └─ 失败 → catch → return false │
│ (拦截器已弹出 Message此处静默吞掉
└──────────────────────┬──────────────────────────┘
┌──────────────────────▼──────────────────────────┐
│ 第三层:页面标准模式 │
│ │
│ 场景A — 查询 (fetchData) │
│ ┌─────────────────────────────────────────────┐ │
│ │ async fetchData () { │ │
│ │ this.loading = true │ │
│ │ try { │ │
│ │ const res = await getList(...) │ │
│ │ this.tableData = res.data │ │
│ │ } finally { this.loading = false } │ │
│ │ } │ │
│ └─────────────────────────────────────────────┘ │
│ • try 内无 catch拦截器已弹错误提示 │
│ • finally 始终执行loading 无论成败都关闭 │
│ │
│ 场景B — 提交 (onDialogSubmit) │
│ ┌─────────────────────────────────────────────┐ │
│ │ async onDialogSubmit () { │ │
│ │ this.submitting = true │ │
│ │ try { │ │
│ │ await createApi(this.formData) │ │
│ │ this.$message.success(...) ← 成功才执行 │ │
│ │ this.dialogVisible = false │ │
│ │ this.fetchData() │ │
│ │ } finally { this.submitting = false } │ │
│ │ } │ │
│ └─────────────────────────────────────────────┘ │
│ • 出错时弹窗不关,用户可修改后重试 │
│ │
│ 场景C — 删除 (handleDelete) │
│ ┌─────────────────────────────────────────────┐ │
│ │ async handleDelete (row) { │ │
│ │ const cancelled = await this.$confirmAction(│ │
│ │ { message: this.key('confirm_delete'), │ │
│ │ title: this.key('tip') }, │ │
│ │ () => deleteApi({ id: row.id }) │ │
│ │ ) │ │
│ │ if (cancelled) return ← 用户取消,静默 │ │
│ │ this.$message.success(...) ← 成功才执行 │ │
│ │ this.fetchData() │ │
│ │ } │ │
│ └─────────────────────────────────────────────┘ │
│ • $confirmAction 内已处理错误 Message不额外 catch│
│ • 没有 try/catch 包裹 → 代码更简洁直观 │
└─────────────────────────────────────────────────┘
```
### 10.3 关键原则
| 原则 | 说明 |
|------|------|
| **拦截器兜底** | 所有 API 错误HTTP + 业务)统一在 `_service.js` 中弹出 `Message.error` |
| **finally 关锁** | `loading` / `submitting``try { ... } finally { lock = false }`,不论成败都关闭 |
| **只用 catch 不动** | 成功后的 `$message.success` / `dialogVisible = false` / `fetchData` 不放在 `finally`,只有成功才执行 |
| **$confirmAction 包装** | 删除/批量操作类的 confirm + API 用 `$confirmAction()` 区分取消和错误 |
| **不吞业务错误** | `fetchData` / `onDialogSubmit` 的 try 只写 `finally`,不写 `catch`,错误由拦截器处理 |
### 10.4 confirmMixin 使用
页面中引入:
```js
import { confirmMixin } from '@/composables/useConfirmHandle'
export default {
mixins: [
i18nMixin('page.xxx.xxx'),
confirmMixin // ← 新增
]
}
```
API
```js
// $confirmAction(confirmOpts, actionFn) → Promise<boolean>
// 返回 true = 用户点了取消
// 返回 false = action 已执行完成(无论成功失败)
const cancelled = await this.$confirmAction(
{
message: this.key('confirm_delete'), // confirm 正文
title: this.key('tip'), // confirm 标题
// type: 'warning', // 可选,默认 'warning'
// confirmButtonText / cancelButtonText 可选,默认用 page.common 的
},
() => deleteApi({ id: row.id }) // 确认后执行的异步函数
)
if (cancelled) return
// 以下只在成功时执行
this.$message.success(...)
this.fetchData()
```
---
## 11. 常见问题排查
### Q1弹框打开后不显示内容

View File

@@ -41,6 +41,18 @@
```
- 后续统一修改路由后再批量替换
#### 4.1 API 参数名必须对齐旧项目(重要)
- **API 函数入参的 key 名称必须与旧项目保持一致**,不能自行发明参数名
- 例如:旧项目角色菜单接口参数是 `role_id`,不能写成 `id`
```js
// ❌ 错误 — 自行发明参数名
getRoleMenu({ id: this.roleId })
// ✅ 正确 — 与旧项目参数名一致
getRoleMenu({ role_id: this.roleId })
```
- **迁移 API 时必须仔细阅读旧项目接口代码**,逐个核对每个接口的入参 key 名称
- 如果后端报「xxx 不能为空」,首先检查参数 key 是否与旧项目一致
#### 5. 旧 key → 新 key 映射
如果用户提供的是旧页面代码,需要根据对照表做 key 映射。已知映射如下(持续补充):

View File

@@ -80,14 +80,19 @@ function createService () {
}
// 有 code 判断为项目接口请求
let errorMessage = ''
switch (response.data.code) {
// 返回响应内容
case 0: return response.data.data
// 例如在 code 401 情况下退回到登录页面
case 401: throw new Error('请重新登录')
// 根据需要添加其它判断
default: throw new Error(`${response.data.msg}: ${response.config.url}`)
case 401:
errorMessage = '请重新登录'
break
default:
errorMessage = response.data.msg || '请求失败'
break
}
const businessError = new Error(`${errorMessage}: ${response.config.url}`)
handleError(businessError)
throw businessError
},
error => {
const status = get(error, 'response.status')

View File

@@ -7,6 +7,8 @@ export function getMenuAll (data) {
url: urls + 'all',
method: 'get',
params: {
method: 'system_settings_menu_configuration_menu_all',
platform: 'background',
...data
}
})

View File

@@ -0,0 +1,75 @@
import { request } from '@/api/_service'
const BASE = 'system_settings/user_management/role/'
function apiParams (method, data = {}) {
return {
method: `system_settings_user_management_role_${method}`,
platform: 'background',
...data
}
}
export function getRoleList (data) {
return request({
url: BASE + 'list',
method: 'get',
params: apiParams('list', data)
})
}
export function createRole (data) {
return request({
url: BASE + 'create',
method: 'post',
data: apiParams('create', data)
})
}
export function editRole (data) {
return request({
url: BASE + 'edit',
method: 'put',
data: apiParams('edit', data)
})
}
export function deleteRole (data) {
return request({
url: BASE + 'delete',
method: 'delete',
data: apiParams('delete', data)
})
}
export function updateRoleStatus (data) {
return request({
url: BASE + 'update_status',
method: 'put',
data: apiParams('update_status', data)
})
}
export function giveRoleMenu (data) {
return request({
url: BASE + 'give',
method: 'put',
data: {
method: 'system_settings_user_management_give_role_menu',
platform: 'background',
...data
}
})
}
export function getRoleMenu (data) {
return request({
url: BASE + 'menu',
method: 'get',
params: {
method: 'system_settings_user_management_role_menu',
platform: 'background',
...data
}
})
}

View File

@@ -105,17 +105,19 @@
v-bind="colAttrs(col)"
>
<template #default="{ row, $index }">
<span
v-for="btn in visibleRowButtons"
:key="btn.key"
:style="btn.cssStyle"
:class="{ 'action-btn--danger': btn.color === 'danger' }"
class="action-btn"
@click="btn.onClick(row, $index)"
>
<i v-if="btn.icon" :class="btn.icon" />
{{ $t(btn.label) }}
</span>
<template v-for="btn in visibleRowButtons">
<span
v-if="btn.visible(row)"
:key="btn.key"
:style="btn.cssStyle"
:class="{ 'action-btn--danger': btn.color === 'danger' }"
class="action-btn"
@click="btn.onClick(row, $index)"
>
<i v-if="btn.icon" :class="btn.icon" />
{{ $t(btn.label) }}
</span>
</template>
</template>
</el-table-column>
<!-- 4. 自定义插槽列列内容或表头可由父组件自定义 -->

View File

@@ -0,0 +1,60 @@
/**
* $confirm + API 调用的标准包装 mixin
*
* 解决问题:$confirm 取消也会 reject和 API 失败混在同一个 catch 里无法区分
*
* 调用方无需 try/catch只需判断返回值
* - 返回 true → 用户取消,静默返回
* - 返回 false → action 已执行(成功或失败),拦截器已处理 Message
*
* @example
* import { confirmMixin } from '@/composables/useConfirmHandle'
* export default {
* mixins: [confirmMixin, i18nMixin('page.xxx')],
* methods: {
* async handleDelete (row) {
* const cancelled = await this.$confirmAction(
* { message: this.key('confirm_delete'), title: this.key('tip') },
* () => deleteApi({ id: row.id })
* )
* if (cancelled) return
* this.$message.success(this.$t(this.key('operation_success')))
* this.fetchData()
* }
* }
* }
*/
export const confirmMixin = {
methods: {
/**
* @param {Object} confirmOpts - { message, title, ...$confirm 其余参数 }
* @param {Function} action - 确认后执行的 async 函数
* @returns {Promise<boolean>} true = 已取消, false = 已执行
*/
async $confirmAction (confirmOpts, action) {
try {
await this.$confirm(
confirmOpts.message ? this.$t(confirmOpts.message) : '',
confirmOpts.title ? this.$t(confirmOpts.title) : '',
{
confirmButtonText: this.$t(confirmOpts.confirmButtonText || 'page.common.confirm'),
cancelButtonText: this.$t(confirmOpts.cancelButtonText || 'page.common.cancel'),
type: confirmOpts.type || 'warning',
closeOnClickModal: confirmOpts.closeOnClickModal !== false
}
)
} catch {
return true
}
try {
await action()
} catch {
// 拦截器已弹出 Message
}
return false
}
}
}
export default confirmMixin

View File

@@ -39,7 +39,8 @@ export function useTableButtons (options = {}, permissionCheck) {
auth: btn.auth,
confirm: btn.confirm || false,
onClick: btn.onClick,
hasPermission: btn.auth ? check(btn.auth) : true
hasPermission: btn.auth ? check(btn.auth) : true,
visible: btn.visible || (() => true)
}))
return { toolbarButtons, rowButtons }

View File

@@ -4,7 +4,10 @@
"page": {
"common": {
"help": "Help",
"use": "Apply"
"use": "Apply",
"confirm": "Confirm",
"cancel": "Cancel",
"tip": "Tip"
},
"demo": {
"playground": {
@@ -45,6 +48,38 @@
}
}
},
"system_administration": {
"user_management": {
"role": {
"search": "Search",
"reset": "Reset",
"index": "No.",
"name": "Role Name",
"status": "Status",
"enabled": "Enabled",
"disabled": "Disabled",
"description": "Description",
"actions": "Actions",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"add_title": "Add Role",
"edit_title": "Edit Role",
"enter_name": "Please enter role name",
"enter_description": "Please enter description",
"select_status": "Please select status",
"select_rows_first": "Please select rows first",
"operation_success": "Operation succeeded",
"confirm_delete": "Are you sure to delete this role?",
"length_limit": "Length should be 1 to 100 characters",
"assign_permissions": "Assign Permissions",
"permission_assignment": "Permission Assignment",
"confirm": "Confirm",
"cancel": "Cancel",
"tip": "Tip"
}
}
},
"system": {
"login": {
"time_is_most_precious": "Time is the most precious of all wealth",

View File

@@ -4,7 +4,10 @@
"page": {
"common": {
"help": "帮 助",
"use": "使用"
"use": "使用",
"confirm": "确定",
"cancel": "取消",
"tip": "提示"
},
"demo": {
"playground": {
@@ -45,6 +48,38 @@
}
}
},
"system_administration": {
"user_management": {
"role": {
"search": "查询",
"reset": "重置",
"index": "序号",
"name": "角色名称",
"status": "状态",
"enabled": "启用",
"disabled": "禁用",
"description": "描述",
"actions": "操作",
"add": "新 增",
"edit": "编 辑",
"delete": "删 除",
"add_title": "新增角色",
"edit_title": "编辑角色",
"enter_name": "请输入角色名称",
"enter_description": "请输入描述",
"select_status": "请选择状态",
"select_rows_first": "请先勾选要操作的数据",
"operation_success": "操作成功",
"confirm_delete": "确定要删除该角色吗?",
"length_limit": "长度在 1 到 100 个字符",
"assign_permissions": "分配权限",
"permission_assignment": "权限分配",
"confirm": "确定",
"cancel": "取消",
"tip": "提示"
}
}
},
"system": {
"login": {
"time_is_most_precious": "时间是一切财富中最宝贵的财富",

View File

@@ -13,6 +13,12 @@ export default {
name: `${pre}index`,
meta: { ...meta, title: '系统设置', root: '/system_settings' },
component: _import('system/function/module-index')
},
{
path: 'user_management/role',
name: `${pre}user_management-role`,
meta: { ...meta, cache: true, title: '角色' },
component: _import('system-administration/user-management/role')
}
])('system_settings-')
}

View File

@@ -70,6 +70,7 @@
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { confirmMixin } from '@/composables/useConfirmHandle'
import {
getFactoryAreaList,
createFactoryArea,
@@ -82,7 +83,7 @@ import PageDialogForm from '@/components/page-dialog-form'
export default {
name: 'production-master-data-factory-area',
components: { PageTable, PageDialogForm },
mixins: [i18nMixin('page.production_master_data.factory_model.factory_area')],
mixins: [i18nMixin('page.production_master_data.factory_model.factory_area'), confirmMixin],
data () {
return {
loading: false,
@@ -195,8 +196,10 @@ export default {
page_no: this.pagination.current,
page_size: this.pagination.size
})
this.tableData = res.data || []
this.pagination.total = res.count || 0
const list = Array.isArray(res) ? res : (res.data || [])
const total = Array.isArray(res) ? res.length : (res.count || 0)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
@@ -261,27 +264,20 @@ export default {
this.resetForm()
},
async handleDelete (row) {
try {
await this.$confirm(
this.$t(this.key('confirm_delete')),
this.$t(this.key('tip')),
{
confirmButtonText: this.$t(this.key('confirm')),
cancelButtonText: this.$t(this.key('cancel')),
type: 'warning',
closeOnClickModal: false
}
)
await deleteFactoryArea({ id: [row.id] })
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
)
this.fetchData()
} catch (e) {
// 取消删除 / 请求失败时不处理
}
const cancelled = await this.$confirmAction(
{
message: this.key('confirm_delete'),
title: this.key('tip')
},
() => deleteFactoryArea({ id: [row.id] })
)
if (cancelled) return
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
)
this.fetchData()
}
}
}

View File

@@ -0,0 +1,151 @@
<template>
<el-drawer
:visible.sync="visibleProxy"
:title="$t(title)"
:size="width"
:close-on-click-modal="false"
direction="rtl"
@close="onClose"
>
<div class="perm-drawer__body" v-loading="treeLoading">
<el-tree
v-if="treeReady"
ref="permTree"
node-key="menu_id"
:data="treeData"
:props="{ label: 'name', children: 'children' }"
:default-expand-all="false"
:expand-on-click-node="false"
:check-strictly="true"
show-checkbox
@check="onTreeCheck"
>
<span slot-scope="{ node, data }" class="perm-drawer__tree-node">
<span :class="{ 'perm-drawer__tree-node--disabled': !data.status }">
<i v-if="data.icon" :class="`fa fa-${data.icon}`" />
<i v-else-if="data.children" :class="`el-icon-${node.expanded ? 'folder-opened' : 'folder'}`" />
<i v-else class="el-icon-document" />
{{ $t(data.name) }}
</span>
</span>
</el-tree>
</div>
<div class="perm-drawer__footer">
<el-button size="mini" @click="onCancel">
{{ $t(cancelText) }}
</el-button>
<el-button type="primary" size="mini" :loading="submitting" @click="onSubmit">
{{ $t(confirmText) }}
</el-button>
</div>
</el-drawer>
</template>
<script>
import { i18nMixin } from '@/composables/useI18n'
import util from '@/libs/util'
import { getMenuAll } from '@/api/menu'
import { giveRoleMenu, getRoleMenu } from '@/api/system-administration/role'
export default {
name: 'RolePermDrawer',
mixins: [i18nMixin('page.system_administration.user_management.role')],
props: {
visible: { type: Boolean, default: false },
roleId: { type: Number, default: 0 },
title: { type: String, default: '' },
confirmText: { type: String, default: '' },
cancelText: { type: String, default: '' },
width: { type: String, default: '360px' }
},
data () {
return {
treeLoading: false,
submitting: false,
treeReady: false,
treeData: [],
checkedMenuIds: []
}
},
computed: {
visibleProxy: {
get () { return this.visible },
set (val) { this.$emit('update:visible', val) }
}
},
watch: {
visible (val) {
if (val) { this.loadData() }
}
},
methods: {
async loadData () {
this.treeReady = false
this.treeLoading = true
try {
const [menuRes, roleMenuRes] = await Promise.all([
getMenuAll()
])
const menuData = Array.isArray(menuRes) ? menuRes : (menuRes.data || [])
const roleData = Array.isArray(roleMenuRes) ? roleMenuRes : (roleMenuRes.data || [])
this.checkedMenuIds = roleData.map(item => item.menu_id)
this.treeData = util.formatDataToTree(menuData)
this.treeReady = true
await this.$nextTick()
await new Promise(resolve => setTimeout(resolve, 50))
this.$refs.permTree.setCheckedKeys(this.checkedMenuIds)
} finally {
this.treeLoading = false
}
},
onTreeCheck () {},
onClose () {
this.treeReady = false
this.treeData = []
this.checkedMenuIds = []
},
onCancel () {
this.visibleProxy = false
},
async onSubmit () {
this.submitting = true
try {
const menuIds = this.$refs.permTree.getCheckedKeys()
await giveRoleMenu({ role_id: this.roleId, role_menu: menuIds })
this.$message.success(this.$t(this.key('operation_success')))
this.visibleProxy = false
this.$emit('saved')
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped>
.perm-drawer__body {
height: calc(100vh - 140px);
overflow-y: auto;
padding: 10px 20px;
}
.perm-drawer__footer {
position: absolute;
bottom: 0;
right: 0;
left: 0;
padding: 10px 20px;
border-top: 1px solid #e8e8e8;
background: #fff;
text-align: right;
}
.perm-drawer__tree-node {
display: flex;
align-items: center;
gap: 5px;
}
.perm-drawer__tree-node--disabled {
color: #c0c4cc;
}
</style>

View File

@@ -0,0 +1,365 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('name'))">
<el-input
v-model="search.name"
:placeholder="$t(key('enter_name'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('status'))">
<el-select
v-model="search.status"
:placeholder="$t(key('select_status'))"
clearable
style="width:150px"
>
<el-option :value="'1'" :label="$t(key('enabled'))" />
<el-option :value="'0'" :label="$t(key('disabled'))" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="onSearch">
{{ $t(key('search')) }}
</el-button>
<el-button icon="el-icon-refresh" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<page-table
ref="pageTable"
:columns="columns"
:data="tableData"
:loading="loading"
:toolbar-buttons="toolbarButtons"
:row-buttons="rowButtons"
:pagination="pagination"
:table-attrs="{ selectable: row => row.system === 0 }"
auto-height
@page-change="onPageChange"
@selection-change="onSelect"
>
<template #col-status="{ row }">
<span v-if="row.status === 1" style="color: #67c23a;">
<i class="el-icon-circle-check" />
{{ $t(key('enabled')) }}
</span>
<span v-else style="color: #909399;">
<i class="el-icon-circle-close" />
{{ $t(key('disabled')) }}
</span>
</template>
</page-table>
<page-dialog-form
ref="dialogForm"
:visible.sync="dialogVisible"
:title="dialogTitle"
width="35%"
:form-cols="formCols"
:form-data="formData"
:rules="rules"
label-width="100px"
:submitting="submitting"
:confirm-text="key('confirm')"
:cancel-text="key('cancel')"
@submit="onDialogSubmit"
@close="onDialogClose"
/>
<role-perm-drawer
:visible.sync="permVisible"
:role-id="permRole.id"
:title="key('assign_permissions')"
:confirm-text="key('confirm')"
:cancel-text="key('cancel')"
width="360px"
@saved="fetchData"
/>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { confirmMixin } from '@/composables/useConfirmHandle'
import {
getRoleList,
createRole,
editRole,
deleteRole,
updateRoleStatus
} from '@/api/system-administration/role'
import PageTable from '@/components/page-table'
import PageDialogForm from '@/components/page-dialog-form'
import RolePermDrawer from './components/PermDrawer/index.vue'
export default {
name: 'system-administration-role',
components: { PageTable, PageDialogForm, RolePermDrawer },
mixins: [i18nMixin('page.system_administration.user_management.role'), confirmMixin],
data () {
return {
loading: false,
submitting: false,
tableData: [],
selectedRows: [],
dialogVisible: false,
dialogTitle: '',
editId: '',
handleType: 'create',
search: { name: '', status: '' },
pagination: { current: 1, size: 10, total: 0 },
formData: { name: '', description: '', status: '1' },
rules: {
name: [
{ required: true, message: this.key('enter_name'), trigger: 'blur' },
{ min: 1, max: 100, message: this.key('length_limit'), trigger: 'blur' }
]
},
columns: [],
toolbarButtons: [],
rowButtons: [],
formCols: [
[
{
type: 'input',
prop: 'name',
label: this.key('name'),
placeholder: this.key('enter_name'),
clearable: true,
style: { width: '90%' }
}
],
[
{
type: 'input',
inputType: 'textarea',
prop: 'description',
autosize: { minRows: 2, maxRows: 6 },
label: this.key('description'),
placeholder: this.key('enter_description'),
clearable: true,
style: { width: '90%' }
}
],
[
{
type: 'select',
prop: 'status',
label: this.key('status'),
clearable: false,
style: { width: '90%' },
options: [
{ value: '1', label: this.$t(this.key('enabled')) },
{ value: '0', label: this.$t(this.key('disabled')) }
]
}
]
],
permVisible: false,
permRole: {}
}
},
created () {
this.columns = useTableColumns([
{ prop: 'sort', label: this.key('index'), width: 80 },
{ prop: 'name', label: this.key('name'), minWidth: 120 },
{ prop: 'status', label: this.key('status'), slot: 'status', width: 100 },
{ prop: 'description', label: this.key('description') },
{ prop: '_actions', label: this.key('actions'), width: 220, fixed: 'right' }
])
const btns = useTableButtons({
toolbar: [
{
key: 'add',
label: this.key('add'),
icon: 'el-icon-plus',
type: 'primary',
auth: '/system_settings/user_management/role/create',
onClick: this.openAdd
},
{
key: 'enable',
label: this.key('enabled'),
icon: 'el-icon-check',
type: 'success',
auth: '/system_settings/user_management/role/enable',
onClick: () => this.batchUpdateStatus(1)
},
{
key: 'disable',
label: this.key('disabled'),
icon: 'el-icon-close',
type: 'warning',
auth: '/system_settings/user_management/role/disable',
onClick: () => this.batchUpdateStatus(0)
}
],
row: [
{
key: 'edit',
label: this.key('edit'),
icon: 'el-icon-edit',
auth: '/system_settings/user_management/role/edit',
visible: row => row.system !== 1,
onClick: this.openEdit
},
{
key: 'assign',
label: this.key('assign_permissions'),
icon: 'el-icon-edit-outline',
auth: '/system_settings/user_management/role/role_give',
visible: row => row.system !== 1,
onClick: this.openPermDialog
},
{
key: 'delete',
label: this.key('delete'),
icon: 'el-icon-delete',
color: 'danger',
auth: '/system_settings/user_management/role/delete',
visible: row => row.system !== 1,
onClick: this.handleDelete
}
]
}, this.$permission)
this.toolbarButtons = btns.toolbarButtons
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
async fetchData () {
this.loading = true
try {
const res = await getRoleList({
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
})
const list = Array.isArray(res) ? res : (res.data || [])
const total = Array.isArray(res) ? res.length : (res.count || 0)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.search = { name: '', status: '' }
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
onSelect (rows) {
this.selectedRows = rows
},
resetForm () {
this.formData = { name: '', description: '', status: '1' }
this.editId = ''
},
openAdd () {
this.handleType = 'create'
this.dialogTitle = this.key('add_title')
this.$nextTick(() => {
this.$refs.dialogForm && this.$refs.dialogForm.reset()
this.resetForm()
this.dialogVisible = true
})
},
openEdit (row) {
this.handleType = 'edit'
this.dialogTitle = this.key('edit_title')
this.editId = row.id
this.formData = {
name: row.name,
description: row.description || '',
status: String(row.status)
}
this.dialogVisible = true
},
async onDialogSubmit () {
this.submitting = true
try {
if (this.handleType === 'create') {
await createRole(this.formData)
} else {
await editRole({ ...this.formData, id: this.editId })
}
this.$message.success(this.$t(this.key('operation_success')))
this.dialogVisible = false
this.fetchData()
} finally {
this.submitting = false
}
},
onDialogClose () {
this.resetForm()
},
async batchUpdateStatus (status) {
const rows = this.selectedRows.filter(row => row.system === 0)
if (rows.length === 0) {
this.$message.warning(this.$t(this.key('select_rows_first')))
return
}
try {
await updateRoleStatus({
ids: rows.map(row => row.id),
status
})
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
} catch {
// 拦截器已处理
}
},
async handleDelete (row) {
const cancelled = await this.$confirmAction(
{
message: this.key('confirm_delete'),
title: this.key('tip')
},
() => deleteRole({ id: [row.id] })
)
if (cancelled) return
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
)
this.fetchData()
},
async openPermDialog (row) {
this.permRole = row
this.permVisible = true
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
/deep/ .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
</style>