Files
nexus/Project/fonrey/UI_DESIGN/权限管理_UI.html
2026-04-29 15:43:49 +08:00

1193 lines
67 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1366" />
<title>Fonrey 权限管理 · 静态原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
500: '#14B8A6',
600: '#0F766E',
700: '#115E59',
800: '#134E4A'
},
neutral: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
}
}
}
};
</script>
<style>
:root {
--bg-page: #F8FAFC;
--bg-card: #FFFFFF;
--bg-subtle: #F1F5F9;
--text-primary: #0F172A;
--text-secondary: #64748B;
--border: #E2E8F0;
}
body {
background: var(--bg-page);
color: var(--text-primary);
}
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
.main-tab { color: #64748B; border-bottom: 2px solid transparent; }
.main-tab.active { color: #115E59; border-bottom-color: #115E59; font-weight: 600; }
.action-link { font-size: 12px; color: #2563EB; }
.action-link:hover { text-decoration: underline; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
</style>
</head>
<body class="text-sm antialiased" x-data="permissionPage()" x-init="init()" @keydown.escape.window="closeAllOverlays()">
<header class="fixed top-0 left-0 right-0 h-14 z-30 bg-primary-800 flex items-center justify-between">
<div class="flex items-center gap-2 px-4 w-60 shrink-0">
<div class="w-7 h-7 rounded-md bg-primary-500 flex items-center justify-center text-white text-sm font-semibold">F</div>
<span class="text-base font-semibold text-white">Fonrey</span>
</div>
<nav class="flex items-center gap-1 flex-1 px-2" aria-label="主导航">
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">主页</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">房源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">客源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">营销</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">交易</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">数据</a>
<a class="px-3 py-1.5 text-sm rounded-md bg-primary-600 text-white font-medium">人事</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">系统</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">三网</a>
</nav>
<div class="flex items-center gap-1 px-4 shrink-0">
<button class="p-1.5 text-primary-200 hover:bg-primary-700 hover:text-white rounded-md" aria-label="消息">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022 23.848 23.848 0 0 0 5.454 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
</button>
<div class="flex items-center gap-2 pl-3 ml-1 border-l border-primary-700">
<div class="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center text-sm font-semibold"></div>
<span class="text-sm font-medium text-primary-100">杜利强</span>
</div>
</div>
</header>
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-neutral-200 bg-white overflow-y-auto">
<nav class="p-3 space-y-0.5">
<div class="px-2 pt-2 pb-1 text-xs font-medium text-neutral-500 uppercase tracking-wide">组织人事</div>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">组织结构</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">员工通讯录</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-primary-50 text-primary-700 font-medium">权限管理</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">系统配置</a>
</nav>
</aside>
<main class="ml-60 pt-[72px] min-h-screen px-6 py-5">
<div class="mx-auto max-w-[1760px] space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4">
<div class="flex items-start justify-between gap-4">
<div>
<nav class="flex items-center gap-1 text-xs text-neutral-500 mb-2" aria-label="面包屑">
<a href="#" class="hover:text-neutral-700">人事OA</a>
<span>/</span>
<a href="#" class="hover:text-neutral-700">组织人事</a>
<span>/</span>
<span class="text-neutral-900">权限管理</span>
</nav>
<h1 class="text-xl font-semibold text-neutral-900">权限管理</h1>
<p class="text-xs text-neutral-500 mt-1">支持角色权限配置、人员权限覆盖、批量角色分配与范围权限管理</p>
</div>
<button class="px-3 py-1.5 rounded-md border border-info-600 text-info-600 bg-white hover:bg-info-50" @click="notify('打开角色权限帮助(原型)')">角色权限帮助</button>
</div>
</section>
<section class="bg-white border border-neutral-200 rounded-lg px-4">
<nav class="flex items-center gap-6 overflow-x-auto" aria-label="权限主Tab">
<button class="main-tab py-3 whitespace-nowrap" :class="{ 'active': mainTab === 'staff' }" @click="switchTab('staff')">权限管理</button>
<button class="main-tab py-3 whitespace-nowrap" :class="{ 'active': mainTab === 'role' }" @click="switchTab('role')">角色管理</button>
</nav>
</section>
<template x-if="mainTab === 'staff'">
<section class="space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-3">
<div class="grid grid-cols-6 gap-3">
<input x-model.trim="staffFilters.keyword" type="text" placeholder="请输入姓名/工号" class="px-3 py-2 rounded-md border border-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/30" />
<select x-model="staffFilters.dept" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">员工部门(全选)</option>
<template x-for="d in departments" :key="'dept-opt-'+d">
<option :value="d" x-text="d"></option>
</template>
</select>
<select x-model.number="staffFilters.roleId" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">角色(全选)</option>
<template x-for="r in roles" :key="'staff-role-opt-'+r.id">
<option :value="r.id" x-text="r.name"></option>
</template>
</select>
<select x-model="staffFilters.title" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">职务名称(全选)</option>
<template x-for="t in titles" :key="'title-opt-'+t">
<option :value="t" x-text="t"></option>
</template>
</select>
<label class="flex items-center gap-2 px-3 py-2 rounded-md border border-neutral-200 bg-neutral-50 text-xs text-neutral-600">
<input type="checkbox" class="rounded border-neutral-300" x-model="staffFilters.onlyMismatch" />
权限与角色权限不一致
</label>
<div class="flex items-center gap-2 justify-end">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="staffPage = 1">查询</button>
<button class="px-3 py-2 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="resetStaffFilters()">清空条件</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed" :disabled="selectedStaffIds.length===0" @click="openBatchRoleModal()">批量设置角色</button>
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-50" @click="notify('导出中,请稍候(原型)')">导出</button>
<span class="text-xs text-neutral-500"><span class="tabular-nums" x-text="filteredStaff.length"></span></span>
</div>
</div>
<div class="border border-neutral-200 rounded-lg overflow-hidden">
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 w-10"><input type="checkbox" class="rounded border-neutral-300" :checked="isCurrentStaffPageAllSelected" @change="toggleCurrentStaffPage($event.target.checked)" /></th>
<th class="px-3 py-2 text-left">员工姓名</th>
<th class="px-3 py-2 text-left">工号</th>
<th class="px-3 py-2 text-left">部门</th>
<th class="px-3 py-2 text-left">职务</th>
<th class="px-3 py-2 text-left">角色</th>
<th class="px-3 py-2 text-left">管理范围</th>
<th class="px-3 py-2 text-left">状态</th>
<th class="px-3 py-2 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 bg-white">
<template x-if="pagedStaff.length===0">
<tr>
<td colspan="9" class="px-3 py-8 text-center text-neutral-400">暂无匹配人员</td>
</tr>
</template>
<template x-for="row in pagedStaff" :key="'staff-row-'+row.id">
<tr>
<td class="px-3 py-2"><input type="checkbox" class="rounded border-neutral-300" :value="row.id" :checked="selectedStaffIds.includes(row.id)" @change="toggleStaff(row.id, $event.target.checked)" /></td>
<td class="px-3 py-2" x-text="row.name"></td>
<td class="px-3 py-2 tabular-nums" x-text="row.no"></td>
<td class="px-3 py-2" x-text="row.dept"></td>
<td class="px-3 py-2" x-text="row.title"></td>
<td class="px-3 py-2" x-text="roleNameById(row.roleId)"></td>
<td class="px-3 py-2">
<span x-text="row.scopeSummary"></span>
<button class="action-link ml-1" @click="openScopeModal(row)">详情</button>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px]" :class="row.mismatch ? 'bg-warning-50 text-warning-600' : 'bg-success-50 text-success-600'" x-text="row.mismatch ? '存在个人覆盖' : '一致'"></span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<button class="action-link" @click="openStaffPermissionDrawer(row)">修改权限</button>
<button class="action-link ml-2" @click="openModifyRoleModal(row)">复制角色</button>
<button class="action-link ml-2" @click="notify('扩充部门范围流程(原型)')">扩充范围</button>
</td>
</tr>
</template>
</tbody>
</table>
<div class="px-4 py-3 border-t border-neutral-200 bg-white flex items-center justify-between">
<div class="text-xs text-neutral-500"><span class="tabular-nums" x-text="filteredStaff.length"></span></div>
<div class="flex items-center gap-2 text-xs">
<button class="px-2 py-1 border border-neutral-300 rounded disabled:opacity-40" :disabled="staffPage<=1" @click="staffPage--">上一页</button>
<span class="px-2 py-1 border border-primary-600 text-primary-600 rounded" x-text="staffPage"></span>
<button class="px-2 py-1 border border-neutral-300 rounded disabled:opacity-40" :disabled="staffPage>=staffTotalPages" @click="staffPage++">下一页</button>
<select x-model.number="staffPageSize" class="px-2 py-1 border border-neutral-300 rounded bg-white">
<option :value="20">20条/页</option>
<option :value="50">50条/页</option>
<option :value="100">100条/页</option>
</select>
<span>跳至</span>
<input x-model.number="staffJumpPage" type="number" min="1" :max="staffTotalPages" class="w-14 px-2 py-1 border border-neutral-300 rounded text-center" />
<span></span>
<button class="px-2 py-1 border border-neutral-300 rounded" @click="jumpStaffPage()">确定</button>
</div>
</div>
</div>
</section>
</section>
</template>
<template x-if="mainTab === 'role'">
<section class="space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-3">
<div class="grid grid-cols-6 gap-3">
<input x-model.trim="roleFilters.name" type="text" placeholder="请输入角色名称" class="px-3 py-2 rounded-md border border-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/30" />
<select x-model="roleFilters.category" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">角色类别(全选)</option>
<template x-for="c in roleCategories" :key="'role-cat-'+c">
<option :value="c" x-text="c"></option>
</template>
</select>
<input x-model="roleFilters.from" type="date" class="px-3 py-2 rounded-md border border-neutral-300" />
<input x-model="roleFilters.to" type="date" class="px-3 py-2 rounded-md border border-neutral-300" />
<div class="col-span-2 flex items-center gap-2 justify-end">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="rolePage=1">查询</button>
<button class="px-3 py-2 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="resetRoleFilters()">清空条件</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="openAddRoleModal()">+ 新增角色</button>
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed" :disabled="selectedRoleIds.length===0" @click="batchDeleteRoles()">批量删除角色</button>
<span class="text-xs text-neutral-500"><span class="tabular-nums" x-text="filteredRoles.length"></span></span>
</div>
</div>
<div class="border border-neutral-200 rounded-lg overflow-hidden">
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 w-10"><input type="checkbox" class="rounded border-neutral-300" :checked="isCurrentRolePageAllSelected" @change="toggleCurrentRolePage($event.target.checked)" /></th>
<th class="px-3 py-2 text-left">角色名称</th>
<th class="px-3 py-2 text-left">角色类别</th>
<th class="px-3 py-2 text-left">应用人数</th>
<th class="px-3 py-2 text-left">引用该角色职务</th>
<th class="px-3 py-2 text-left">创建时间</th>
<th class="px-3 py-2 text-left">修改时间</th>
<th class="px-3 py-2 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 bg-white">
<template x-if="pagedRoles.length===0">
<tr>
<td colspan="8" class="px-3 py-8 text-center text-neutral-400">暂无匹配角色</td>
</tr>
</template>
<template x-for="row in pagedRoles" :key="'role-row-'+row.id">
<tr>
<td class="px-3 py-2"><input type="checkbox" class="rounded border-neutral-300" :value="row.id" :disabled="row.isBuiltin" :checked="selectedRoleIds.includes(row.id)" @change="toggleRole(row.id, $event.target.checked)" /></td>
<td class="px-3 py-2">
<span x-text="row.name"></span>
<span x-show="row.isBuiltin" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-info-50 text-info-600">预设</span>
</td>
<td class="px-3 py-2" x-text="row.category"></td>
<td class="px-3 py-2"><span class="tabular-nums" x-text="row.applyCount"></span> <button class="action-link" @click="notify('查看应用人员(原型)')">查看</button></td>
<td class="px-3 py-2" x-text="row.refTitle"></td>
<td class="px-3 py-2 tabular-nums" x-text="row.createdAt"></td>
<td class="px-3 py-2 tabular-nums" x-text="row.updatedAt"></td>
<td class="px-3 py-2 whitespace-nowrap">
<button class="action-link" @click="openRolePermissionDrawer(row)">编辑</button>
<button class="action-link ml-2" @click="deleteRole(row)">删除</button>
<button class="action-link ml-2" @click="notify('打开修改日志(原型)')">修改日志</button>
</td>
</tr>
</template>
</tbody>
</table>
<div class="px-4 py-3 border-t border-neutral-200 bg-white flex items-center justify-between">
<div class="text-xs text-neutral-500"><span class="tabular-nums" x-text="filteredRoles.length"></span></div>
<div class="flex items-center gap-2 text-xs">
<button class="px-2 py-1 border border-neutral-300 rounded disabled:opacity-40" :disabled="rolePage<=1" @click="rolePage--">上一页</button>
<span class="px-2 py-1 border border-primary-600 text-primary-600 rounded" x-text="rolePage"></span>
<button class="px-2 py-1 border border-neutral-300 rounded disabled:opacity-40" :disabled="rolePage>=roleTotalPages" @click="rolePage++">下一页</button>
<select x-model.number="rolePageSize" class="px-2 py-1 border border-neutral-300 rounded bg-white">
<option :value="20">20条/页</option>
<option :value="50">50条/页</option>
<option :value="100">100条/页</option>
</select>
<span>跳至</span>
<input x-model.number="roleJumpPage" type="number" min="1" :max="roleTotalPages" class="w-14 px-2 py-1 border border-neutral-300 rounded text-center" />
<span></span>
<button class="px-2 py-1 border border-neutral-300 rounded" @click="jumpRolePage()">确定</button>
</div>
</div>
</div>
</section>
</section>
</template>
</div>
</main>
<div x-cloak x-show="batchRoleModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeBatchRoleModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-lg bg-white rounded-xl shadow-xl pointer-events-auto">
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200">
<h3 class="text-base font-semibold">批量设置角色</h3>
<button class="p-1 text-neutral-500 hover:bg-neutral-100 rounded" @click="closeBatchRoleModal()"></button>
</div>
<div class="px-5 py-4 space-y-3">
<p class="text-xs text-neutral-500">已选择 <span class="tabular-nums" x-text="selectedStaffIds.length"></span> 位员工</p>
<div>
<label class="block text-sm mb-1"><span class="text-danger-600">*</span> 角色</label>
<select x-model.number="batchRoleModal.roleId" class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">请选择角色</option>
<template x-for="r in roles" :key="'batch-role-'+r.id"><option :value="r.id" x-text="r.name"></option></template>
</select>
<p x-show="batchRoleModal.error" class="text-xs text-danger-600 mt-1" x-text="batchRoleModal.error"></p>
</div>
<div x-show="selectedHasCustomOverride" class="px-3 py-2 rounded-md bg-warning-50 border border-warning-600/20 text-warning-600 text-xs">
该操作将覆盖所选员工的个人自定义权限,请确认。
</div>
</div>
<div class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-100" @click="closeBatchRoleModal()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="submitBatchRole()">确定</button>
</div>
</div>
</div>
</div>
<div x-cloak x-show="addRoleModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeAddRoleModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-lg bg-white rounded-xl shadow-xl pointer-events-auto">
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200">
<h3 class="text-base font-semibold">添加角色</h3>
<button class="p-1 text-neutral-500 hover:bg-neutral-100 rounded" @click="closeAddRoleModal()"></button>
</div>
<div class="px-5 py-4 space-y-3">
<div>
<label class="block text-sm mb-1"><span class="text-danger-600">*</span> 角色名称</label>
<input x-model.trim="addRoleModal.form.name" type="text" placeholder="请输入角色名称" class="w-full px-3 py-2 rounded-md border border-neutral-300" />
<p x-show="addRoleModal.errors.name" class="text-xs text-danger-600 mt-1" x-text="addRoleModal.errors.name"></p>
</div>
<div>
<label class="block text-sm mb-1"><span class="text-danger-600">*</span> 角色类别</label>
<select x-model="addRoleModal.form.category" class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">请选择</option>
<template x-for="c in roleCategories" :key="'add-cat-'+c"><option :value="c" x-text="c"></option></template>
</select>
<p class="text-xs text-neutral-500 mt-1">角色类别影响权限,创建后仅本人创建类别可修改。</p>
<p x-show="addRoleModal.errors.category" class="text-xs text-danger-600 mt-1" x-text="addRoleModal.errors.category"></p>
</div>
</div>
<div class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-100" @click="closeAddRoleModal()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="submitAddRole()">下一步:设置权限</button>
</div>
</div>
</div>
</div>
<div x-cloak x-show="modifyRoleModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeModifyRoleModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-lg bg-white rounded-xl shadow-xl pointer-events-auto">
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200">
<h3 class="text-base font-semibold">修改角色</h3>
<button class="p-1 text-neutral-500 hover:bg-neutral-100 rounded" @click="closeModifyRoleModal()"></button>
</div>
<div class="px-5 py-4 space-y-3">
<p class="text-xs text-neutral-500" x-text="modifyRoleModal.staff ? `${modifyRoleModal.staff.name}${modifyRoleModal.staff.no}` : ''"></p>
<div>
<label class="block text-sm mb-1"><span class="text-danger-600">*</span> 角色(可多选)</label>
<div class="min-h-[40px] px-2 py-2 rounded-md border border-neutral-300 flex flex-wrap gap-1">
<template x-for="id in modifyRoleModal.selectedRoleIds" :key="'tag-role-'+id">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-neutral-100 text-xs">
<span x-text="roleNameById(id)"></span>
<button @click="toggleModifyRole(id)"></button>
</span>
</template>
<span x-show="modifyRoleModal.selectedRoleIds.length===0" class="text-neutral-400 text-xs">请选择角色</span>
</div>
<div class="mt-2 max-h-44 overflow-y-auto border border-neutral-200 rounded-md">
<template x-for="r in roles" :key="'mod-role-list-'+r.id">
<label class="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50 border-b border-neutral-100 last:border-b-0">
<input type="checkbox" class="rounded border-neutral-300" :checked="modifyRoleModal.selectedRoleIds.includes(r.id)" @change="toggleModifyRole(r.id)" />
<span x-text="r.name"></span>
</label>
</template>
</div>
<p x-show="modifyRoleModal.error" class="text-xs text-danger-600 mt-1" x-text="modifyRoleModal.error"></p>
</div>
</div>
<div class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-100" @click="closeModifyRoleModal()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="submitModifyRole()">确定</button>
</div>
</div>
</div>
</div>
<div x-cloak x-show="scopeModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="scopeModal.open=false"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-2xl bg-white rounded-xl shadow-xl pointer-events-auto">
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200">
<h3 class="text-base font-semibold">管理范围详情</h3>
<button class="p-1 text-neutral-500 hover:bg-neutral-100 rounded" @click="scopeModal.open=false"></button>
</div>
<div class="px-5 py-4 space-y-3">
<p class="text-sm text-neutral-700" x-text="scopeModal.staff ? `${scopeModal.staff.name}${scopeModal.staff.no}` : ''"></p>
<div class="flex flex-wrap gap-2">
<template x-for="item in scopeModal.scopes" :key="'scope-item-'+item">
<span class="inline-flex px-2 py-1 rounded bg-info-50 text-info-600 text-xs" x-text="item"></span>
</template>
</div>
</div>
<div class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex justify-end">
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="scopeModal.open=false">确定</button>
</div>
</div>
</div>
</div>
<div x-cloak x-show="staffDrawer.open" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-neutral-900/35" @click="closeStaffDrawer()"></div>
<div class="absolute right-0 top-0 h-full w-[1120px] bg-white shadow-2xl border-l border-neutral-200 flex flex-col">
<div class="px-5 py-4 border-b border-neutral-200 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold">人员权限编辑</h3>
<p class="text-xs text-neutral-500" x-text="staffDrawer.staff ? `${staffDrawer.staff.name} - ${staffDrawer.staff.dept}` : ''"></p>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="closeStaffDrawer()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveStaffPermissions()">保存</button>
</div>
</div>
<div class="flex-1 min-h-0 grid grid-cols-[220px_1fr]">
<aside class="border-r border-neutral-200 p-3 overflow-y-auto">
<input x-model.trim="staffDrawer.search" type="text" placeholder="请输入权限名称" class="w-full px-3 py-2 rounded-md border border-neutral-300" />
<nav class="mt-3 space-y-1">
<template x-for="m in staffDrawer.modules" :key="'sd-module-'+m.key">
<button class="w-full text-left px-2 py-1.5 rounded" :class="staffDrawer.currentModuleKey===m.key ? 'bg-primary-50 text-primary-700' : 'hover:bg-neutral-50 text-neutral-700'" @click="staffDrawer.currentModuleKey=m.key" x-text="m.name"></button>
</template>
</nav>
</aside>
<section class="p-4 overflow-y-auto space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold" x-text="currentStaffModule ? currentStaffModule.name : '-'"></h4>
<label class="inline-flex items-center gap-2 text-xs text-neutral-600">
<span>本模块开启</span>
<input type="checkbox" class="rounded border-neutral-300" :checked="currentStaffModule ? currentStaffModule.enabled : false" @change="toggleCurrentStaffModule($event.target.checked)" />
</label>
</div>
<template x-if="currentStaffModule">
<div class="space-y-4">
<template x-for="group in filteredCurrentStaffGroups" :key="'sd-group-'+group.name">
<section class="border border-neutral-200 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-neutral-50 border-b border-neutral-200 text-xs font-semibold" x-text="group.name"></div>
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 text-left">权限项</th>
<th class="px-3 py-2 text-left">设置值</th>
<th class="px-3 py-2 text-left">说明</th>
<th class="px-3 py-2 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100">
<template x-for="item in group.items" :key="'sd-item-'+item.code">
<tr>
<td class="px-3 py-2" x-text="item.name"></td>
<td class="px-3 py-2">
<template x-if="item.type==='boolean'">
<select x-model="item.value" class="px-2 py-1 border border-neutral-300 rounded bg-white" :disabled="!currentStaffModule.enabled">
<option value="true">开通</option>
<option value="false">关闭</option>
</select>
</template>
<template x-if="item.type==='scope'">
<select x-model="item.value" class="px-2 py-1 border border-neutral-300 rounded bg-white" :disabled="!currentStaffModule.enabled">
<template x-for="opt in scopeOptions" :key="'scope-opt-'+opt">
<option :value="opt" x-text="opt"></option>
</template>
</select>
</template>
<template x-if="item.type==='integer'">
<input x-model.number="item.value" type="number" min="0" class="w-24 px-2 py-1 border border-neutral-300 rounded" :disabled="!currentStaffModule.enabled" />
</template>
<template x-if="item.type==='enum'">
<select x-model="item.value" class="px-2 py-1 border border-neutral-300 rounded bg-white" :disabled="!currentStaffModule.enabled">
<template x-for="opt in item.options" :key="'enum-'+item.code+'-'+opt">
<option :value="opt" x-text="opt"></option>
</template>
</select>
</template>
</td>
<td class="px-3 py-2 text-neutral-500" x-text="item.desc"></td>
<td class="px-3 py-2"><button class="action-link" @click="openPermissionItemDrawer(item)">编辑</button></td>
</tr>
</template>
</tbody>
</table>
</section>
</template>
</div>
</template>
</section>
</div>
</div>
</div>
<div x-cloak x-show="roleDrawer.open" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-neutral-900/35" @click="closeRoleDrawer()"></div>
<div class="absolute right-0 top-0 h-full w-[1120px] bg-white shadow-2xl border-l border-neutral-200 flex flex-col">
<div class="px-5 py-4 border-b border-neutral-200 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold">角色权限配置</h3>
<p class="text-xs text-neutral-500" x-text="roleDrawer.role ? `${roleDrawer.role.name}|应用人数 ${roleDrawer.role.applyCount}` : ''"></p>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="closeRoleDrawer()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveRolePermissions()">保存</button>
</div>
</div>
<div class="flex-1 min-h-0 grid grid-cols-[220px_1fr]">
<aside class="border-r border-neutral-200 p-3 overflow-y-auto">
<input x-model.trim="roleDrawer.search" type="text" placeholder="请输入权限名称" class="w-full px-3 py-2 rounded-md border border-neutral-300" />
<nav class="mt-3 space-y-1">
<template x-for="m in roleDrawer.modules" :key="'rd-module-'+m.key">
<button class="w-full text-left px-2 py-1.5 rounded" :class="roleDrawer.currentModuleKey===m.key ? 'bg-primary-50 text-primary-700' : 'hover:bg-neutral-50 text-neutral-700'" @click="roleDrawer.currentModuleKey=m.key" x-text="m.name"></button>
</template>
</nav>
</aside>
<section class="p-4 overflow-y-auto space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold" x-text="currentRoleModule ? currentRoleModule.name : '-'"></h4>
<label class="inline-flex items-center gap-2 text-xs text-neutral-600">
<span>本模块开启</span>
<input type="checkbox" class="rounded border-neutral-300" :checked="currentRoleModule ? currentRoleModule.enabled : false" @change="toggleCurrentRoleModule($event.target.checked)" />
</label>
</div>
<template x-if="currentRoleModule">
<div class="space-y-4">
<template x-for="group in filteredCurrentRoleGroups" :key="'rd-group-'+group.name">
<section class="border border-neutral-200 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-neutral-50 border-b border-neutral-200 text-xs font-semibold" x-text="group.name"></div>
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 text-left">权限项</th>
<th class="px-3 py-2 text-left">设置值</th>
<th class="px-3 py-2 text-left">说明</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100">
<template x-for="item in group.items" :key="'rd-item-'+item.code">
<tr>
<td class="px-3 py-2" x-text="item.name"></td>
<td class="px-3 py-2">
<template x-if="item.type==='boolean'">
<select x-model="item.value" class="px-2 py-1 border border-neutral-300 rounded bg-white" :disabled="!currentRoleModule.enabled">
<option value="true">开通</option>
<option value="false">关闭</option>
</select>
</template>
<template x-if="item.type==='scope'">
<select x-model="item.value" class="px-2 py-1 border border-neutral-300 rounded bg-white" :disabled="!currentRoleModule.enabled">
<template x-for="opt in scopeOptions" :key="'rd-scope-opt-'+opt"><option :value="opt" x-text="opt"></option></template>
</select>
</template>
<template x-if="item.type==='integer'">
<input x-model.number="item.value" type="number" min="0" class="w-24 px-2 py-1 border border-neutral-300 rounded" :disabled="!currentRoleModule.enabled" />
</template>
<template x-if="item.type==='enum'">
<select x-model="item.value" class="px-2 py-1 border border-neutral-300 rounded bg-white" :disabled="!currentRoleModule.enabled">
<template x-for="opt in item.options" :key="'rd-enum-'+item.code+'-'+opt"><option :value="opt" x-text="opt"></option></template>
</select>
</template>
</td>
<td class="px-3 py-2 text-neutral-500" x-text="item.desc"></td>
</tr>
</template>
</tbody>
</table>
</section>
</template>
</div>
</template>
</section>
</div>
</div>
</div>
<div x-cloak x-show="permissionItemDrawer.open" class="fixed inset-0 z-[60]">
<div class="absolute inset-0 bg-neutral-900/35" @click="closePermissionItemDrawer()"></div>
<div class="absolute right-0 top-0 h-full w-[520px] bg-white border-l border-neutral-200 shadow-2xl flex flex-col">
<div class="px-5 py-4 border-b border-neutral-200 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold" x-text="permissionItemDrawer.item ? permissionItemDrawer.item.name : '权限项' "></h3>
<p class="text-xs text-neutral-500">权限修改后将影响当前编辑对象的可见范围</p>
</div>
<button class="p-1 text-neutral-500 hover:bg-neutral-100 rounded" @click="closePermissionItemDrawer()"></button>
</div>
<div class="p-5 space-y-4 flex-1 overflow-y-auto">
<p class="text-xs text-neutral-500" x-text="permissionItemDrawer.item ? permissionItemDrawer.item.desc : ''"></p>
<div class="border border-neutral-200 rounded-lg p-3 space-y-3" x-show="permissionItemDrawer.item">
<div class="text-xs text-neutral-500">设置值</div>
<template x-if="permissionItemDrawer.item && permissionItemDrawer.item.type==='boolean'">
<select x-model="permissionItemDrawer.item.value" class="w-full px-3 py-2 border border-neutral-300 rounded bg-white">
<option value="true">开通</option>
<option value="false">关闭</option>
</select>
</template>
<template x-if="permissionItemDrawer.item && permissionItemDrawer.item.type==='scope'">
<select x-model="permissionItemDrawer.item.value" class="w-full px-3 py-2 border border-neutral-300 rounded bg-white">
<template x-for="opt in scopeOptions" :key="'pid-scope-'+opt"><option :value="opt" x-text="opt"></option></template>
</select>
</template>
<template x-if="permissionItemDrawer.item && permissionItemDrawer.item.type==='integer'">
<input x-model.number="permissionItemDrawer.item.value" type="number" min="0" class="w-full px-3 py-2 border border-neutral-300 rounded" />
</template>
<template x-if="permissionItemDrawer.item && permissionItemDrawer.item.type==='enum'">
<select x-model="permissionItemDrawer.item.value" class="w-full px-3 py-2 border border-neutral-300 rounded bg-white">
<template x-for="opt in permissionItemDrawer.item.options" :key="'pid-enum-'+opt"><option :value="opt" x-text="opt"></option></template>
</select>
</template>
</div>
</div>
<div class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 bg-white hover:bg-neutral-100" @click="closePermissionItemDrawer()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="savePermissionItemDrawer()">保存</button>
</div>
</div>
</div>
<div x-cloak x-show="toast.show" class="fixed right-6 top-20 z-[80]" x-transition>
<div class="px-3 py-2 rounded-md shadow border text-sm" :class="toast.type==='error' ? 'bg-danger-50 border-danger-600/20 text-danger-600' : 'bg-success-50 border-success-600/20 text-success-600'" x-text="toast.message"></div>
</div>
<script>
function permissionPage() {
return {
mainTab: 'staff',
scopeOptions: ['无', '本人', '本组', '本门店', '本区域', '本大区', '全公司'],
departments: ['上海豪园店二组', '静安门店', '浦东门店', '系统管理组'],
titles: ['高级业务员', '分行经理', '行政人员', '系统管理员'],
roleCategories: ['置业顾问', '店管', '区域管理', '职能'],
staffFilters: { keyword: '', dept: '', roleId: '', title: '', onlyMismatch: false },
roleFilters: { name: '', category: '', from: '', to: '' },
staffPage: 1,
staffPageSize: 20,
staffJumpPage: 1,
rolePage: 1,
rolePageSize: 20,
roleJumpPage: 1,
selectedStaffIds: [],
selectedRoleIds: [],
batchRoleModal: { open: false, roleId: '', error: '' },
addRoleModal: { open: false, form: { name: '', category: '' }, errors: {} },
modifyRoleModal: { open: false, staff: null, selectedRoleIds: [], error: '' },
scopeModal: { open: false, staff: null, scopes: [] },
staffDrawer: { open: false, staff: null, modules: [], currentModuleKey: '', search: '' },
roleDrawer: { open: false, role: null, modules: [], currentModuleKey: '', search: '' },
permissionItemDrawer: { open: false, item: null, source: null },
toast: { show: false, type: 'success', message: '' },
roles: [
{ id: 1, name: '高级业务员', category: '置业顾问', applyCount: 12, refTitle: '高级业务员', createdAt: '2026-04-10 09:20', updatedAt: '2026-04-25 10:10', isBuiltin: true, permissionProfile: [] },
{ id: 2, name: '分行经理', category: '店管', applyCount: 6, refTitle: '分行经理', createdAt: '2026-04-10 09:20', updatedAt: '2026-04-24 16:40', isBuiltin: true, permissionProfile: [] },
{ id: 3, name: '行政人员', category: '职能', applyCount: 3, refTitle: '行政人员', createdAt: '2026-04-11 12:00', updatedAt: '2026-04-23 15:10', isBuiltin: false, permissionProfile: [] },
{ id: 4, name: '最大权限角色', category: '区域管理', applyCount: 1, refTitle: '系统管理员', createdAt: '2026-04-12 08:30', updatedAt: '2026-04-26 11:20', isBuiltin: false, permissionProfile: [] },
{ id: 5, name: '刘文龙', category: '店管', applyCount: 1, refTitle: '分行经理', createdAt: '2026-04-15 10:20', updatedAt: '2026-04-27 17:30', isBuiltin: false, permissionProfile: [] }
],
staff: [
{ id: 101, name: '刘源', no: '41', dept: '上海豪园店二组', title: '高级业务员', roleId: 1, scopeSummary: '本人/本组', scopes: ['本人', '本组'], mismatch: false, hasPersonalOverride: false },
{ id: 102, name: '刘文龙', no: '17', dept: '上海豪园店二组', title: '分行经理', roleId: 2, scopeSummary: '本门店', scopes: ['本门店'], mismatch: true, hasPersonalOverride: true },
{ id: 103, name: '金怡', no: '18', dept: '静安门店', title: '行政人员', roleId: 3, scopeSummary: '本门店', scopes: ['本门店'], mismatch: false, hasPersonalOverride: false },
{ id: 104, name: '孙海鹏', no: '56', dept: '浦东门店', title: '高级业务员', roleId: 1, scopeSummary: '本人', scopes: ['本人'], mismatch: false, hasPersonalOverride: false },
{ id: 105, name: '周伟', no: '63', dept: '上海豪园店二组', title: '高级业务员', roleId: 1, scopeSummary: '本人/本组', scopes: ['本人', '本组'], mismatch: false, hasPersonalOverride: false },
{ id: 106, name: '杜利强', no: '1', dept: '系统管理组', title: '系统管理员', roleId: 4, scopeSummary: '全公司', scopes: ['全公司'], mismatch: true, hasPersonalOverride: true }
],
init() {
const base = this.buildPermissionTemplate();
this.roles = this.roles.map(r => ({ ...r, permissionProfile: this.deepClone(base) }));
},
get filteredStaff() {
return this.staff.filter(s => {
if (this.staffFilters.keyword && !(s.name.includes(this.staffFilters.keyword) || s.no.includes(this.staffFilters.keyword))) return false;
if (this.staffFilters.dept && s.dept !== this.staffFilters.dept) return false;
if (this.staffFilters.roleId && s.roleId !== this.staffFilters.roleId) return false;
if (this.staffFilters.title && s.title !== this.staffFilters.title) return false;
if (this.staffFilters.onlyMismatch && !s.mismatch) return false;
return true;
});
},
get staffTotalPages() {
return Math.max(1, Math.ceil(this.filteredStaff.length / this.staffPageSize));
},
get pagedStaff() {
if (this.staffPage > this.staffTotalPages) this.staffPage = this.staffTotalPages;
const start = (this.staffPage - 1) * this.staffPageSize;
return this.filteredStaff.slice(start, start + this.staffPageSize);
},
get isCurrentStaffPageAllSelected() {
if (!this.pagedStaff.length) return false;
return this.pagedStaff.every(r => this.selectedStaffIds.includes(r.id));
},
toggleCurrentStaffPage(checked) {
const ids = this.pagedStaff.map(r => r.id);
if (checked) {
this.selectedStaffIds = Array.from(new Set([...this.selectedStaffIds, ...ids]));
} else {
this.selectedStaffIds = this.selectedStaffIds.filter(id => !ids.includes(id));
}
},
toggleStaff(id, checked) {
if (checked) this.selectedStaffIds = Array.from(new Set([...this.selectedStaffIds, id]));
else this.selectedStaffIds = this.selectedStaffIds.filter(x => x !== id);
},
jumpStaffPage() {
const n = Number(this.staffJumpPage || 1);
this.staffPage = Math.min(this.staffTotalPages, Math.max(1, n));
},
resetStaffFilters() {
this.staffFilters = { keyword: '', dept: '', roleId: '', title: '', onlyMismatch: false };
this.staffPage = 1;
},
get filteredRoles() {
return this.roles.filter(r => {
if (this.roleFilters.name && !r.name.includes(this.roleFilters.name)) return false;
if (this.roleFilters.category && r.category !== this.roleFilters.category) return false;
if (this.roleFilters.from && r.updatedAt.slice(0, 10) < this.roleFilters.from) return false;
if (this.roleFilters.to && r.updatedAt.slice(0, 10) > this.roleFilters.to) return false;
return true;
});
},
get roleTotalPages() {
return Math.max(1, Math.ceil(this.filteredRoles.length / this.rolePageSize));
},
get pagedRoles() {
if (this.rolePage > this.roleTotalPages) this.rolePage = this.roleTotalPages;
const start = (this.rolePage - 1) * this.rolePageSize;
return this.filteredRoles.slice(start, start + this.rolePageSize);
},
get isCurrentRolePageAllSelected() {
const rows = this.pagedRoles.filter(r => !r.isBuiltin);
if (!rows.length) return false;
return rows.every(r => this.selectedRoleIds.includes(r.id));
},
toggleCurrentRolePage(checked) {
const ids = this.pagedRoles.filter(r => !r.isBuiltin).map(r => r.id);
if (checked) this.selectedRoleIds = Array.from(new Set([...this.selectedRoleIds, ...ids]));
else this.selectedRoleIds = this.selectedRoleIds.filter(id => !ids.includes(id));
},
toggleRole(id, checked) {
if (checked) this.selectedRoleIds = Array.from(new Set([...this.selectedRoleIds, id]));
else this.selectedRoleIds = this.selectedRoleIds.filter(x => x !== id);
},
jumpRolePage() {
const n = Number(this.roleJumpPage || 1);
this.rolePage = Math.min(this.roleTotalPages, Math.max(1, n));
},
resetRoleFilters() {
this.roleFilters = { name: '', category: '', from: '', to: '' };
this.rolePage = 1;
},
switchTab(tab) {
this.mainTab = tab;
},
roleNameById(id) {
const role = this.roles.find(r => r.id === id);
return role ? role.name : '-';
},
openBatchRoleModal() {
if (!this.selectedStaffIds.length) return;
this.batchRoleModal = { open: true, roleId: '', error: '' };
},
closeBatchRoleModal() {
this.batchRoleModal = { open: false, roleId: '', error: '' };
},
get selectedHasCustomOverride() {
const set = new Set(this.selectedStaffIds);
return this.staff.some(s => set.has(s.id) && s.hasPersonalOverride);
},
submitBatchRole() {
if (!this.batchRoleModal.roleId) {
this.batchRoleModal.error = '请选择角色';
return;
}
const set = new Set(this.selectedStaffIds);
this.staff = this.staff.map(s => {
if (!set.has(s.id)) return s;
return {
...s,
roleId: this.batchRoleModal.roleId,
mismatch: false,
hasPersonalOverride: false
};
});
this.closeBatchRoleModal();
this.selectedStaffIds = [];
this.notify('批量设置角色成功');
},
openScopeModal(staff) {
this.scopeModal.open = true;
this.scopeModal.staff = staff;
this.scopeModal.scopes = staff.scopes || [];
},
openAddRoleModal() {
this.addRoleModal = { open: true, form: { name: '', category: '' }, errors: {} };
},
closeAddRoleModal() {
this.addRoleModal = { open: false, form: { name: '', category: '' }, errors: {} };
},
submitAddRole() {
const errors = {};
const name = (this.addRoleModal.form.name || '').trim();
const category = this.addRoleModal.form.category;
if (!name) errors.name = '请输入角色名称';
if (!category) errors.category = '请选择角色类别';
if (name && this.roles.some(r => r.name === name)) errors.name = '角色名称已存在';
this.addRoleModal.errors = errors;
if (Object.keys(errors).length) return;
const id = Math.max(...this.roles.map(r => r.id)) + 1;
const now = new Date();
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const role = {
id,
name,
category,
applyCount: 0,
refTitle: '-',
createdAt: ts,
updatedAt: ts,
isBuiltin: false,
permissionProfile: this.deepClone(this.buildPermissionTemplate())
};
this.roles.unshift(role);
this.closeAddRoleModal();
this.openRolePermissionDrawer(role);
this.notify('角色创建成功,请继续配置权限');
},
openModifyRoleModal(staff) {
this.modifyRoleModal.open = true;
this.modifyRoleModal.staff = staff;
this.modifyRoleModal.selectedRoleIds = [staff.roleId];
this.modifyRoleModal.error = '';
},
closeModifyRoleModal() {
this.modifyRoleModal = { open: false, staff: null, selectedRoleIds: [], error: '' };
},
toggleModifyRole(roleId) {
if (this.modifyRoleModal.selectedRoleIds.includes(roleId)) {
this.modifyRoleModal.selectedRoleIds = this.modifyRoleModal.selectedRoleIds.filter(id => id !== roleId);
} else {
this.modifyRoleModal.selectedRoleIds = [...this.modifyRoleModal.selectedRoleIds, roleId];
}
},
submitModifyRole() {
if (!this.modifyRoleModal.selectedRoleIds.length) {
this.modifyRoleModal.error = '请至少选择一个角色';
return;
}
const primary = this.modifyRoleModal.selectedRoleIds[0];
this.staff = this.staff.map(s => {
if (s.id !== this.modifyRoleModal.staff.id) return s;
return { ...s, roleId: primary, mismatch: true, hasPersonalOverride: true };
});
this.closeModifyRoleModal();
this.notify('员工角色已更新');
},
deleteRole(role) {
if (role.isBuiltin) {
this.notify('预设角色不可删除', 'error');
return;
}
if (role.applyCount > 0) {
this.notify('该角色仍有应用人员,无法删除', 'error');
return;
}
this.roles = this.roles.filter(r => r.id !== role.id);
this.selectedRoleIds = this.selectedRoleIds.filter(id => id !== role.id);
this.notify('角色删除成功');
},
batchDeleteRoles() {
if (!this.selectedRoleIds.length) return;
const blocked = this.roles.filter(r => this.selectedRoleIds.includes(r.id) && (r.isBuiltin || r.applyCount > 0));
if (blocked.length) {
this.notify('选中角色中包含预设角色或仍有应用人数,无法批量删除', 'error');
return;
}
const set = new Set(this.selectedRoleIds);
this.roles = this.roles.filter(r => !set.has(r.id));
this.selectedRoleIds = [];
this.notify('批量删除成功');
},
openStaffPermissionDrawer(staff) {
this.staffDrawer.open = true;
this.staffDrawer.staff = staff;
this.staffDrawer.modules = this.deepClone(this.buildPermissionTemplate());
this.staffDrawer.currentModuleKey = this.staffDrawer.modules[0].key;
this.staffDrawer.search = '';
},
closeStaffDrawer() {
this.staffDrawer = { open: false, staff: null, modules: [], currentModuleKey: '', search: '' };
this.closePermissionItemDrawer();
},
get currentStaffModule() {
return this.staffDrawer.modules.find(m => m.key === this.staffDrawer.currentModuleKey);
},
get filteredCurrentStaffGroups() {
const module = this.currentStaffModule;
if (!module) return [];
if (!this.staffDrawer.search) return module.groups;
const q = this.staffDrawer.search;
return module.groups.map(g => ({
...g,
items: g.items.filter(i => i.name.includes(q) || i.desc.includes(q))
})).filter(g => g.items.length);
},
toggleCurrentStaffModule(checked) {
const module = this.currentStaffModule;
if (module) module.enabled = checked;
},
saveStaffPermissions() {
if (!this.staffDrawer.staff) return;
this.staff = this.staff.map(s => s.id === this.staffDrawer.staff.id ? { ...s, mismatch: true, hasPersonalOverride: true } : s);
this.closeStaffDrawer();
this.notify('人员权限已保存(已标记与角色不一致)');
},
openRolePermissionDrawer(role) {
this.roleDrawer.open = true;
this.roleDrawer.role = role;
this.roleDrawer.modules = this.deepClone(role.permissionProfile && role.permissionProfile.length ? role.permissionProfile : this.buildPermissionTemplate());
this.roleDrawer.currentModuleKey = this.roleDrawer.modules[0].key;
this.roleDrawer.search = '';
},
closeRoleDrawer() {
this.roleDrawer = { open: false, role: null, modules: [], currentModuleKey: '', search: '' };
},
get currentRoleModule() {
return this.roleDrawer.modules.find(m => m.key === this.roleDrawer.currentModuleKey);
},
get filteredCurrentRoleGroups() {
const module = this.currentRoleModule;
if (!module) return [];
if (!this.roleDrawer.search) return module.groups;
const q = this.roleDrawer.search;
return module.groups.map(g => ({
...g,
items: g.items.filter(i => i.name.includes(q) || i.desc.includes(q))
})).filter(g => g.items.length);
},
toggleCurrentRoleModule(checked) {
const module = this.currentRoleModule;
if (module) module.enabled = checked;
},
saveRolePermissions() {
if (!this.roleDrawer.role) return;
const now = new Date();
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
this.roles = this.roles.map(r => {
if (r.id !== this.roleDrawer.role.id) return r;
return { ...r, permissionProfile: this.deepClone(this.roleDrawer.modules), updatedAt: ts };
});
this.closeRoleDrawer();
this.notify('角色权限已保存');
},
openPermissionItemDrawer(item) {
this.permissionItemDrawer.open = true;
this.permissionItemDrawer.item = item;
},
closePermissionItemDrawer() {
this.permissionItemDrawer = { open: false, item: null, source: null };
},
savePermissionItemDrawer() {
this.closePermissionItemDrawer();
this.notify('权限项已更新');
},
closeAllOverlays() {
if (this.permissionItemDrawer.open) return this.closePermissionItemDrawer();
if (this.staffDrawer.open) return this.closeStaffDrawer();
if (this.roleDrawer.open) return this.closeRoleDrawer();
if (this.batchRoleModal.open) return this.closeBatchRoleModal();
if (this.addRoleModal.open) return this.closeAddRoleModal();
if (this.modifyRoleModal.open) return this.closeModifyRoleModal();
if (this.scopeModal.open) this.scopeModal.open = false;
},
notify(message, type = 'success') {
this.toast = { show: true, type, message };
setTimeout(() => { this.toast.show = false; }, 2200);
},
deepClone(v) {
return JSON.parse(JSON.stringify(v));
},
buildPermissionTemplate() {
return [
{
key: 'home',
name: '首页',
enabled: true,
groups: [
{
name: '基础权限',
items: [
{ code: 'home.version', name: '查看首页版本', type: 'enum', value: '置业顾问', options: ['无', '置业顾问', '店管', '区管', '区总', '副总', '总经理', '职能人员'], desc: '控制员工查看的首页版本。' },
{ code: 'home.new_property', name: '今日新上房源', type: 'boolean', value: 'true', desc: '控制移动端是否显示。' },
{ code: 'home.rank_person', name: '个人排行榜权限', type: 'scope', value: '本人', desc: '控制个人排行榜可见范围。' },
{ code: 'home.rank_dept', name: '部门排行榜权限', type: 'scope', value: '本组', desc: '控制部门排行榜可见范围。' },
{ code: 'home.like_manage', name: '管理点赞信息和屏蔽点赞', type: 'boolean', value: 'false', desc: '开启后可删除点赞内容并屏蔽发布。' }
]
}
]
},
{
key: 'client',
name: '客源',
enabled: true,
groups: [
{
name: '私客基础权限',
items: [
{ code: 'client.private.add', name: '新增私客', type: 'boolean', value: 'true', desc: '开启后可新增私客。' },
{ code: 'client.private.limit', name: '个人私客数量上限', type: 'integer', value: 999, desc: '999=不限制0=不允许。' },
{ code: 'client.private.view', name: '查看私客(非保护客)', type: 'scope', value: '本组', desc: '控制查看非保护客私客范围。' },
{ code: 'client.private.edit', name: '编辑私客(非保护客)', type: 'scope', value: '本人', desc: '控制可编辑私客范围。' }
]
},
{
name: '公客基础权限',
items: [
{ code: 'client.public.view', name: '公客查看范围', type: 'scope', value: '本门店', desc: '控制公客查看范围。' },
{ code: 'client.public.convert', name: '公客转私客', type: 'boolean', value: 'false', desc: '开启后允许将公客转私客。' },
{ code: 'client.public.edit', name: '编辑公客', type: 'boolean', value: 'false', desc: '开启后可编辑公客。' }
]
},
{
name: '联系人号码权限',
items: [
{ code: 'client.tel.view', name: '私客&成交客查看号码', type: 'scope', value: '本人', desc: '控制可查看号码范围。' },
{ code: 'client.tel.count', name: '联系人号码查看个数', type: 'integer', value: 999, desc: '每天查看次数上限。' }
]
}
]
},
{
key: 'property',
name: '房源',
enabled: true,
groups: [
{
name: '基础权限',
items: [
{ code: 'property.add', name: '新增房源', type: 'boolean', value: 'true', desc: '开启后可新增房源。' },
{ code: 'property.status.view', name: '状态查看范围', type: 'enum', value: '出租/出售', options: ['出租/出售', '出租/出售/暂缓', '全部状态'], desc: '控制可查看房源状态集合。' },
{ code: 'property.maintain', name: '维护房源列表', type: 'scope', value: '本门店', desc: '按维护人范围查看。' }
]
},
{
name: '管理权限',
items: [
{ code: 'property.delete', name: '删除房源', type: 'boolean', value: 'false', desc: '开启后可删除房源。' },
{ code: 'property.recover', name: '恢复已删除房源', type: 'boolean', value: 'false', desc: '开启后可恢复已删除房源。' },
{ code: 'property.related.modify', name: '批量修改相关方范围', type: 'scope', value: '本门店', desc: '控制批量修改相关方范围。' }
]
}
]
},
{
key: 'trade',
name: '交易',
enabled: false,
groups: [
{
name: '基础权限',
items: [
{ code: 'trade.view', name: '查看交易单', type: 'scope', value: '本门店', desc: '控制交易单查看范围。' },
{ code: 'trade.edit', name: '编辑交易单', type: 'boolean', value: 'false', desc: '控制交易单编辑权限。' }
]
}
]
}
];
}
}
}
</script>
</body>
</html>