606 lines
14 KiB
Vue
606 lines
14 KiB
Vue
<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>
|