feat: 移除mock并接入真实权限控制

This commit is contained in:
湛兮
2026-05-26 16:24:03 +08:00
parent 5003628017
commit 304589bf8b
30 changed files with 965 additions and 1037 deletions
+27 -17
View File
@@ -12,7 +12,7 @@ import {
getEmployee,
listEmployees,
listRoles,
listStores,
listStoreOptions,
updateEmployee,
updateEmployeeStatus,
type Employee,
@@ -21,7 +21,7 @@ import {
type RoleOption,
type StoreOption
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import { hasMenuAction, hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -107,7 +107,12 @@ const inactiveCount = computed(
() => employees.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
const canManageEmployees = computed(() => hasPerms("employee:manage"));
const canCreateEmployee = computed(() => hasMenuAction("employees", "create"));
const canUpdateEmployee = computed(() => hasMenuAction("employees", "update"));
const canDeleteEmployee = computed(() => hasMenuAction("employees", "delete"));
const canOperateEmployee = computed(
() => canUpdateEmployee.value || canDeleteEmployee.value
);
const canViewAllEmployees = computed(() => hasPerms("employee:view:all"));
function getErrorMessage(error: unknown, fallback: string) {
@@ -158,15 +163,17 @@ function buildPayload(): EmployeePayload {
/** 门店和角色是员工表单的基础字典,打开弹窗前必须先加载。 */
async function fetchCatalog() {
const shouldLoadStores =
canViewAllEmployees.value || canManageEmployees.value;
const shouldLoadRoles = canManageEmployees.value;
canViewAllEmployees.value ||
canCreateEmployee.value ||
canUpdateEmployee.value;
const shouldLoadRoles = canCreateEmployee.value || canUpdateEmployee.value;
if (!shouldLoadStores && !shouldLoadRoles) return;
catalogLoading.value = true;
try {
const [storeResult, roleResult] = await Promise.all([
shouldLoadStores ? listStores() : Promise.resolve({ data: [] }),
shouldLoadStores ? listStoreOptions() : Promise.resolve({ data: [] }),
shouldLoadRoles ? listRoles() : Promise.resolve({ data: [] })
]);
stores.value = storeResult.data;
@@ -227,14 +234,14 @@ function handleSizeChange(pageSize: number) {
}
function openCreateDialog() {
if (!canManageEmployees.value) return;
if (!canCreateEmployee.value) return;
resetFormState();
dialogVisible.value = true;
}
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
async function openEditDialog(row: Employee) {
if (!canManageEmployees.value) return;
if (!canUpdateEmployee.value) return;
try {
const result = await getEmployee(row.id);
const employee = result.data;
@@ -255,7 +262,7 @@ async function openEditDialog(row: Employee) {
}
async function submitForm() {
if (!canManageEmployees.value) return;
if (form.id ? !canUpdateEmployee.value : !canCreateEmployee.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -280,7 +287,7 @@ async function submitForm() {
}
async function toggleStatus(row: Employee) {
if (!canManageEmployees.value) return;
if (!canUpdateEmployee.value) return;
const nextStatus: EmployeeStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
@@ -306,7 +313,7 @@ async function toggleStatus(row: Employee) {
}
async function removeEmployee(row: Employee) {
if (!canManageEmployees.value) return;
if (!canDeleteEmployee.value) return;
try {
await ElMessageBox.confirm(
`删除后员工「${row.name}」会被软删除并停用,确认继续?`,
@@ -345,7 +352,7 @@ onMounted(async () => {
<h1>员工管理</h1>
</div>
<el-button
v-if="canManageEmployees"
v-if="canCreateEmployee"
type="primary"
:icon="Plus"
@click="openCreateDialog"
@@ -470,13 +477,14 @@ onMounted(async () => {
</template>
</el-table-column>
<el-table-column
v-if="canManageEmployees"
v-if="canOperateEmployee"
label="操作"
width="260"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="canUpdateEmployee"
link
type="primary"
:icon="EditPen"
@@ -485,6 +493,7 @@ onMounted(async () => {
编辑
</el-button>
<el-button
v-if="canUpdateEmployee"
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@@ -493,6 +502,7 @@ onMounted(async () => {
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
v-if="canDeleteEmployee"
link
type="danger"
:icon="Delete"
@@ -618,9 +628,9 @@ onMounted(async () => {
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
@@ -733,9 +743,9 @@ onMounted(async () => {
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
@@ -761,8 +771,8 @@ onMounted(async () => {
}
.page-heading {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
@@ -781,8 +791,8 @@ onMounted(async () => {
}
.pagination-row {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.form-grid {
-99
View File
@@ -1,99 +0,0 @@
<script setup lang="ts">
import { hasAuth, getAuths } from "@/router/utils";
defineOptions({
name: "PermissionButtonRouter"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ getAuths() }}</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Auth value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Auth>
<Auth :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Auth>
<Auth
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Auth>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasAuth([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-auth="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-auth="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-auth="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
-109
View File
@@ -1,109 +0,0 @@
<script setup lang="ts">
import { hasPerms } from "@/utils/auth";
import { useUserStoreHook } from "@/store/modules/user";
const { permissions } = useUserStoreHook();
defineOptions({
name: "PermissionButtonLogin"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ permissions }}</p>
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
*:*:* 代表拥有全部按钮级别权限
</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Perms value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Perms>
<Perms :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Perms>
<Perms
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Perms>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button
v-if="hasPerms(['permission:btn:edit'])"
plain
type="primary"
>
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasPerms([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-perms="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-perms="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-perms="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
-66
View File
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { initRouter } from "@/router/utils";
import { storageLocal } from "@pureadmin/utils";
import { type CSSProperties, ref, computed } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { usePermissionStoreHook } from "@/store/modules/permission";
defineOptions({
name: "PermissionPage"
});
const elStyle = computed((): CSSProperties => {
return {
width: "85vw",
justifyContent: "start"
};
});
const username = ref(useUserStoreHook()?.username);
const options = [
{
value: "admin",
label: "管理员角色"
},
{
value: "common",
label: "普通角色"
}
];
function onChange() {
useUserStoreHook()
.loginByUsername({ username: username.value, password: "admin123" })
.then(res => {
if (res.success) {
storageLocal().removeItem("async-routes");
usePermissionStoreHook().clearAllCachePage();
initRouter();
}
});
}
</script>
<template>
<div>
<p class="mb-2!">
模拟后台根据不同角色返回对应路由观察左侧菜单变化管理员角色可查看系统管理菜单普通角色不可查看系统管理菜单
</p>
<el-card shadow="never" :style="elStyle">
<template #header>
<div class="card-header">
<span>当前角色{{ username }}</span>
</div>
</template>
<el-select v-model="username" class="w-[160px]!" @change="onChange">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-card>
</div>
</template>
+533
View File
@@ -0,0 +1,533 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import {
getPermissionDefinitions,
getPermissionPolicies,
updateRolePermissions,
type PermissionDefinitionGroup,
type PermissionPolicy
} from "@/api/access";
import { useUserStoreHook } from "@/store/modules/user";
import { hasMenuAction } from "@/utils/auth";
import Check from "~icons/ep/check";
import Edit from "~icons/ep/edit";
import Refresh from "~icons/ep/refresh";
defineOptions({
name: "PermissionPolicies"
});
const tableLoading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const policies = ref<PermissionPolicy[]>([]);
const definitionGroups = ref<PermissionDefinitionGroup[]>([]);
const editingPolicy = ref<PermissionPolicy | null>(null);
const checkedPermissions = ref<string[]>([]);
const canUpdatePermissions = computed(() =>
hasMenuAction("permissions", "update")
);
const editableRoleCount = computed(
() => policies.value.filter(item => item.editable).length
);
const menuTotal = computed(() =>
policies.value.reduce((total, item) => total + item.menus.length, 0)
);
const permissionTotal = computed(() =>
policies.value.reduce(
(total, item) =>
total + item.permissions.filter(permission => permission !== "*").length,
0
)
);
const drawerTitle = computed(() =>
editingPolicy.value ? `分配权限:${editingPolicy.value.roleName}` : "分配权限"
);
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 formatActions(actions: string[]) {
const actionMap: Record<string, string> = {
view: "查看",
create: "新增",
update: "编辑",
delete: "删除"
};
return actions.map(action => actionMap[action] ?? action).join(" / ");
}
function formatPermissionTitle(code: string) {
for (const group of definitionGroups.value) {
const found = group.permissions.find(permission => permission.code === code);
if (found) return found.title;
}
return code;
}
function isGroupChecked(group: PermissionDefinitionGroup) {
const codes = group.permissions.map(permission => permission.code);
return codes.every(code => checkedPermissions.value.includes(code));
}
function isGroupIndeterminate(group: PermissionDefinitionGroup) {
const codes = group.permissions.map(permission => permission.code);
const checkedCount = codes.filter(code =>
checkedPermissions.value.includes(code)
).length;
return checkedCount > 0 && checkedCount < codes.length;
}
function toggleGroup(group: PermissionDefinitionGroup, checked: boolean) {
const nextPermissions = new Set(checkedPermissions.value);
for (const permission of group.permissions) {
if (checked) {
nextPermissions.add(permission.code);
} else {
nextPermissions.delete(permission.code);
}
}
checkedPermissions.value = [...nextPermissions];
}
function openEditor(policy: PermissionPolicy) {
if (!policy.editable || !canUpdatePermissions.value) return;
editingPolicy.value = policy;
checkedPermissions.value = policy.permissions.filter(
permission => permission !== "*"
);
drawerVisible.value = true;
}
async function fetchPermissionData() {
tableLoading.value = true;
try {
const [policyResult, definitionResult] = await Promise.all([
getPermissionPolicies(),
getPermissionDefinitions()
]);
policies.value = policyResult.data;
definitionGroups.value = definitionResult.data.groups;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载权限数据失败"));
} finally {
tableLoading.value = false;
}
}
async function savePermissions() {
if (!editingPolicy.value) return;
saving.value = true;
try {
await updateRolePermissions(
editingPolicy.value.roleId,
checkedPermissions.value
);
await fetchPermissionData();
await useUserStoreHook().loadAuthContext(true);
drawerVisible.value = false;
ElMessage.success("权限已更新");
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存权限失败"));
} finally {
saving.value = false;
}
}
onMounted(fetchPermissionData);
</script>
<template>
<section class="permission-page">
<div class="page-heading">
<div>
<p class="eyebrow">动态权限分配</p>
<h1>权限策略</h1>
</div>
<el-button :icon="Refresh" @click="fetchPermissionData">刷新</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>角色策略</span>
<strong>{{ policies.length }}</strong>
</div>
<div class="summary-item">
<span>可分配角色</span>
<strong>{{ editableRoleCount }}</strong>
</div>
<div class="summary-item">
<span>权限码</span>
<strong>{{ permissionTotal }}</strong>
</div>
<div class="summary-item">
<span>菜单授权</span>
<strong>{{ menuTotal }}</strong>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="policies"
row-key="roleCode"
stripe
class="permission-table"
>
<el-table-column label="角色" min-width="190">
<template #default="{ row }">
<div class="role-cell">
<strong>{{ row.roleName }}</strong>
<span>{{ row.roleCode }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="scope" label="作用范围" min-width="140" />
<el-table-column label="权限码" min-width="300">
<template #default="{ row }">
<div class="tag-list">
<el-tag
v-for="permission in row.permissions"
:key="permission"
effect="plain"
size="small"
>
{{ permission === "*" ? "全部权限" : formatPermissionTitle(permission) }}
</el-tag>
<span v-if="row.permissions.length === 0" class="muted-text">
未分配
</span>
</div>
</template>
</el-table-column>
<el-table-column label="菜单与动作" min-width="360">
<template #default="{ row }">
<div class="menu-list">
<div v-for="menu in row.menus" :key="menu.key" class="menu-item">
<strong>{{ menu.title }}</strong>
<span>{{ formatActions(menu.actions) }}</span>
</div>
<span v-if="row.menus.length === 0" class="muted-text">
无后台菜单
</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right">
<template #default="{ row }">
<el-tooltip
:content="row.editable ? '分配权限' : '系统最高权限不可编辑'"
placement="top"
>
<span>
<el-button
:icon="Edit"
:disabled="!row.editable || !canUpdatePermissions"
text
type="primary"
@click="openEditor(row)"
>
分配
</el-button>
</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<el-drawer
v-model="drawerVisible"
:title="drawerTitle"
size="520px"
destroy-on-close
>
<div class="drawer-body">
<section
v-for="group in definitionGroups"
:key="group.key"
class="permission-group"
>
<div class="group-heading">
<strong>{{ group.title }}</strong>
<el-checkbox
:model-value="isGroupChecked(group)"
:indeterminate="isGroupIndeterminate(group)"
@change="value => toggleGroup(group, Boolean(value))"
>
全选
</el-checkbox>
</div>
<div class="permission-options">
<el-checkbox-group v-model="checkedPermissions">
<el-checkbox
v-for="permission in group.permissions"
:key="permission.code"
:label="permission.code"
border
>
<span class="permission-option">
<strong>{{ permission.title }}</strong>
<small>{{ permission.code }}</small>
</span>
</el-checkbox>
</el-checkbox-group>
</div>
</section>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button
:icon="Check"
:loading="saving"
type="primary"
@click="savePermissions"
>
保存
</el-button>
</div>
</template>
</el-drawer>
</section>
</template>
<style scoped lang="scss">
.permission-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;
}
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.permission-table {
width: 100%;
}
.role-cell {
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 12px;
color: #64748b;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.menu-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-item {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 13px;
color: #64748b;
}
}
.muted-text {
font-size: 13px;
color: #94a3b8;
}
.drawer-body {
padding-right: 4px;
}
.permission-group {
padding: 14px 0;
border-bottom: 1px solid #e5e7eb;
}
.permission-group:first-child {
padding-top: 0;
}
.permission-group:last-child {
border-bottom: 0;
}
.group-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
strong {
font-size: 15px;
color: #111827;
}
}
.permission-options {
:deep(.el-checkbox-group) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
:deep(.el-checkbox.is-bordered) {
height: auto;
margin-right: 0;
padding: 10px 12px;
}
:deep(.el-checkbox__label) {
min-width: 0;
}
}
.permission-option {
display: inline-flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 14px;
font-weight: 650;
color: #111827;
}
small {
overflow: hidden;
font-size: 12px;
color: #64748b;
text-overflow: ellipsis;
}
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 760px) {
.permission-page {
padding: 12px;
}
.page-heading {
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-options :deep(.el-checkbox-group) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.menu-item {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+107 -37
View File
@@ -9,12 +9,12 @@ import {
import {
createRole,
deleteRole,
listRoles,
listRolePage,
updateRole,
type Role,
type RolePayload
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import { hasMenuAction } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -40,7 +40,16 @@ const formRef = ref<FormInstance>();
const roles = ref<Role[]>([]);
const query = reactive({
keyword: ""
keyword: "",
isSystem: undefined as boolean | undefined,
page: 1,
pageSize: 20
});
/** 角色管理列表由后端分页,这里只记录当前筛选结果的分页摘要。 */
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<RoleFormState>({
@@ -72,26 +81,15 @@ const describedCount = computed(
() => roles.value.filter(item => Boolean(item.description)).length
);
const systemRoleCount = computed(
() => roles.value.filter(item => item.code === "admin").length
() => roles.value.filter(item => item.isSystem).length
);
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
const canManageRoles = computed(() => hasPerms("role:manage"));
function applyRoleQuery(items: Role[]) {
const keyword = query.keyword.trim().toLowerCase();
if (keyword.length === 0) {
return items;
}
return items.filter(role => {
return (
role.code.toLowerCase().includes(keyword) ||
role.name.toLowerCase().includes(keyword) ||
(role.description ?? "").toLowerCase().includes(keyword)
);
});
}
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 = (
@@ -134,12 +132,19 @@ function buildPayload(): RolePayload {
};
}
/** 角色查询必须走接口,避免搜索条件只在前端过滤当前缓存。 */
/** 角色筛选、分页必须走接口,避免查询条件只在前端过滤当前缓存。 */
async function fetchRoles() {
tableLoading.value = true;
try {
const result = await listRoles();
roles.value = applyRoleQuery(result.data);
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 {
@@ -149,22 +154,37 @@ async function fetchRoles() {
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 (!canManageRoles.value) return;
if (!canCreateRole.value) return;
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
if (!canUpdateRole.value || row.isSystem) return;
Object.assign(form, {
id: row.id,
code: row.code,
@@ -176,7 +196,7 @@ function openEditDialog(row: Role) {
}
async function submitForm() {
if (!canManageRoles.value) return;
if (form.id ? !canUpdateRole.value : !canCreateRole.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -201,7 +221,7 @@ async function submitForm() {
}
async function removeRole(row: Role) {
if (!canManageRoles.value || row.isSystem) return;
if (!canDeleteRole.value || row.isSystem) return;
try {
await ElMessageBox.confirm(
`删除后角色「${row.name}」无法再绑定员工,确认继续?`,
@@ -214,6 +234,10 @@ async function removeRole(row: Role) {
);
await deleteRole(row.id);
ElMessage.success("角色已删除");
if (roles.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchRoles();
} catch (error) {
if (error !== "cancel") {
@@ -233,7 +257,7 @@ onMounted(fetchRoles);
<h1>角色管理</h1>
</div>
<el-button
v-if="canManageRoles"
v-if="canCreateRole"
type="primary"
:icon="Plus"
@click="openCreateDialog"
@@ -245,14 +269,14 @@ onMounted(fetchRoles);
<div class="summary-strip">
<div class="summary-item">
<span>总角色</span>
<strong>{{ roles.length }}</strong>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>已配置说明</span>
<span>当前页有说明</span>
<strong>{{ describedCount }}</strong>
</div>
<div class="summary-item">
<span>系统角色</span>
<span>当前页系统角色</span>
<strong>{{ systemRoleCount }}</strong>
</div>
<div class="summary-item">
@@ -262,6 +286,16 @@ onMounted(fetchRoles);
</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
@@ -305,14 +339,14 @@ onMounted(fetchRoles);
</template>
</el-table-column>
<el-table-column
v-if="canManageRoles"
v-if="canOperateRole"
label="操作"
width="170"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="!row.isSystem"
v-if="canUpdateRole && !row.isSystem"
link
type="primary"
:icon="EditPen"
@@ -321,7 +355,7 @@ onMounted(fetchRoles);
编辑
</el-button>
<el-button
v-if="!row.isSystem"
v-if="canDeleteRole && !row.isSystem"
link
type="danger"
:icon="Delete"
@@ -333,6 +367,22 @@ onMounted(fetchRoles);
</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
@@ -391,9 +441,9 @@ onMounted(fetchRoles);
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
@@ -459,6 +509,10 @@ onMounted(fetchRoles);
width: 280px;
}
.toolbar-control {
width: 180px;
}
.toolbar-actions {
display: flex;
gap: 8px;
@@ -474,6 +528,16 @@ onMounted(fetchRoles);
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;
@@ -510,8 +574,8 @@ onMounted(fetchRoles);
}
.page-heading {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
@@ -519,6 +583,7 @@ onMounted(fetchRoles);
}
.keyword-input,
.toolbar-control,
.toolbar-actions {
width: 100%;
margin-left: 0;
@@ -528,6 +593,11 @@ onMounted(fetchRoles);
justify-content: flex-end;
}
.pagination-row {
flex-direction: column;
align-items: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}
+88 -34
View File
@@ -15,7 +15,7 @@ import {
type StorePayload,
type StoreStatus
} from "@/api/access";
import { hasPerms } from "@/utils/auth";
import { hasMenuAction } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
@@ -42,7 +42,15 @@ const stores = ref<Store[]>([]);
const query = reactive({
status: undefined as StoreStatus | undefined,
keyword: ""
keyword: "",
page: 1,
pageSize: 20
});
/** 门店管理列表由后端分页,这里只记录当前筛选结果的分页摘要。 */
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<StoreFormState>({
@@ -71,23 +79,12 @@ const inactiveCount = computed(
() => stores.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
const canManageStores = computed(() => hasPerms("store:manage"));
function applyStoreQuery(items: Store[]) {
const keyword = query.keyword.trim().toLowerCase();
return items.filter(store => {
const matchedStatus =
query.status === undefined || store.status === query.status;
const matchedKeyword =
keyword.length === 0 ||
store.name.toLowerCase().includes(keyword) ||
(store.address ?? "").toLowerCase().includes(keyword) ||
(store.phone ?? "").toLowerCase().includes(keyword);
return matchedStatus && matchedKeyword;
});
}
const canCreateStore = computed(() => hasMenuAction("stores", "create"));
const canUpdateStore = computed(() => hasMenuAction("stores", "update"));
const canDeleteStore = computed(() => hasMenuAction("stores", "delete"));
const canOperateStore = computed(
() => canUpdateStore.value || canDeleteStore.value
);
function getErrorMessage(error: unknown, fallback: string) {
const message = (
@@ -133,14 +130,19 @@ function buildPayload(): StorePayload {
};
}
/** 门店筛选必须走接口,避免查询条件只停留在当前页面内存里。 */
/** 门店筛选、分页必须走接口,避免查询条件只停留在当前页面内存里。 */
async function fetchStores() {
tableLoading.value = true;
try {
const result = await listStores({
includeInactive: true
status: query.status,
keyword: query.keyword.trim() || undefined,
page: query.page,
pageSize: query.pageSize
});
stores.value = applyStoreQuery(result.data);
stores.value = result.data.items;
pagination.total = result.data.pagination.total;
pagination.totalPages = result.data.pagination.totalPages;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店列表失败"));
} finally {
@@ -151,22 +153,36 @@ async function fetchStores() {
function handleReset() {
query.status = undefined;
query.keyword = "";
query.page = 1;
query.pageSize = 20;
fetchStores();
}
function handleSearch() {
query.keyword = query.keyword.trim();
query.page = 1;
fetchStores();
}
function handlePageChange(page: number) {
query.page = page;
fetchStores();
}
function handleSizeChange(pageSize: number) {
query.page = 1;
query.pageSize = pageSize;
fetchStores();
}
function openCreateDialog() {
if (!canManageStores.value) return;
if (!canCreateStore.value) return;
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Store) {
if (!canManageStores.value) return;
if (!canUpdateStore.value) return;
Object.assign(form, {
id: row.id,
name: row.name,
@@ -179,7 +195,7 @@ function openEditDialog(row: Store) {
}
async function submitForm() {
if (!canManageStores.value) return;
if (form.id ? !canUpdateStore.value : !canCreateStore.value) return;
await formRef.value?.validate();
submitLoading.value = true;
@@ -204,7 +220,7 @@ async function submitForm() {
}
async function toggleStatus(row: Store) {
if (!canManageStores.value) return;
if (!canUpdateStore.value) return;
const nextStatus: StoreStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
@@ -230,7 +246,7 @@ async function toggleStatus(row: Store) {
}
async function removeStore(row: Store) {
if (!canManageStores.value) return;
if (!canDeleteStore.value) return;
try {
await ElMessageBox.confirm(
`删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`,
@@ -243,6 +259,10 @@ async function removeStore(row: Store) {
);
await deleteStore(row.id);
ElMessage.success("门店已删除");
if (stores.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchStores();
} catch (error) {
if (error !== "cancel") {
@@ -262,7 +282,7 @@ onMounted(fetchStores);
<h1>门店管理</h1>
</div>
<el-button
v-if="canManageStores"
v-if="canCreateStore"
type="primary"
:icon="Plus"
@click="openCreateDialog"
@@ -274,14 +294,14 @@ onMounted(fetchStores);
<div class="summary-strip">
<div class="summary-item">
<span>总门店</span>
<strong>{{ stores.length }}</strong>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>启用门店</span>
<span>当前页启用</span>
<strong>{{ activeCount }}</strong>
</div>
<div class="summary-item">
<span>停用门店</span>
<span>当前页停用</span>
<strong>{{ inactiveCount }}</strong>
</div>
<div class="summary-item">
@@ -356,13 +376,14 @@ onMounted(fetchStores);
</template>
</el-table-column>
<el-table-column
v-if="canManageStores"
v-if="canOperateStore"
label="操作"
width="260"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="canUpdateStore"
link
type="primary"
:icon="EditPen"
@@ -371,6 +392,7 @@ onMounted(fetchStores);
编辑
</el-button>
<el-button
v-if="canUpdateStore"
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@@ -379,6 +401,7 @@ onMounted(fetchStores);
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
v-if="canDeleteStore"
link
type="danger"
:icon="Delete"
@@ -389,6 +412,22 @@ onMounted(fetchStores);
</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
@@ -453,9 +492,9 @@ onMounted(fetchStores);
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
@@ -540,6 +579,16 @@ onMounted(fetchStores);
width: 100%;
}
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
}
.primary-text {
font-weight: 650;
color: #111827;
@@ -561,8 +610,8 @@ onMounted(fetchStores);
}
.page-heading {
align-items: flex-start;
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
@@ -580,6 +629,11 @@ onMounted(fetchStores);
justify-content: flex-end;
}
.pagination-row {
flex-direction: column;
align-items: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}