Files
role-admin/src/views/roles/index.vue
T
2026-05-26 18:01:52 +08:00

606 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules
} from "element-plus";
import {
createRole,
deleteRole,
listRolePage,
updateRole,
type Role,
type RolePayload
} from "@/api/access";
import { hasMenuAction } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
import Refresh from "~icons/ep/refresh";
import EditPen from "~icons/ep/edit-pen";
import Delete from "~icons/ep/delete";
defineOptions({
name: "RoleManagement"
});
/** 表单状态与角色 payload 对齐,id 只在编辑已有角色时存在。 */
type RoleFormState = RolePayload & {
id?: number;
};
/** 角色编码用于权限判断,限制为稳定的小写标识,避免展示名变更影响授权逻辑。 */
const codePattern = /^[a-z][a-z0-9_]*$/;
const tableLoading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const roles = ref<Role[]>([]);
const query = reactive({
keyword: "",
isSystem: undefined as boolean | undefined,
page: 1,
pageSize: 20
});
/** 角色管理列表由后端分页,这里只记录当前筛选结果的分页摘要。 */
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<RoleFormState>({
code: "",
name: "",
description: ""
});
const rules: FormRules<RoleFormState> = {
code: [
{ required: true, message: "请输入角色编码", trigger: "blur" },
{
pattern: codePattern,
message: "角色编码只能使用小写字母、数字和下划线,并以字母开头",
trigger: "blur"
},
{ max: 50, message: "角色编码不能超过 50 个字符", trigger: "blur" }
],
name: [
{ required: true, message: "请输入角色名称", trigger: "blur" },
{ max: 50, message: "角色名称不能超过 50 个字符", trigger: "blur" }
],
description: [
{ max: 255, message: "角色说明不能超过 255 个字符", trigger: "blur" }
]
};
const describedCount = computed(
() => roles.value.filter(item => Boolean(item.description)).length
);
const systemRoleCount = computed(
() => roles.value.filter(item => item.isSystem).length
);
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
const canCreateRole = computed(() => hasMenuAction("roles", "create"));
const canUpdateRole = computed(() => hasMenuAction("roles", "update"));
const canDeleteRole = computed(() => hasMenuAction("roles", "delete"));
const canOperateRole = computed(
() => canUpdateRole.value || canDeleteRole.value
);
function getErrorMessage(error: unknown, fallback: string) {
const message = (
error as { response?: { data?: { error?: { message?: string } } } }
)?.response?.data?.error?.message;
if (message) return message;
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function resetFormState() {
Object.assign(form, {
id: undefined,
code: "",
name: "",
description: ""
});
formRef.value?.clearValidate();
}
/** 空说明统一转为 null,和后端可选字段语义保持一致。 */
function buildPayload(): RolePayload {
const description = form.description?.trim();
return {
code: form.code.trim(),
name: form.name.trim(),
description: description ? description : null
};
}
/** 角色筛选、分页必须走接口,避免查询条件只在前端过滤当前缓存。 */
async function fetchRoles() {
tableLoading.value = true;
try {
const result = await listRolePage({
keyword: query.keyword.trim() || undefined,
isSystem: query.isSystem,
page: query.page,
pageSize: query.pageSize
});
roles.value = result.data.items;
pagination.total = result.data.pagination.total;
pagination.totalPages = result.data.pagination.totalPages;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载角色列表失败"));
} finally {
tableLoading.value = false;
}
}
function handleReset() {
query.keyword = "";
query.isSystem = undefined;
query.page = 1;
query.pageSize = 20;
fetchRoles();
}
function handleSearch() {
query.keyword = query.keyword.trim();
query.page = 1;
fetchRoles();
}
function handlePageChange(page: number) {
query.page = page;
fetchRoles();
}
function handleSizeChange(pageSize: number) {
query.page = 1;
query.pageSize = pageSize;
fetchRoles();
}
function openCreateDialog() {
if (!canCreateRole.value) return;
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Role) {
if (!canUpdateRole.value || row.isSystem) return;
Object.assign(form, {
id: row.id,
code: row.code,
name: row.name,
description: row.description ?? ""
});
dialogVisible.value = true;
formRef.value?.clearValidate();
}
async function submitForm() {
if (form.id ? !canUpdateRole.value : !canCreateRole.value) return;
await formRef.value?.validate();
submitLoading.value = true;
try {
const payload = buildPayload();
if (form.id) {
await updateRole(form.id, payload);
ElMessage.success("角色信息已更新");
} else {
await createRole(payload);
ElMessage.success("角色已创建");
}
dialogVisible.value = false;
fetchRoles();
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存角色失败"));
} finally {
submitLoading.value = false;
}
}
async function removeRole(row: Role) {
if (!canDeleteRole.value || row.isSystem) return;
try {
await ElMessageBox.confirm(
`删除后角色「${row.name}」无法再绑定员工,确认继续?`,
"删除角色",
{
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消"
}
);
await deleteRole(row.id);
ElMessage.success("角色已删除");
if (roles.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchRoles();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "删除角色失败"));
}
}
}
onMounted(fetchRoles);
</script>
<template>
<section class="management-page">
<div class="page-heading">
<div>
<p class="eyebrow">权限角色基础数据</p>
<h1>角色管理</h1>
</div>
<el-button
v-if="canCreateRole"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增角色
</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>总角色</span>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>当前页有说明</span>
<strong>{{ describedCount }}</strong>
</div>
<div class="summary-item">
<span>当前页系统角色</span>
<strong>{{ systemRoleCount }}</strong>
</div>
<div class="summary-item">
<span>当前结果</span>
<strong>{{ roles.length }}</strong>
</div>
</div>
<div class="toolbar">
<el-select
v-model="query.isSystem"
clearable
placeholder="全部类型"
class="toolbar-control"
@change="handleSearch"
>
<el-option label="系统内置" :value="true" />
<el-option label="自定义角色" :value="false" />
</el-select>
<el-input
v-model="query.keyword"
clearable
class="keyword-input"
placeholder="搜索编码、名称或说明"
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
<div class="toolbar-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="roles"
row-key="id"
stripe
class="management-table"
>
<el-table-column prop="name" label="角色" min-width="160">
<template #default="{ row }">
<div class="role-cell">
<strong>{{ row.name }}</strong>
<span>{{ row.code }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="260">
<template #default="{ row }">
<span>{{ row.description || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column
v-if="canOperateRole"
label="操作"
width="170"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="canUpdateRole && !row.isSystem"
link
type="primary"
:icon="EditPen"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
v-if="canDeleteRole && !row.isSystem"
link
type="danger"
:icon="Delete"
@click="removeRole(row)"
>
删除
</el-button>
<span v-if="row.isSystem" class="muted">系统内置</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-row">
<span
> {{ pagination.total }} {{ pagination.totalPages }} </span
>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
background
layout="sizes, prev, pager, next"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="management-form"
>
<div class="form-grid">
<el-form-item label="角色编码" prop="code">
<el-input
v-model="form.code"
maxlength="50"
placeholder="例如 store_manager"
/>
</el-form-item>
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
</el-form-item>
</div>
<el-form-item label="角色说明" prop="description">
<el-input
v-model="form.description"
type="textarea"
maxlength="255"
show-word-limit
:rows="4"
placeholder="请输入"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.management-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 650;
color: #111827;
letter-spacing: 0;
}
}
.eyebrow {
margin: 0;
font-size: 13px;
color: #64748b;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
strong {
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: 0;
}
}
.toolbar,
.table-shell {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 14px;
margin-bottom: 16px;
}
.keyword-input {
width: 280px;
}
.toolbar-control {
width: 180px;
}
.toolbar-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
}
.management-table {
width: 100%;
}
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
}
.role-cell {
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 12px;
color: #64748b;
}
}
.muted {
color: #94a3b8;
}
.management-form {
padding-top: 4px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (width <= 760px) {
.management-page {
padding: 12px;
}
.page-heading {
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.keyword-input,
.toolbar-control,
.toolbar-actions {
width: 100%;
margin-left: 0;
}
.toolbar-actions {
justify-content: flex-end;
}
.pagination-row {
flex-direction: column;
align-items: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>