feat: 接入权限接口
This commit is contained in:
+16
-16
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。登录、首页统计、签署工作台的患者/就诊/模板/任务/审计、签署文件下载、短信重发、模板创建/版本工作流、用户组织查询与用户创建编辑,以及按模板维护文档权限已经接入 MEDISIGN 后端;文档库管理已接入模板与版本接口,系统设置仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
|
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
|
||||||
|
|
||||||
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
|
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
|
||||||
|
|
||||||
@@ -102,16 +102,14 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
|
|||||||
公开页
|
公开页
|
||||||
└─ /login 登录页
|
└─ /login 登录页
|
||||||
|
|
||||||
ClinicalLayout
|
ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
|
||||||
├─ 工作台
|
├─ /dashboard 首页
|
||||||
│ ├─ /workbench/home 首页
|
├─ /sign/workbench 签署工作台
|
||||||
│ └─ /workbench/signing 签署工作台
|
├─ /system/org 组织架构 / 员工角色绑定
|
||||||
└─ 管理
|
├─ /system/role 角色权限与数据范围
|
||||||
├─ /management/documents 文档库管理
|
├─ /doc/library 文档库管理
|
||||||
├─ /management/reports 报表分析
|
├─ /report/index 报表分析
|
||||||
├─ /management/users 用户与权限
|
└─ /system/setting 系统设置 / 菜单设置
|
||||||
├─ /management/document-permissions 文档权限
|
|
||||||
└─ /management/settings 系统设置
|
|
||||||
```
|
```
|
||||||
|
|
||||||
布局拆分为 `Menu`、`Header` 和 `Container` 三个公共组件,页面内容由各自的 View 负责。
|
布局拆分为 `Menu`、`Header` 和 `Container` 三个公共组件,页面内容由各自的 View 负责。
|
||||||
@@ -177,7 +175,7 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
|||||||
- `POST /api/v1/auth/logout`:注销当前会话;
|
- `POST /api/v1/auth/logout`:注销当前会话;
|
||||||
- 后续请求自动携带 `X-Token` 请求头。
|
- 后续请求自动携带 `X-Token` 请求头。
|
||||||
|
|
||||||
路由和菜单按后端权限码控制可见性,页面中的创建、修改、投递、作废、下载和导出等动作也使用对应权限码单独控制。`READ_ONLY_ALL` 账号即使被误配了写权限码,前端仍不会展示写操作;最终授权结果始终以后端校验为准。系统设置尚无正式后端权限契约,生产默认隐藏,仅在显式开启 `VITE_MOCK_SETTINGS=true` 时用于演示。
|
路由、侧栏和页面操作均按后端授权资源控制;导航每 15 秒及窗口重新聚焦时刷新,角色、菜单资源或员工角色绑定变更后也会主动刷新当前授权。角色权限从菜单资源树配置目录、菜单和按钮,员工角色绑定通过独立用户角色接口保存。菜单资源维护使用真实接口;系统参数卡片仍是演示数据,最终授权始终以后端校验为准。
|
||||||
|
|
||||||
签署工作台已接入的真实接口包括:
|
签署工作台已接入的真实接口包括:
|
||||||
|
|
||||||
@@ -198,11 +196,13 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
|||||||
- `PUT /api/v1/templates/{id}`:更新模板元数据;
|
- `PUT /api/v1/templates/{id}`:更新模板元数据;
|
||||||
- `GET/POST /api/v1/templates/{id}/versions`、`GET /api/v1/templates/{id}/versions/{versionId}`:版本列表、创建与版本详情(**正文 `contentHtml` 只在这个详情接口里返回**);
|
- `GET/POST /api/v1/templates/{id}/versions`、`GET /api/v1/templates/{id}/versions/{versionId}`:版本列表、创建与版本详情(**正文 `contentHtml` 只在这个详情接口里返回**);
|
||||||
- `POST /api/v1/templates/{id}/versions/{versionId}/{action}`(submit-review / reject / approve / publish / disable / archive):版本工作流,模板「删除」即走 archive;
|
- `POST /api/v1/templates/{id}/versions/{versionId}/{action}`(submit-review / reject / approve / publish / disable / archive):版本工作流,模板「删除」即走 archive;
|
||||||
- `GET /api/v1/users`、`GET /api/v1/roles`、`GET /api/v1/campuses`、`GET /api/v1/departments`:用户页真实查询及组织字典;
|
- `GET /api/v1/users`、`POST /api/v1/users`、`GET/PUT /api/v1/users/{id}`:员工列表、资料创建和资料更新;
|
||||||
- `GET /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/management`:用户角色读取和用户资料/角色聚合保存;
|
- `GET /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/roles`:单独查询和替换员工角色;用户资料接口不再同时保存角色或个人数据范围;
|
||||||
- `POST /api/v1/users/{id}/password/reset`、`POST /api/v1/users/import/preview`、`POST /api/v1/users/import/commit`、`GET /api/v1/users/import/jobs/{jobId}`:管理员重置密码和用户导入流程;
|
- `POST /api/v1/users/{id}/password/reset`、`POST /api/v1/users/import/preview`、`POST /api/v1/users/import/commit`、`GET /api/v1/users/import/jobs/{jobId}`:管理员重置密码和用户导入流程;
|
||||||
- `GET /api/v1/roles/summary`、`GET/PUT /api/v1/roles/{roleId}/permissions`:角色人数统计和角色 API 权限分配;
|
- `GET /api/v1/roles`、`GET /api/v1/roles/assignable`、`POST /api/v1/roles`、`PUT /api/v1/roles/{id}`、`PUT /api/v1/roles/{id}/status`、`DELETE /api/v1/roles/{id}`:角色列表与角色维护;
|
||||||
- `GET/POST/PUT/DELETE /api/v1/permissions`:API 权限分页查询、详情、新增、修改和停用;
|
- `GET /api/v1/roles/permission-tree`、`GET/PUT /api/v1/roles/{id}/permissions`、`GET/PUT /api/v1/roles/{id}/data-scope`:基于菜单资源树配置角色权限和数据范围;
|
||||||
|
- `GET/POST /api/v1/menu-resources`、`PUT /api/v1/menu-resources/{id}`、`PUT /api/v1/menu-resources/{id}/status`、`PUT /api/v1/menu-resources/{id}/sort`、`DELETE /api/v1/menu-resources/{id}`:菜单资源树维护;
|
||||||
|
- `GET /api/v1/authorization/me`:当前用户权限、数据范围和可访问导航;
|
||||||
- `GET /api/v1/templates/permission-config`:按权限配置范围分页查询模板;
|
- `GET /api/v1/templates/permission-config`:按权限配置范围分页查询模板;
|
||||||
- `GET /api/v1/templates/permissions/matrix`、`POST /api/v1/templates/permissions/batch`:跨模板权限矩阵查询和批量新增/撤销;
|
- `GET /api/v1/templates/permissions/matrix`、`POST /api/v1/templates/permissions/batch`:跨模板权限矩阵查询和批量新增/撤销;
|
||||||
- `GET/POST/DELETE /api/v1/templates/{id}/permissions`:单模板权限兼容接口,其中前端使用 GET 补充用户继承预览;
|
- `GET/POST/DELETE /api/v1/templates/{id}/permissions`:单模板权限兼容接口,其中前端使用 GET 补充用户继承预览;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { request } from '@/utils/request'
|
|||||||
import type {
|
import type {
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
CaptchaResponse,
|
CaptchaResponse,
|
||||||
|
CurrentAuthorization,
|
||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -76,6 +77,11 @@ export async function getCurrentUser(): Promise<CurrentUserResponse> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCurrentAuthorization(): Promise<CurrentAuthorization> {
|
||||||
|
const response = await request.get<ApiResponse<CurrentAuthorization>>('/v1/authorization/me')
|
||||||
|
return unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
const response = await request.post<ApiResponse<Record<string, never>>>('/v1/auth/logout')
|
const response = await request.post<ApiResponse<Record<string, never>>>('/v1/auth/logout')
|
||||||
unwrapApiResponse(response)
|
unwrapApiResponse(response)
|
||||||
|
|||||||
@@ -1,210 +1,56 @@
|
|||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
import { mockFlags } from '@/utils/mock-flags'
|
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
import type { ApiResponse, PageResult } from '@/types/common'
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiPermissionListResponse,
|
CreatePermissionResourceRequest,
|
||||||
ApiPermissionQuery,
|
PermissionResourceNodeDto,
|
||||||
ApiPermissionRecord,
|
UpdatePermissionResourceRequest,
|
||||||
ApiPermissionResponseDto,
|
|
||||||
BackendPage,
|
|
||||||
CreateApiPermissionRequest,
|
|
||||||
UpdateApiPermissionRequest,
|
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const useMockData = mockFlags.users
|
export async function getPermissionResources(): Promise<PermissionResourceNodeDto[]> {
|
||||||
|
const response = await request.get<ApiResponse<PermissionResourceNodeDto[]>>('/v1/menu-resources')
|
||||||
export const isApiPermissionMockEnabled = useMockData
|
return unwrapApiResponse(response)
|
||||||
|
|
||||||
const mockRecords: ApiPermissionRecord[] = [
|
|
||||||
{
|
|
||||||
id: 'api-permission-001',
|
|
||||||
parentId: null,
|
|
||||||
code: 'system:user:query',
|
|
||||||
name: '查询用户',
|
|
||||||
resourceType: 'USER',
|
|
||||||
resourcePath: '/api/v1/users',
|
|
||||||
action: 'QUERY',
|
|
||||||
status: 'ENABLED',
|
|
||||||
createdAt: '2026-08-27 09:00',
|
|
||||||
updatedAt: '2026-08-27 09:00',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'api-permission-002',
|
|
||||||
parentId: null,
|
|
||||||
code: 'system:sign-task:create',
|
|
||||||
name: '创建签署任务',
|
|
||||||
resourceType: 'SIGN_TASK',
|
|
||||||
resourcePath: '/api/v1/sign-tasks',
|
|
||||||
action: 'CREATE',
|
|
||||||
status: 'ENABLED',
|
|
||||||
createdAt: '2026-08-27 09:02',
|
|
||||||
updatedAt: '2026-08-27 09:02',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined) {
|
|
||||||
if (!value) {
|
|
||||||
return '—'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(value)
|
export async function createPermissionResource(
|
||||||
|
payload: CreatePermissionResourceRequest,
|
||||||
if (Number.isNaN(date.getTime())) {
|
): Promise<string> {
|
||||||
return value
|
const response = await request.post<ApiResponse<{ id: string }>>('/v1/menu-resources', payload)
|
||||||
.replace('T', ' ')
|
return unwrapApiResponse(response).id
|
||||||
.replace(/\.\d+Z$/, '')
|
|
||||||
.replace(/Z$/, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad = (part: number) => String(part).padStart(2, '0')
|
export async function updatePermissionResource(
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
|
||||||
date.getHours(),
|
|
||||||
)}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapPermission(dto: ApiPermissionResponseDto): ApiPermissionRecord {
|
|
||||||
return {
|
|
||||||
id: dto.id,
|
|
||||||
parentId: dto.parentId ?? null,
|
|
||||||
code: dto.code,
|
|
||||||
name: dto.name,
|
|
||||||
resourceType: dto.resourceType,
|
|
||||||
resourcePath: dto.resourcePath ?? '',
|
|
||||||
action: dto.action ?? 'QUERY',
|
|
||||||
status: dto.status ?? 'ENABLED',
|
|
||||||
createdAt: formatDateTime(dto.createdAt),
|
|
||||||
updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePage<T>(
|
|
||||||
data: BackendPage<T> | T[],
|
|
||||||
fallbackPage: number,
|
|
||||||
fallbackPageSize: number,
|
|
||||||
): PageResult<T> {
|
|
||||||
const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
records,
|
|
||||||
total: Array.isArray(data) ? records.length : (data.total ?? records.length),
|
|
||||||
page: Array.isArray(data) ? fallbackPage : (data.page ?? fallbackPage),
|
|
||||||
pageSize: Array.isArray(data) ? fallbackPageSize : (data.size ?? fallbackPageSize),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBackendQuery(query: ApiPermissionQuery) {
|
|
||||||
const params: Record<string, string | number> = {
|
|
||||||
page: Math.max(query.page, 1),
|
|
||||||
size: Math.min(Math.max(query.pageSize, 1), 200),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.keyword?.trim()) {
|
|
||||||
params.keyword = query.keyword.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPermissions(query: ApiPermissionQuery): Promise<ApiPermissionListResponse> {
|
|
||||||
if (!useMockData) {
|
|
||||||
return request
|
|
||||||
.get<ApiResponse<BackendPage<ApiPermissionResponseDto>>>('/v1/permissions', {
|
|
||||||
params: toBackendQuery(query),
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
const page = normalizePage(unwrapApiResponse(response), query.page, query.pageSize)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
records: page.records.map(mapPermission),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = query.keyword?.trim().toLowerCase()
|
|
||||||
const filteredRecords = mockRecords.filter((record) => {
|
|
||||||
return (
|
|
||||||
!keyword ||
|
|
||||||
[record.code, record.name, record.resourceType, record.resourcePath]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(keyword)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const page = Math.max(query.page, 1)
|
|
||||||
const pageSize = Math.max(query.pageSize, 1)
|
|
||||||
const start = (page - 1) * pageSize
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
records: filteredRecords.slice(start, start + pageSize).map((record) => ({ ...record })),
|
|
||||||
total: filteredRecords.length,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPermission(id: string): Promise<ApiPermissionRecord | null> {
|
|
||||||
if (useMockData) {
|
|
||||||
const record = mockRecords.find((item) => item.id === id)
|
|
||||||
return record ? { ...record } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await request.get<ApiResponse<ApiPermissionResponseDto>>(
|
|
||||||
`/v1/permissions/${encodeURIComponent(id)}`,
|
|
||||||
)
|
|
||||||
return mapPermission(unwrapApiResponse(response))
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPermission(
|
|
||||||
payload: CreateApiPermissionRequest,
|
|
||||||
): Promise<ApiPermissionRecord> {
|
|
||||||
if (useMockData) {
|
|
||||||
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
.post<ApiResponse<ApiPermissionResponseDto>>('/v1/permissions', payload)
|
|
||||||
.then(unwrapApiResponse)
|
|
||||||
.then(mapPermission)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePermission(
|
|
||||||
id: string,
|
id: string,
|
||||||
payload: UpdateApiPermissionRequest,
|
payload: UpdatePermissionResourceRequest,
|
||||||
): Promise<ApiPermissionRecord> {
|
): Promise<void> {
|
||||||
if (useMockData) {
|
const response = await request.put<ApiResponse<null>>(
|
||||||
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
|
`/v1/menu-resources/${encodeURIComponent(id)}`,
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
.put<ApiResponse<ApiPermissionResponseDto>>(
|
|
||||||
`/v1/permissions/${encodeURIComponent(id)}`,
|
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.then(unwrapApiResponse)
|
|
||||||
.then(mapPermission)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deletePermission(id: string): Promise<void> {
|
|
||||||
if (useMockData) {
|
|
||||||
return Promise.reject(new Error('Mock 模式不执行 API 权限写操作'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
.delete<ApiResponse<null>>(`/v1/permissions/${encodeURIComponent(id)}`)
|
|
||||||
.then((response) => {
|
|
||||||
unwrapNullableApiResponse(response)
|
unwrapNullableApiResponse(response)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export async function setPermissionResourceStatus(id: string, enabled: boolean): Promise<void> {
|
||||||
|
const response = await request.put<ApiResponse<null>>(
|
||||||
|
`/v1/menu-resources/${encodeURIComponent(id)}/status`,
|
||||||
|
{ enabled },
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sortPermissionResource(id: string, sortNo: number): Promise<void> {
|
||||||
|
const response = await request.put<ApiResponse<null>>(
|
||||||
|
`/v1/menu-resources/${encodeURIComponent(id)}/sort`,
|
||||||
|
{ sortNo },
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePermissionResource(id: string, cascade = false): Promise<void> {
|
||||||
|
const response = await request.delete<ApiResponse<null>>(
|
||||||
|
`/v1/menu-resources/${encodeURIComponent(id)}`,
|
||||||
|
{ params: { cascade } },
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,64 @@
|
|||||||
import { unwrapApiResponse } from '@/utils/api-response'
|
import { unwrapApiResponse, unwrapNullableApiResponse } from '@/utils/api-response'
|
||||||
import { mockFlags } from '@/utils/mock-flags'
|
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
import type { ApiResponse } from '@/types/common'
|
import type { ApiResponse } from '@/types/common'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BackendCollection,
|
BackendCollection,
|
||||||
CreateRoleRequest,
|
CreateRoleRequest,
|
||||||
RolePermissionsResponseDto,
|
PermissionResourceNodeDto,
|
||||||
RolePermissionsUpdateRequest,
|
RoleDataScopeRequest,
|
||||||
|
RoleDataScopeResponseDto,
|
||||||
RoleRecord,
|
RoleRecord,
|
||||||
RoleQuery,
|
|
||||||
RoleResponseDto,
|
RoleResponseDto,
|
||||||
RoleSummaryResponseDto,
|
|
||||||
UpdateRoleRequest,
|
UpdateRoleRequest,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const useMockData = mockFlags.users
|
|
||||||
|
|
||||||
const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d']
|
const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d']
|
||||||
|
|
||||||
function normalizeRole(dto: RoleResponseDto, index: number, userCount?: number | null): RoleRecord {
|
function normalizeRole(dto: RoleResponseDto, index: number): RoleRecord {
|
||||||
return {
|
return {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
code: dto.code,
|
code: dto.code,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description ?? '—',
|
description: dto.description ?? '',
|
||||||
color: fallbackColors[index % fallbackColors.length],
|
color: fallbackColors[index % fallbackColors.length],
|
||||||
userCount: userCount ?? undefined,
|
status: dto.status ?? 'DISABLED',
|
||||||
status: dto.status ?? 'ENABLED',
|
menuPermissionCount: dto.menuPermissionCount ?? 0,
|
||||||
|
buttonPermissionCount: dto.buttonPermissionCount ?? 0,
|
||||||
|
dataScope: dto.dataScope ?? 'SELF',
|
||||||
|
configured: dto.configured ?? false,
|
||||||
createdAt: dto.createdAt,
|
createdAt: dto.createdAt,
|
||||||
updatedAt: dto.updatedAt,
|
updatedAt: dto.updatedAt,
|
||||||
version: dto.version ?? undefined,
|
version: dto.version ?? undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRoleSummary(dto: RoleSummaryResponseDto, index: number): RoleRecord {
|
function normalizeCollection<T>(collection: BackendCollection<T>): T[] {
|
||||||
return normalizeRole(
|
return Array.isArray(collection)
|
||||||
{
|
? collection
|
||||||
id: dto.roleId,
|
: (collection.records ?? collection.items ?? collection.content ?? [])
|
||||||
code: dto.code,
|
}
|
||||||
name: dto.name,
|
|
||||||
description: dto.description,
|
export async function getRoles(): Promise<RoleRecord[]> {
|
||||||
status: dto.status,
|
const response = await request.get<ApiResponse<BackendCollection<RoleResponseDto>>>('/v1/roles')
|
||||||
},
|
return normalizeCollection(unwrapApiResponse(response)).map(normalizeRole)
|
||||||
index,
|
}
|
||||||
dto.userCount,
|
|
||||||
|
export async function getAssignableRoles(): Promise<RoleRecord[]> {
|
||||||
|
const response =
|
||||||
|
await request.get<ApiResponse<BackendCollection<RoleResponseDto>>>('/v1/roles/assignable')
|
||||||
|
return normalizeCollection(unwrapApiResponse(response)).map(normalizeRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRolePermissionTree(): Promise<PermissionResourceNodeDto[]> {
|
||||||
|
const response = await request.get<ApiResponse<PermissionResourceNodeDto[]>>(
|
||||||
|
'/v1/roles/permission-tree',
|
||||||
)
|
)
|
||||||
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBackendQuery(query: RoleQuery) {
|
export async function getRolePermissions(roleId: string): Promise<string[]> {
|
||||||
const params: Record<string, string | number> = {
|
const response = await request.get<ApiResponse<string[]>>(
|
||||||
page: Math.max(query.page, 1),
|
|
||||||
size: Math.min(Math.max(query.pageSize, 1), 200),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.keyword?.trim()) {
|
|
||||||
params.keyword = query.keyword.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoles(query: RoleQuery = { page: 1, pageSize: 200 }): Promise<RoleRecord[]> {
|
|
||||||
if (useMockData) {
|
|
||||||
return Promise.resolve([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
.get<ApiResponse<BackendCollection<RoleResponseDto>>>('/v1/roles', {
|
|
||||||
params: toBackendQuery(query),
|
|
||||||
})
|
|
||||||
.then(unwrapApiResponse)
|
|
||||||
.then((data) => {
|
|
||||||
const records = Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: (data.records ?? data.items ?? data.content ?? [])
|
|
||||||
return records.map((role, index) => normalizeRole(role, index))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoleSummaries(campusId?: string): Promise<RoleRecord[]> {
|
|
||||||
if (useMockData) {
|
|
||||||
return Promise.resolve([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
.get<ApiResponse<BackendCollection<RoleSummaryResponseDto>>>('/v1/roles/summary', {
|
|
||||||
params: campusId ? { campusId } : undefined,
|
|
||||||
})
|
|
||||||
.then(unwrapApiResponse)
|
|
||||||
.then((data) => {
|
|
||||||
const records = Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: (data.records ?? data.items ?? data.content ?? [])
|
|
||||||
return records.map(normalizeRoleSummary)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRolePermissions(roleId: string): Promise<RolePermissionsResponseDto> {
|
|
||||||
if (useMockData) {
|
|
||||||
return {
|
|
||||||
roleId,
|
|
||||||
permissions: [],
|
|
||||||
version: 0,
|
|
||||||
updatedAt: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await request.get<ApiResponse<RolePermissionsResponseDto>>(
|
|
||||||
`/v1/roles/${encodeURIComponent(roleId)}/permissions`,
|
`/v1/roles/${encodeURIComponent(roleId)}/permissions`,
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
@@ -115,51 +66,55 @@ export async function getRolePermissions(roleId: string): Promise<RolePermission
|
|||||||
|
|
||||||
export async function updateRolePermissions(
|
export async function updateRolePermissions(
|
||||||
roleId: string,
|
roleId: string,
|
||||||
payload: RolePermissionsUpdateRequest,
|
permissionIds: string[],
|
||||||
): Promise<RolePermissionsResponseDto> {
|
): Promise<void> {
|
||||||
if (useMockData) {
|
const response = await request.put<ApiResponse<null>>(
|
||||||
throw new Error('Mock 模式不执行角色权限写操作')
|
`/v1/roles/${encodeURIComponent(roleId)}/permissions`,
|
||||||
|
{ permissionIds },
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await request.put<ApiResponse<RolePermissionsResponseDto>>(
|
export async function getRoleDataScope(roleId: string): Promise<RoleDataScopeResponseDto> {
|
||||||
`/v1/roles/${encodeURIComponent(roleId)}/permissions`,
|
const response = await request.get<ApiResponse<RoleDataScopeResponseDto>>(
|
||||||
payload,
|
`/v1/roles/${encodeURIComponent(roleId)}/data-scope`,
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRole(payload: CreateRoleRequest): Promise<RoleRecord> {
|
export async function updateRoleDataScope(
|
||||||
if (useMockData) {
|
roleId: string,
|
||||||
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
|
payload: RoleDataScopeRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await request.put<ApiResponse<null>>(
|
||||||
|
`/v1/roles/${encodeURIComponent(roleId)}/data-scope`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
return request
|
export async function createRole(payload: CreateRoleRequest): Promise<string> {
|
||||||
.post<ApiResponse<RoleResponseDto>>('/v1/roles', payload)
|
const response = await request.post<ApiResponse<{ id: string }>>('/v1/roles', payload)
|
||||||
.then(unwrapApiResponse)
|
return unwrapApiResponse(response).id
|
||||||
.then((role) => normalizeRole(role, 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRole(id: string, payload: UpdateRoleRequest): Promise<RoleRecord> {
|
export async function updateRole(id: string, payload: UpdateRoleRequest): Promise<void> {
|
||||||
if (useMockData) {
|
const response = await request.put<ApiResponse<null>>(
|
||||||
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
|
`/v1/roles/${encodeURIComponent(id)}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
return request
|
export async function setRoleStatus(id: string, enabled: boolean): Promise<void> {
|
||||||
.put<ApiResponse<RoleResponseDto>>(`/v1/roles/${encodeURIComponent(id)}`, payload)
|
const response = await request.put<ApiResponse<null>>(
|
||||||
.then(unwrapApiResponse)
|
`/v1/roles/${encodeURIComponent(id)}/status`,
|
||||||
.then((role) => normalizeRole(role, 0))
|
{ enabled },
|
||||||
|
)
|
||||||
|
unwrapNullableApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRole(id: string): Promise<void> {
|
export async function deleteRole(id: string): Promise<void> {
|
||||||
if (useMockData) {
|
const response = await request.delete<ApiResponse<null>>(`/v1/roles/${encodeURIComponent(id)}`)
|
||||||
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
|
unwrapNullableApiResponse(response)
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
.delete<ApiResponse<null>>(`/v1/roles/${encodeURIComponent(id)}`)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.code !== 0 && response.code !== '0') {
|
|
||||||
throw new Error(response.message || '角色删除失败')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ export interface RoleRecord {
|
|||||||
color?: string
|
color?: string
|
||||||
userCount?: number
|
userCount?: number
|
||||||
status: RoleStatus
|
status: RoleStatus
|
||||||
|
menuPermissionCount: number
|
||||||
|
buttonPermissionCount: number
|
||||||
|
dataScope: RoleDataScopeType
|
||||||
|
configured: boolean
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
version?: number
|
version?: number
|
||||||
@@ -154,83 +158,78 @@ export interface RoleResponseDto {
|
|||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
status?: RoleStatus | null
|
status?: RoleStatus | null
|
||||||
|
menuPermissionCount?: number | null
|
||||||
|
buttonPermissionCount?: number | null
|
||||||
|
dataScope?: RoleDataScopeType | null
|
||||||
|
configured?: boolean | null
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
version?: number | null
|
version?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoleSummaryResponseDto {
|
|
||||||
roleId: string
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
status?: RoleStatus | null
|
|
||||||
userCount?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRoleRequest {
|
export interface CreateRoleRequest {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
status?: RoleStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateRoleRequest = Partial<CreateRoleRequest>
|
export type UpdateRoleRequest = CreateRoleRequest
|
||||||
|
|
||||||
export type ApiPermissionStatus = 'ENABLED' | 'DISABLED'
|
export type RoleDataScopeType = 'ALL' | 'CUSTOM' | 'DEPARTMENT' | 'DEPARTMENT_AND_CHILDREN' | 'SELF'
|
||||||
export type ApiPermissionAction = 'QUERY' | 'CREATE' | 'UPDATE' | 'DISABLE'
|
|
||||||
|
|
||||||
export interface ApiPermissionQuery extends PageQuery {
|
export interface RoleDataScopeResponseDto {
|
||||||
keyword?: string
|
scopeType: RoleDataScopeType
|
||||||
|
campusIds: string[]
|
||||||
|
departmentIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiPermissionRecord {
|
export interface RoleDataScopeRequest {
|
||||||
|
scopeType: RoleDataScopeType
|
||||||
|
campusIds: string[]
|
||||||
|
departmentIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionResourceNodeDto {
|
||||||
id: string
|
id: string
|
||||||
parentId: string | null
|
parentId: string | null
|
||||||
code: string
|
code: string | null
|
||||||
name: string
|
name: string
|
||||||
resourceType: string
|
resourceType: 'DIR' | 'MENU' | 'BUTTON'
|
||||||
resourcePath: string
|
routePath: string | null
|
||||||
action: ApiPermissionAction | string
|
componentPath: string | null
|
||||||
status: ApiPermissionStatus
|
icon: string | null
|
||||||
createdAt: string
|
sortNo: number
|
||||||
updatedAt: string
|
visible: boolean
|
||||||
|
cacheable: boolean
|
||||||
|
status: 'ENABLED' | 'DISABLED'
|
||||||
|
protectedNode: boolean
|
||||||
|
children: PermissionResourceNodeDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiPermissionResponseDto {
|
export interface CreatePermissionResourceRequest {
|
||||||
id: string
|
resourceType: 'DIR' | 'MENU' | 'BUTTON'
|
||||||
parentId?: string | null
|
parentId: string | null
|
||||||
code: string
|
code: string | null
|
||||||
name: string
|
name: string
|
||||||
resourceType: string
|
routePath?: string | null
|
||||||
resourcePath?: string | null
|
componentPath?: string | null
|
||||||
action?: ApiPermissionAction | string | null
|
icon?: string | null
|
||||||
status?: ApiPermissionStatus | null
|
sortNo: number
|
||||||
createdAt?: string | null
|
visible?: boolean
|
||||||
updatedAt?: string | null
|
cacheable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateApiPermissionRequest {
|
export interface UpdatePermissionResourceRequest {
|
||||||
parentId?: string | null
|
code: string | null
|
||||||
code: string
|
|
||||||
name: string
|
name: string
|
||||||
resourceType: string
|
routePath?: string | null
|
||||||
resourcePath?: string
|
componentPath?: string | null
|
||||||
action?: ApiPermissionAction
|
icon?: string | null
|
||||||
status?: ApiPermissionStatus
|
sortNo: number
|
||||||
|
visible?: boolean
|
||||||
|
cacheable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateApiPermissionRequest {
|
|
||||||
parentId?: string | null
|
|
||||||
name: string
|
|
||||||
resourceType: string
|
|
||||||
resourcePath?: string
|
|
||||||
action?: ApiPermissionAction
|
|
||||||
status: ApiPermissionStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApiPermissionListResponse = PageResult<ApiPermissionRecord>
|
|
||||||
|
|
||||||
export interface CampusRecord {
|
export interface CampusRecord {
|
||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
@@ -444,7 +443,6 @@ export interface UserRecord {
|
|||||||
departmentName?: string
|
departmentName?: string
|
||||||
roleNames?: string[]
|
roleNames?: string[]
|
||||||
roleIds?: string[]
|
roleIds?: string[]
|
||||||
dataScope?: string
|
|
||||||
version?: number
|
version?: number
|
||||||
passwordChangeRequired?: boolean
|
passwordChangeRequired?: boolean
|
||||||
}
|
}
|
||||||
@@ -463,7 +461,6 @@ export interface UserResponseDto {
|
|||||||
departmentId?: string | null
|
departmentId?: string | null
|
||||||
departmentName?: string | null
|
departmentName?: string | null
|
||||||
roles?: UserRoleSummaryDto[] | null
|
roles?: UserRoleSummaryDto[] | null
|
||||||
dataScope?: string | null
|
|
||||||
status?: BackendUserStatus | null
|
status?: BackendUserStatus | null
|
||||||
lastLoginAt?: string | null
|
lastLoginAt?: string | null
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
@@ -489,12 +486,9 @@ export interface CreateUserRequest {
|
|||||||
campusId: string
|
campusId: string
|
||||||
departmentId?: string | null
|
departmentId?: string | null
|
||||||
status?: BackendUserStatus
|
status?: BackendUserStatus
|
||||||
dataScope?: BackendDataScope
|
|
||||||
roleIds?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
||||||
export type BackendDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL'
|
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -504,49 +498,13 @@ export interface UpdateUserRequest {
|
|||||||
campusId: string
|
campusId: string
|
||||||
departmentId?: string | null
|
departmentId?: string | null
|
||||||
status: BackendUserStatus
|
status: BackendUserStatus
|
||||||
dataScope: BackendDataScope
|
|
||||||
clearPhone?: boolean
|
clearPhone?: boolean
|
||||||
clearEmail?: boolean
|
clearEmail?: boolean
|
||||||
expectedVersion?: number
|
expectedVersion?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserManagementUpdateRequest {
|
|
||||||
displayName: string
|
|
||||||
employeeNo?: string
|
|
||||||
phone?: string
|
|
||||||
email?: string
|
|
||||||
clearPhone?: boolean
|
|
||||||
clearEmail?: boolean
|
|
||||||
campusId: string
|
|
||||||
departmentId?: string | null
|
|
||||||
status: BackendUserStatus
|
|
||||||
dataScope: BackendDataScope
|
|
||||||
roleIds: string[]
|
|
||||||
expectedVersion?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserRolesResponseDto {
|
|
||||||
userId: string
|
|
||||||
roles: UserRoleSummaryDto[]
|
|
||||||
version: number
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserRolesUpdateRequest {
|
export interface UserRolesUpdateRequest {
|
||||||
roleIds: string[]
|
roleIds: string[]
|
||||||
expectedVersion?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RolePermissionsResponseDto {
|
|
||||||
roleId: string
|
|
||||||
permissions: ApiPermissionResponseDto[]
|
|
||||||
version: number
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RolePermissionsUpdateRequest {
|
|
||||||
permissionIds: string[]
|
|
||||||
expectedVersion?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionPolicyLevel = 'NONE' | 'VIEW' | 'USE' | 'MAINTAIN'
|
export type PermissionPolicyLevel = 'NONE' | 'VIEW' | 'USE' | 'MAINTAIN'
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ import type {
|
|||||||
UserImportCommitRequest,
|
UserImportCommitRequest,
|
||||||
UserImportJobResponseDto,
|
UserImportJobResponseDto,
|
||||||
UserImportPreviewResponseDto,
|
UserImportPreviewResponseDto,
|
||||||
UserManagementUpdateRequest,
|
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
UserQuery,
|
UserQuery,
|
||||||
UserRecord,
|
UserRecord,
|
||||||
UserResponseDto,
|
UserResponseDto,
|
||||||
UserRolesResponseDto,
|
UserRoleSummaryDto,
|
||||||
UserRolesUpdateRequest,
|
UserRolesUpdateRequest,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -106,7 +105,6 @@ function mapUser(dto: UserResponseDto): UserRecord {
|
|||||||
departmentName: dto.departmentName ?? undefined,
|
departmentName: dto.departmentName ?? undefined,
|
||||||
roleNames,
|
roleNames,
|
||||||
roleIds: roles.map((role) => role.id),
|
roleIds: roles.map((role) => role.id),
|
||||||
dataScope: dto.dataScope ?? '—',
|
|
||||||
version: dto.version ?? undefined,
|
version: dto.version ?? undefined,
|
||||||
passwordChangeRequired: dto.passwordChangeRequired,
|
passwordChangeRequired: dto.passwordChangeRequired,
|
||||||
}
|
}
|
||||||
@@ -224,31 +222,12 @@ export function updateUser(id: string, payload: UpdateUserRequest): Promise<User
|
|||||||
.then(mapUser)
|
.then(mapUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUserManagement(
|
export async function getUserRoles(userId: string): Promise<UserRoleSummaryDto[]> {
|
||||||
id: string,
|
|
||||||
payload: UserManagementUpdateRequest,
|
|
||||||
): Promise<UserRecord> {
|
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
return Promise.reject(new Error('Mock 模式不执行用户写操作'))
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return request
|
const response = await request.get<ApiResponse<UserRoleSummaryDto[]>>(
|
||||||
.put<ApiResponse<UserResponseDto>>(`/v1/users/${encodeURIComponent(id)}/management`, payload)
|
|
||||||
.then(unwrapApiResponse)
|
|
||||||
.then(mapUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserRoles(userId: string): Promise<UserRolesResponseDto> {
|
|
||||||
if (useMockData) {
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
roles: [],
|
|
||||||
version: 0,
|
|
||||||
updatedAt: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await request.get<ApiResponse<UserRolesResponseDto>>(
|
|
||||||
`/v1/users/${encodeURIComponent(userId)}/roles`,
|
`/v1/users/${encodeURIComponent(userId)}/roles`,
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
@@ -257,16 +236,16 @@ export async function getUserRoles(userId: string): Promise<UserRolesResponseDto
|
|||||||
export async function updateUserRoles(
|
export async function updateUserRoles(
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: UserRolesUpdateRequest,
|
payload: UserRolesUpdateRequest,
|
||||||
): Promise<UserRolesResponseDto> {
|
): Promise<void> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
return Promise.reject(new Error('Mock 模式不执行用户角色写操作'))
|
return Promise.reject(new Error('Mock 模式不执行用户角色写操作'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await request.put<ApiResponse<UserRolesResponseDto>>(
|
const response = await request.put<ApiResponse<null>>(
|
||||||
`/v1/users/${encodeURIComponent(userId)}/roles`,
|
`/v1/users/${encodeURIComponent(userId)}/roles`,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
return unwrapApiResponse(response)
|
unwrapNullableApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetUserPassword(
|
export async function resetUserPassword(
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { computed, onMounted } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
|
||||||
const pageTitle = computed(() => String(route.meta.title ?? '首页'))
|
const currentResource = computed(() => loginStore.menuForPath(route.path))
|
||||||
const pageGroup = computed(() => String(route.meta.group ?? '工作台'))
|
const pageTitle = computed(() => currentResource.value?.name ?? String(route.meta.title ?? '首页'))
|
||||||
|
const pageGroup = computed(() => {
|
||||||
|
const directory = loginStore.authorization?.navigation.find((item) =>
|
||||||
|
item.children?.some((child) => child.id === currentResource.value?.id),
|
||||||
|
)
|
||||||
|
return directory?.name ?? String(route.meta.group ?? '工作台')
|
||||||
|
})
|
||||||
const selectedCampusId = computed<string>({
|
const selectedCampusId = computed<string>({
|
||||||
get: () => appStore.selectedCampusId,
|
get: () => appStore.selectedCampusId,
|
||||||
set: (id) => appStore.selectCampus(id),
|
set: (id) => appStore.selectCampus(id),
|
||||||
|
|||||||
@@ -3,64 +3,30 @@ import { computed } from 'vue'
|
|||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useLoginStore } from '@/stores/login'
|
import { useLoginStore } from '@/stores/login'
|
||||||
import { mockFlags } from '@/utils/mock-flags'
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
permission?: string
|
|
||||||
available?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuGroup {
|
|
||||||
label: string
|
|
||||||
items: MenuItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginStore = useLoginStore()
|
const loginStore = useLoginStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const menuGroups: MenuGroup[] = [
|
|
||||||
{
|
|
||||||
label: '工作台',
|
|
||||||
items: [
|
|
||||||
{ path: '/workbench/home', label: '首页', permission: 'sign:task:query' },
|
|
||||||
{ path: '/workbench/signing', label: '签署工作台', permission: 'sign:task:query' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '管理',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
path: '/management/documents',
|
|
||||||
label: '文档库管理',
|
|
||||||
permission: 'sign:template:query',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/management/reports',
|
|
||||||
label: '报表分析',
|
|
||||||
permission: 'report:signing:query',
|
|
||||||
},
|
|
||||||
{ path: '/management/users', label: '用户与权限', permission: 'system:user:query' },
|
|
||||||
{
|
|
||||||
path: '/management/document-permissions',
|
|
||||||
label: '文档权限',
|
|
||||||
permission: 'sign:template:permission:query',
|
|
||||||
},
|
|
||||||
{ path: '/management/settings', label: '系统设置', available: mockFlags.settings },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const visibleMenuGroups = computed(() =>
|
const visibleMenuGroups = computed(() =>
|
||||||
menuGroups
|
(loginStore.authorization?.navigation ?? [])
|
||||||
.map((group) => ({
|
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||||
...group,
|
.map((directory) => ({
|
||||||
items: group.items.filter(
|
label: directory.name,
|
||||||
(item) =>
|
items: (directory.children ?? [])
|
||||||
item.available !== false &&
|
.filter(
|
||||||
(!item.permission || loginStore.hasPermission(item.permission)),
|
(menu) =>
|
||||||
),
|
menu.resourceType === 'MENU' &&
|
||||||
|
menu.status === 'ENABLED' &&
|
||||||
|
menu.visible &&
|
||||||
|
Boolean(menu.routePath) &&
|
||||||
|
Boolean(menu.code) &&
|
||||||
|
loginStore.hasPermission(menu.code ?? ''),
|
||||||
|
)
|
||||||
|
.map((menu) => ({
|
||||||
|
path: menu.routePath as string,
|
||||||
|
label: menu.name,
|
||||||
|
icon: menu.icon,
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
.filter((group) => group.items.length),
|
.filter((group) => group.items.length),
|
||||||
)
|
)
|
||||||
@@ -112,7 +78,7 @@ async function handleLogout() {
|
|||||||
class="menu-item"
|
class="menu-item"
|
||||||
active-class="is-active"
|
active-class="is-active"
|
||||||
>
|
>
|
||||||
<span class="menu-dot" aria-hidden="true" />
|
<span class="menu-dot" aria-hidden="true">{{ item.icon?.slice(0, 1) ?? '' }}</span>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,15 +181,21 @@ async function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-dot {
|
.menu-dot {
|
||||||
|
display: grid;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 6px;
|
width: 14px;
|
||||||
height: 6px;
|
height: 14px;
|
||||||
background: transparent;
|
color: #a7c2cc;
|
||||||
border-radius: 50%;
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
place-items: center;
|
||||||
|
background: rgb(255 255 255 / 8%);
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.is-active .menu-dot {
|
.menu-item.is-active .menu-dot {
|
||||||
background: #5ee0a8;
|
color: #fff;
|
||||||
|
background: rgb(255 255 255 / 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-profile {
|
.menu-profile {
|
||||||
|
|||||||
@@ -1,9 +1,69 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import Container from '@/components/layout/Container.vue'
|
import Container from '@/components/layout/Container.vue'
|
||||||
import Header from '@/components/layout/Header.vue'
|
import Header from '@/components/layout/Header.vue'
|
||||||
import Menu from '@/components/layout/Menu.vue'
|
import Menu from '@/components/layout/Menu.vue'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
let authorizationTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let authorizationRefreshing = false
|
||||||
|
|
||||||
|
async function redirectIfUnauthorized() {
|
||||||
|
const resource = loginStore.menuForPath(route.path)
|
||||||
|
if (
|
||||||
|
route.name === 'menu-resource' &&
|
||||||
|
(!resource ||
|
||||||
|
resource.resourceType !== 'MENU' ||
|
||||||
|
resource.status !== 'ENABLED' ||
|
||||||
|
!resource.code ||
|
||||||
|
!loginStore.hasPermission(resource.code))
|
||||||
|
) {
|
||||||
|
await router.replace(loginStore.firstAuthorizedPath ?? '/forbidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthorization() {
|
||||||
|
if (authorizationRefreshing) return
|
||||||
|
authorizationRefreshing = true
|
||||||
|
try {
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
await redirectIfUnauthorized()
|
||||||
|
} catch {
|
||||||
|
// A transient refresh error should not interrupt an otherwise active session.
|
||||||
|
} finally {
|
||||||
|
authorizationRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowFocus() {
|
||||||
|
void refreshAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
authorizationTimer = setInterval(() => void refreshAuthorization(), 15_000)
|
||||||
|
window.addEventListener('focus', handleWindowFocus)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (authorizationTimer) {
|
||||||
|
clearInterval(authorizationTimer)
|
||||||
|
}
|
||||||
|
window.removeEventListener('focus', handleWindowFocus)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.path, loginStore.authorization] as const,
|
||||||
|
() => {
|
||||||
|
if (loginStore.authorization) {
|
||||||
|
void redirectIfUnauthorized()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -4,31 +4,12 @@ import ClinicalLayout from '@/layouts/ClinicalLayout.vue'
|
|||||||
import ChangePasswordView from '@/views/auth/ChangePasswordView.vue'
|
import ChangePasswordView from '@/views/auth/ChangePasswordView.vue'
|
||||||
import ForbiddenView from '@/views/auth/ForbiddenView.vue'
|
import ForbiddenView from '@/views/auth/ForbiddenView.vue'
|
||||||
import LoginView from '@/views/auth/LoginView.vue'
|
import LoginView from '@/views/auth/LoginView.vue'
|
||||||
import DocumentPermissionsView from '@/views/management/document-permissions/index.vue'
|
import MenuResourceView from '@/views/management/MenuResourceView.vue'
|
||||||
import DocumentsView from '@/views/management/documents/index.vue'
|
|
||||||
import ReportsView from '@/views/management/reports/index.vue'
|
|
||||||
import SettingsView from '@/views/management/settings/index.vue'
|
|
||||||
import UserPermissionsView from '@/views/management/users/index.vue'
|
|
||||||
import ConsentTasksView from '@/views/workbench/signing/index.vue'
|
|
||||||
import DashboardView from '@/views/workbench/home/index.vue'
|
|
||||||
import { useLoginStore } from '@/stores/login'
|
import { useLoginStore } from '@/stores/login'
|
||||||
import { readStoredAuthSession } from '@/utils/auth-storage'
|
import { readStoredAuthSession } from '@/utils/auth-storage'
|
||||||
import { mockFlags } from '@/utils/mock-flags'
|
|
||||||
|
|
||||||
const authorizedLandingRoutes = [
|
function firstAuthorizedPath(loginStore: ReturnType<typeof useLoginStore>) {
|
||||||
{ name: 'workbench-home', permission: 'sign:task:query' },
|
return loginStore.firstAuthorizedPath ?? '/forbidden'
|
||||||
{ name: 'workbench-signing', permission: 'sign:task:query' },
|
|
||||||
{ name: 'management-documents', permission: 'sign:template:query' },
|
|
||||||
{ name: 'management-reports', permission: 'report:signing:query' },
|
|
||||||
{ name: 'management-users', permission: 'system:user:query' },
|
|
||||||
{ name: 'management-document-permissions', permission: 'sign:template:permission:query' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function firstAuthorizedRoute(loginStore: ReturnType<typeof useLoginStore>) {
|
|
||||||
return (
|
|
||||||
authorizedLandingRoutes.find((route) => loginStore.hasPermission(route.permission))?.name ??
|
|
||||||
'forbidden'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -56,79 +37,29 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
name: 'clinical',
|
name: 'clinical',
|
||||||
component: ClinicalLayout,
|
component: ClinicalLayout,
|
||||||
redirect: '/workbench/home',
|
redirect: '/dashboard',
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'workbench',
|
path: ':pathMatch(.*)*',
|
||||||
redirect: '/workbench/home',
|
name: 'menu-resource',
|
||||||
children: [
|
component: MenuResourceView,
|
||||||
{
|
meta: { requiresAuth: true },
|
||||||
path: 'home',
|
|
||||||
name: 'workbench-home',
|
|
||||||
component: DashboardView,
|
|
||||||
meta: { title: '首页', group: '工作台', permission: 'sign:task:query' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'signing',
|
|
||||||
name: 'workbench-signing',
|
|
||||||
component: ConsentTasksView,
|
|
||||||
meta: { title: '签署工作台', group: '工作台', permission: 'sign:task:query' },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{ path: '/workbench/home', redirect: '/dashboard' },
|
||||||
path: 'management',
|
{ path: '/workbench/signing', redirect: '/sign/workbench' },
|
||||||
redirect: '/management/documents',
|
{ path: '/management/users', redirect: '/system/org' },
|
||||||
children: [
|
{ path: '/management/roles', redirect: '/system/role' },
|
||||||
{
|
{ path: '/management/documents', redirect: '/doc/library' },
|
||||||
path: 'documents',
|
{ path: '/management/reports', redirect: '/report/index' },
|
||||||
name: 'management-documents',
|
{ path: '/management/settings', redirect: '/system/setting' },
|
||||||
component: DocumentsView,
|
{ path: '/management/document-permissions', redirect: '/system/role' },
|
||||||
meta: { title: '文档库管理', group: '管理', permission: 'sign:template:query' },
|
{ path: '/consent-tasks', redirect: '/sign/workbench' },
|
||||||
},
|
{ path: '/documents', redirect: '/doc/library' },
|
||||||
{
|
{ path: '/reports', redirect: '/report/index' },
|
||||||
path: 'reports',
|
{ path: '/settings', redirect: '/system/setting' },
|
||||||
name: 'management-reports',
|
|
||||||
component: ReportsView,
|
|
||||||
meta: { title: '报表分析', group: '管理', permission: 'report:signing:query' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'users',
|
|
||||||
name: 'management-users',
|
|
||||||
component: UserPermissionsView,
|
|
||||||
meta: {
|
|
||||||
title: '用户与权限',
|
|
||||||
group: '管理',
|
|
||||||
permission: 'system:user:query',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'document-permissions',
|
|
||||||
name: 'management-document-permissions',
|
|
||||||
component: DocumentPermissionsView,
|
|
||||||
meta: {
|
|
||||||
title: '文档权限',
|
|
||||||
group: '管理',
|
|
||||||
permission: 'sign:template:permission:query',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'settings',
|
|
||||||
name: 'management-settings',
|
|
||||||
component: SettingsView,
|
|
||||||
meta: { title: '系统设置', group: '管理' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ path: '/dashboard', redirect: '/workbench/home' },
|
|
||||||
{ path: '/consent-tasks', redirect: '/workbench/signing' },
|
|
||||||
{ path: '/documents', redirect: '/management/documents' },
|
|
||||||
{ path: '/reports', redirect: '/management/reports' },
|
|
||||||
{ path: '/settings', redirect: '/management/settings' },
|
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/workbench/home' },
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,11 +72,9 @@ router.beforeEach(async (to) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return loginStore.user?.passwordChangeRequired
|
||||||
name: loginStore.user?.passwordChangeRequired
|
? { name: 'change-password' }
|
||||||
? 'change-password'
|
: firstAuthorizedPath(loginStore)
|
||||||
: firstAuthorizedRoute(loginStore),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !loginStore.isLoggedIn) {
|
if (to.meta.requiresAuth && !loginStore.isLoggedIn) {
|
||||||
@@ -155,7 +84,10 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresAuth) {
|
if (!to.meta.requiresAuth) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await loginStore.ensureCurrentUser()
|
const user = await loginStore.ensureCurrentUser()
|
||||||
|
|
||||||
@@ -164,16 +96,20 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.passwordChangeRequired && to.name === 'change-password') {
|
if (!user?.passwordChangeRequired && to.name === 'change-password') {
|
||||||
return { name: firstAuthorizedRoute(loginStore) }
|
return firstAuthorizedPath(loginStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.name === 'management-settings' && !mockFlags.settings) {
|
if (to.name === 'menu-resource') {
|
||||||
return { name: 'forbidden' }
|
const resource = loginStore.menuForPath(to.path)
|
||||||
|
if (
|
||||||
|
!resource ||
|
||||||
|
resource.resourceType !== 'MENU' ||
|
||||||
|
resource.status !== 'ENABLED' ||
|
||||||
|
!resource.code ||
|
||||||
|
!loginStore.hasPermission(resource.code)
|
||||||
|
) {
|
||||||
|
return firstAuthorizedPath(loginStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredPermission = to.meta.permission as string | string[] | undefined
|
|
||||||
if (requiredPermission && !loginStore.hasPermission(requiredPermission)) {
|
|
||||||
return { name: firstAuthorizedRoute(loginStore) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -184,9 +120,6 @@ router.beforeEach(async (to) => {
|
|||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
changePassword as changePasswordRequest,
|
changePassword as changePasswordRequest,
|
||||||
|
getCurrentAuthorization,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
login as loginRequest,
|
login as loginRequest,
|
||||||
logout as logoutRequest,
|
logout as logoutRequest,
|
||||||
@@ -11,33 +12,27 @@ import type {
|
|||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
CurrentAuthorization,
|
||||||
|
PermissionResourceNode,
|
||||||
PasswordChangeRequest,
|
PasswordChangeRequest,
|
||||||
} from '@/types/auth'
|
} from '@/types/auth'
|
||||||
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
|
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
|
||||||
|
|
||||||
const dataScopeLabels: Record<AuthenticatedUser['dataScope'], string> = {
|
|
||||||
ALL: '全院',
|
|
||||||
CAMPUS: '院区',
|
|
||||||
DEPARTMENT: '科室',
|
|
||||||
READ_ONLY_ALL: '全院只读',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserContext(user: AuthenticatedUser | null) {
|
|
||||||
return user ? `${dataScopeLabels[user.dataScope]}数据范围` : '医签通用户'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLoginStore = defineStore('login', () => {
|
export const useLoginStore = defineStore('login', () => {
|
||||||
const storedSession = readStoredAuthSession()
|
const storedSession = readStoredAuthSession()
|
||||||
const token = ref(storedSession?.token ?? '')
|
const token = ref(storedSession?.token ?? '')
|
||||||
const expiresAt = ref(storedSession?.expiresAt ?? '')
|
const expiresAt = ref(storedSession?.expiresAt ?? '')
|
||||||
const user = ref<AuthenticatedUser | null>(storedSession?.user ?? null)
|
const user = ref<AuthenticatedUser | null>(storedSession?.user ?? null)
|
||||||
|
const authorization = ref<CurrentAuthorization | null>(null)
|
||||||
const currentUserReady = ref(false)
|
const currentUserReady = ref(false)
|
||||||
let currentUserRequest: Promise<AuthenticatedUser> | null = null
|
let currentUserRequest: Promise<AuthenticatedUser> | null = null
|
||||||
|
let authorizationSyncedAt = 0
|
||||||
|
|
||||||
const isLoggedIn = computed(() => Boolean(token.value))
|
const isLoggedIn = computed(() => Boolean(token.value))
|
||||||
const isReadOnly = computed(() => user.value?.dataScope === 'READ_ONLY_ALL')
|
// Data scope belongs to roles now; it no longer determines whether a user can write.
|
||||||
|
const isReadOnly = computed(() => false)
|
||||||
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
|
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
|
||||||
const department = computed(() => getUserContext(user.value))
|
const department = computed(() => user.value?.departmentName || '医签通用户')
|
||||||
|
|
||||||
function hasPermission(required: string | string[]) {
|
function hasPermission(required: string | string[]) {
|
||||||
const requiredPermissions = Array.isArray(required) ? required : [required]
|
const requiredPermissions = Array.isArray(required) ? required : [required]
|
||||||
@@ -46,12 +41,57 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = user.value?.permissions ?? []
|
const permissions = authorization.value?.permissions ?? user.value?.permissions ?? []
|
||||||
return requiredPermissions.every(
|
const legacyAliases: Record<string, string> = {
|
||||||
(permission) => permissions.includes(permission) || permissions.includes('*'),
|
'sign:task:query': 'sign:task:view',
|
||||||
|
'sign:template:query': 'doc:template:view',
|
||||||
|
'sign:template:create': 'doc:template:create',
|
||||||
|
'sign:template:update': 'doc:template:edit',
|
||||||
|
'sign:template:publish': 'doc:template:publish',
|
||||||
|
'report:signing:query': 'report:sign:view',
|
||||||
|
'report:signing:export': 'report:sign:export',
|
||||||
|
'system:user:query': 'system:org:view',
|
||||||
|
'system:user:create': 'system:org:emp',
|
||||||
|
'system:user:update': 'system:org:emp',
|
||||||
|
'system:user:disable': 'system:org:emp',
|
||||||
|
'system:user:password:reset': 'system:org:pwd',
|
||||||
|
'system:role:permission:query': 'system:role-permission:view',
|
||||||
|
'system:role:permission:update': 'system:role-permission:edit',
|
||||||
|
'audit:log:query': 'audit:log:view',
|
||||||
|
}
|
||||||
|
|
||||||
|
return requiredPermissions.every((permission) => {
|
||||||
|
const alias = legacyAliases[permission]
|
||||||
|
return (
|
||||||
|
permissions.includes(permission) ||
|
||||||
|
(alias !== undefined && permissions.includes(alias)) ||
|
||||||
|
permissions.includes('*')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleMenus = computed(() =>
|
||||||
|
(authorization.value?.navigation ?? [])
|
||||||
|
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||||
|
.flatMap((directory) => directory.children ?? []),
|
||||||
|
)
|
||||||
|
|
||||||
|
function menuForPath(path: string): PermissionResourceNode | null {
|
||||||
|
const normalizedPath = path.replace(/\/$/, '') || '/'
|
||||||
|
return (
|
||||||
|
accessibleMenus.value.find((menu) => menu.routePath?.replace(/\/$/, '') === normalizedPath) ??
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstAuthorizedPath = computed(
|
||||||
|
() =>
|
||||||
|
accessibleMenus.value.find(
|
||||||
|
(menu) =>
|
||||||
|
menu.visible && menu.status === 'ENABLED' && menu.code && hasPermission(menu.code),
|
||||||
|
)?.routePath ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
function hasWritePermission(required: string | string[]) {
|
function hasWritePermission(required: string | string[]) {
|
||||||
return !isReadOnly.value && hasPermission(required)
|
return !isReadOnly.value && hasPermission(required)
|
||||||
}
|
}
|
||||||
@@ -61,6 +101,8 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
token.value = session.token
|
token.value = session.token
|
||||||
expiresAt.value = session.expiresAt
|
expiresAt.value = session.expiresAt
|
||||||
user.value = session.user
|
user.value = session.user
|
||||||
|
authorization.value = null
|
||||||
|
authorizationSyncedAt = 0
|
||||||
currentUserReady.value = false
|
currentUserReady.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +123,8 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
token.value = ''
|
token.value = ''
|
||||||
expiresAt.value = ''
|
expiresAt.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
|
authorization.value = null
|
||||||
|
authorizationSyncedAt = 0
|
||||||
currentUserReady.value = false
|
currentUserReady.value = false
|
||||||
currentUserRequest = null
|
currentUserRequest = null
|
||||||
}
|
}
|
||||||
@@ -92,9 +136,25 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCurrentUser() {
|
async function refreshCurrentUser() {
|
||||||
const currentUser = await getCurrentUser()
|
const [currentUser, currentAuthorization] = await Promise.all([
|
||||||
setCurrentUser(currentUser)
|
getCurrentUser(),
|
||||||
return currentUser
|
getCurrentAuthorization(),
|
||||||
|
])
|
||||||
|
const effectiveUser = { ...currentUser, permissions: currentAuthorization.permissions }
|
||||||
|
authorization.value = currentAuthorization
|
||||||
|
authorizationSyncedAt = Date.now()
|
||||||
|
setCurrentUser(effectiveUser)
|
||||||
|
return effectiveUser
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthorization() {
|
||||||
|
const currentAuthorization = await getCurrentAuthorization()
|
||||||
|
authorization.value = currentAuthorization
|
||||||
|
authorizationSyncedAt = Date.now()
|
||||||
|
if (user.value) {
|
||||||
|
setCurrentUser({ ...user.value, permissions: currentAuthorization.permissions })
|
||||||
|
}
|
||||||
|
return currentAuthorization
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCurrentUser() {
|
async function ensureCurrentUser() {
|
||||||
@@ -102,7 +162,12 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUserReady.value && user.value) {
|
if (
|
||||||
|
currentUserReady.value &&
|
||||||
|
user.value &&
|
||||||
|
authorization.value &&
|
||||||
|
Date.now() - authorizationSyncedAt < 10_000
|
||||||
|
) {
|
||||||
return user.value
|
return user.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +207,8 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
token,
|
token,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
user,
|
user,
|
||||||
|
authorization,
|
||||||
|
accessibleMenus,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
userName,
|
userName,
|
||||||
@@ -149,6 +216,9 @@ export const useLoginStore = defineStore('login', () => {
|
|||||||
login,
|
login,
|
||||||
refreshCurrentUser,
|
refreshCurrentUser,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
|
refreshAuthorization,
|
||||||
|
menuForPath,
|
||||||
|
firstAuthorizedPath,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
hasWritePermission,
|
hasWritePermission,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export type AuthDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL'
|
|
||||||
|
|
||||||
export type AuthUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
export type AuthUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
||||||
|
|
||||||
export interface AuthRoleSummary {
|
export interface AuthRoleSummary {
|
||||||
@@ -33,12 +31,40 @@ export interface AuthenticatedUser {
|
|||||||
displayName: string
|
displayName: string
|
||||||
campusId: string
|
campusId: string
|
||||||
departmentId: string | null
|
departmentId: string | null
|
||||||
dataScope: AuthDataScope
|
campusName?: string | null
|
||||||
permissions: string[]
|
departmentName?: string | null
|
||||||
|
permissions?: string[]
|
||||||
passwordChangeRequired?: boolean
|
passwordChangeRequired?: boolean
|
||||||
roles?: AuthRoleSummary[]
|
roles?: AuthRoleSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionResourceType = 'DIR' | 'MENU' | 'BUTTON'
|
||||||
|
export type PermissionResourceStatus = 'ENABLED' | 'DISABLED'
|
||||||
|
|
||||||
|
export interface PermissionResourceNode {
|
||||||
|
id: string
|
||||||
|
parentId: string | null
|
||||||
|
code: string | null
|
||||||
|
name: string
|
||||||
|
resourceType: PermissionResourceType
|
||||||
|
routePath: string | null
|
||||||
|
componentPath: string | null
|
||||||
|
icon: string | null
|
||||||
|
sortNo: number
|
||||||
|
visible: boolean
|
||||||
|
cacheable: boolean
|
||||||
|
status: PermissionResourceStatus
|
||||||
|
protectedNode: boolean
|
||||||
|
children: PermissionResourceNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentAuthorization {
|
||||||
|
permissions: string[]
|
||||||
|
menus: string[]
|
||||||
|
dataScopes: string[]
|
||||||
|
navigation: PermissionResourceNode[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string
|
token: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
@@ -54,7 +80,6 @@ export interface CurrentUserResponse extends AuthenticatedUser {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
campusName?: string | null
|
campusName?: string | null
|
||||||
departmentName?: string | null
|
|
||||||
version: number
|
version: number
|
||||||
roles: AuthRoleSummary[]
|
roles: AuthRoleSummary[]
|
||||||
passwordChangeRequired: boolean
|
passwordChangeRequired: boolean
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineAsyncComponent } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
|
||||||
|
const componentRegistry: Record<string, Component> = {
|
||||||
|
'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')),
|
||||||
|
'sign/workbench/index': defineAsyncComponent(() => import('@/views/workbench/signing/index.vue')),
|
||||||
|
'system/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')),
|
||||||
|
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
||||||
|
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
||||||
|
'report/index': defineAsyncComponent(() => import('@/views/management/reports/index.vue')),
|
||||||
|
'system/setting/index': defineAsyncComponent(
|
||||||
|
() => import('@/views/management/settings/index.vue'),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = computed(() => loginStore.menuForPath(route.path))
|
||||||
|
const pageComponent = computed(() => {
|
||||||
|
const path = resource.value?.componentPath
|
||||||
|
return path ? (componentRegistry[path] ?? null) : null
|
||||||
|
})
|
||||||
|
const unsupportedMessage = computed(() => {
|
||||||
|
const componentPath = resource.value?.componentPath
|
||||||
|
return componentPath
|
||||||
|
? `前端暂未接入组件「${componentPath}」。资源路由和权限已从菜单资源读取。`
|
||||||
|
: '当前路由没有对应的启用菜单资源。'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<KeepAlive v-if="pageComponent && resource?.cacheable">
|
||||||
|
<component :is="pageComponent" :key="resource?.id" />
|
||||||
|
</KeepAlive>
|
||||||
|
<component :is="pageComponent" v-else-if="pageComponent" />
|
||||||
|
<PagePlaceholder
|
||||||
|
v-else
|
||||||
|
group="菜单资源"
|
||||||
|
:title="resource?.name ?? '页面未接入'"
|
||||||
|
:icon="resource?.icon ?? '◇'"
|
||||||
|
:description="unsupportedMessage"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -123,6 +123,10 @@ function mapRoleRecord(policy: RolePermissionPolicyRecord, index: number): RoleR
|
|||||||
color: ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d'][index % 5],
|
color: ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d'][index % 5],
|
||||||
userCount: policy.userCount ?? undefined,
|
userCount: policy.userCount ?? undefined,
|
||||||
status: 'ENABLED',
|
status: 'ENABLED',
|
||||||
|
menuPermissionCount: 0,
|
||||||
|
buttonPermissionCount: 0,
|
||||||
|
dataScope: 'ALL',
|
||||||
|
configured: true,
|
||||||
version: policy.version,
|
version: policy.version,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,870 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import { getAllDepartments, getCampuses } from '@/api/management/organization'
|
||||||
|
import {
|
||||||
|
createRole,
|
||||||
|
deleteRole,
|
||||||
|
getRoleDataScope,
|
||||||
|
getRolePermissionTree,
|
||||||
|
getRolePermissions,
|
||||||
|
getRoles,
|
||||||
|
setRoleStatus,
|
||||||
|
updateRole,
|
||||||
|
updateRoleDataScope,
|
||||||
|
updateRolePermissions,
|
||||||
|
} from '@/api/management/roles'
|
||||||
|
import type {
|
||||||
|
CampusRecord,
|
||||||
|
DepartmentRecord,
|
||||||
|
PermissionResourceNodeDto,
|
||||||
|
RoleDataScopeType,
|
||||||
|
RoleRecord,
|
||||||
|
} from '@/api/management/types'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const roles = ref<RoleRecord[]>([])
|
||||||
|
const keyword = ref('')
|
||||||
|
const formVisible = ref(false)
|
||||||
|
const formSaving = ref(false)
|
||||||
|
const permissionVisible = ref(false)
|
||||||
|
const permissionLoading = ref(false)
|
||||||
|
const permissionSaving = ref(false)
|
||||||
|
const scopeVisible = ref(false)
|
||||||
|
const scopeLoading = ref(false)
|
||||||
|
const scopeSaving = ref(false)
|
||||||
|
const activeRole = ref<RoleRecord | null>(null)
|
||||||
|
const resourceTree = ref<PermissionResourceNodeDto[]>([])
|
||||||
|
const permissionIds = ref<string[]>([])
|
||||||
|
const campuses = ref<CampusRecord[]>([])
|
||||||
|
const departments = ref<DepartmentRecord[]>([])
|
||||||
|
const scope = reactive<{
|
||||||
|
scopeType: RoleDataScopeType
|
||||||
|
campusIds: string[]
|
||||||
|
departmentIds: string[]
|
||||||
|
}>({ scopeType: 'ALL', campusIds: [], departmentIds: [] })
|
||||||
|
const roleForm = reactive<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
status: 'ENABLED' | 'DISABLED'
|
||||||
|
}>({ id: '', code: '', name: '', description: '', status: 'ENABLED' })
|
||||||
|
|
||||||
|
const canCreateOrEdit = computed(() => loginStore.hasPermission('system:role:edit'))
|
||||||
|
const canChangeStatus = computed(() => loginStore.hasPermission('system:role:status'))
|
||||||
|
const canDelete = computed(() => loginStore.hasPermission('system:role:delete'))
|
||||||
|
const canViewPermissions = computed(() => loginStore.hasPermission('system:role-permission:view'))
|
||||||
|
const canEditPermissions = computed(() => loginStore.hasPermission('system:role-permission:edit'))
|
||||||
|
const canEditScope = computed(() => loginStore.hasPermission('system:role-scope:edit'))
|
||||||
|
const availableDepartments = computed(() =>
|
||||||
|
scope.campusIds.length
|
||||||
|
? departments.value.filter((department) => scope.campusIds.includes(department.campusId))
|
||||||
|
: departments.value,
|
||||||
|
)
|
||||||
|
const filteredRoles = computed(() => {
|
||||||
|
const query = keyword.value.trim().toLocaleLowerCase()
|
||||||
|
if (!query) return roles.value
|
||||||
|
return roles.value.filter(
|
||||||
|
(role) =>
|
||||||
|
role.name.toLocaleLowerCase().includes(query) ||
|
||||||
|
role.code.toLocaleLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const scopeLabels: Record<RoleDataScopeType, string> = {
|
||||||
|
ALL: '全院',
|
||||||
|
CUSTOM: '指定院区 / 科室',
|
||||||
|
DEPARTMENT: '本科室',
|
||||||
|
DEPARTMENT_AND_CHILDREN: '本科室及下级',
|
||||||
|
SELF: '仅本人',
|
||||||
|
}
|
||||||
|
function flattenNodes(nodes: PermissionResourceNodeDto[]): PermissionResourceNodeDto[] {
|
||||||
|
return nodes.flatMap((node) => [node, ...flattenNodes(node.children ?? [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendants(node: PermissionResourceNodeDto): PermissionResourceNodeDto[] {
|
||||||
|
return (node.children ?? []).flatMap((child) => [child, ...descendants(child)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeIsEffectivelyDisabled(node: PermissionResourceNodeDto): boolean {
|
||||||
|
if (node.status === 'DISABLED') return true
|
||||||
|
if (!node.parentId) return false
|
||||||
|
const parent = flattenNodes(resourceTree.value).find((item) => item.id === node.parentId)
|
||||||
|
return parent ? nodeIsEffectivelyDisabled(parent) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionChecked(node: PermissionResourceNodeDto): boolean {
|
||||||
|
if (node.resourceType !== 'DIR') return permissionIds.value.includes(node.id)
|
||||||
|
const activePermissions = flattenNodes([node]).filter(
|
||||||
|
(child) => child.resourceType !== 'DIR' && !nodeIsEffectivelyDisabled(child),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
activePermissions.length > 0 &&
|
||||||
|
activePermissions.every((child) => permissionIds.value.includes(child.id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionIndeterminate(node: PermissionResourceNodeDto): boolean {
|
||||||
|
if (node.resourceType !== 'DIR') return false
|
||||||
|
const activePermissions = flattenNodes([node]).filter(
|
||||||
|
(child) => child.resourceType !== 'DIR' && !nodeIsEffectivelyDisabled(child),
|
||||||
|
)
|
||||||
|
const selectedCount = activePermissions.filter((child) =>
|
||||||
|
permissionIds.value.includes(child.id),
|
||||||
|
).length
|
||||||
|
return selectedCount > 0 && selectedCount < activePermissions.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionDisabled(node: PermissionResourceNodeDto): boolean {
|
||||||
|
if (node.resourceType === 'DIR') {
|
||||||
|
const hasGrantableDescendant = flattenNodes([node]).some(
|
||||||
|
(child) => child.resourceType !== 'DIR' && !nodeIsEffectivelyDisabled(child),
|
||||||
|
)
|
||||||
|
return nodeIsEffectivelyDisabled(node) || !hasGrantableDescendant
|
||||||
|
}
|
||||||
|
return nodeIsEffectivelyDisabled(node) && !permissionIds.value.includes(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
roles.value = await getRoles()
|
||||||
|
} catch {
|
||||||
|
roles.value = []
|
||||||
|
ElMessage.error('角色列表加载失败,请检查当前账号权限')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSystemAdmin(role: RoleRecord) {
|
||||||
|
return role.code === 'SYSTEM_ADMIN'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateForm() {
|
||||||
|
Object.assign(roleForm, { id: '', code: '', name: '', description: '', status: 'ENABLED' })
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditForm(role: RoleRecord) {
|
||||||
|
Object.assign(roleForm, {
|
||||||
|
id: role.id,
|
||||||
|
code: role.code,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
status: role.status,
|
||||||
|
})
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRole() {
|
||||||
|
if (!roleForm.name.trim() || (!roleForm.id && !roleForm.code.trim())) {
|
||||||
|
ElMessage.warning('请填写角色名称和角色编码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!roleForm.id && !/^[A-Z][A-Z0-9_]{1,19}$/.test(roleForm.code.trim())) {
|
||||||
|
ElMessage.warning('角色编码需为 2–20 位大写字母、数字或下划线,且以字母开头')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formSaving.value = true
|
||||||
|
let savedRoleId = roleForm.id
|
||||||
|
let roleDetailsSaved = false
|
||||||
|
let statusChanged = false
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
code: roleForm.code.trim(),
|
||||||
|
name: roleForm.name.trim(),
|
||||||
|
description: roleForm.description.trim(),
|
||||||
|
}
|
||||||
|
if (roleForm.id) {
|
||||||
|
await updateRole(roleForm.id, payload)
|
||||||
|
roleDetailsSaved = true
|
||||||
|
const currentRole = roles.value.find((role) => role.id === roleForm.id)
|
||||||
|
if (currentRole && currentRole.status !== roleForm.status) {
|
||||||
|
await setRoleStatus(roleForm.id, roleForm.status === 'ENABLED')
|
||||||
|
statusChanged = true
|
||||||
|
}
|
||||||
|
ElMessage.success('角色信息已保存')
|
||||||
|
formVisible.value = false
|
||||||
|
await loadRoles()
|
||||||
|
if (statusChanged) await loginStore.refreshAuthorization()
|
||||||
|
} else {
|
||||||
|
const id = await createRole(payload)
|
||||||
|
savedRoleId = id
|
||||||
|
roleDetailsSaved = true
|
||||||
|
if (roleForm.status === 'DISABLED') {
|
||||||
|
await setRoleStatus(id, false)
|
||||||
|
statusChanged = true
|
||||||
|
}
|
||||||
|
formVisible.value = false
|
||||||
|
await loadRoles()
|
||||||
|
const createdRole = roles.value.find((role) => role.id === id)
|
||||||
|
if (createdRole && canViewPermissions.value) {
|
||||||
|
await openPermissionConfig(createdRole)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('角色已创建,请配置角色权限后再启用')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (roleDetailsSaved && savedRoleId) {
|
||||||
|
ElMessage.error('角色信息已保存,但状态更新失败;请在列表中重试状态操作')
|
||||||
|
formVisible.value = false
|
||||||
|
await loadRoles()
|
||||||
|
if (statusChanged) await loginStore.refreshAuthorization()
|
||||||
|
} else {
|
||||||
|
ElMessage.error('角色保存失败,请确认角色编码未重复')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
formSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRoleStatus(role: RoleRecord) {
|
||||||
|
if (!canChangeStatus.value || isSystemAdmin(role)) return
|
||||||
|
const enabled = role.status !== 'ENABLED'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定${enabled ? '启用' : '停用'}角色“${role.name}”吗?`,
|
||||||
|
'确认角色状态',
|
||||||
|
{ type: 'warning', confirmButtonText: enabled ? '启用' : '停用', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
await setRoleStatus(role.id, enabled)
|
||||||
|
ElMessage.success(`角色已${enabled ? '启用' : '停用'}`)
|
||||||
|
await loadRoles()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('角色状态更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRole(role: RoleRecord) {
|
||||||
|
if (!canDelete.value || isSystemAdmin(role)) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除角色“${role.name}”后无法恢复,是否继续?`, '删除角色', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await deleteRole(role.id)
|
||||||
|
ElMessage.success('角色已删除')
|
||||||
|
await loadRoles()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('角色删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPermissionConfig(role: RoleRecord) {
|
||||||
|
if (!canViewPermissions.value) return
|
||||||
|
activeRole.value = role
|
||||||
|
permissionVisible.value = true
|
||||||
|
permissionLoading.value = true
|
||||||
|
try {
|
||||||
|
const [tree, assigned] = await Promise.all([
|
||||||
|
getRolePermissionTree(),
|
||||||
|
getRolePermissions(role.id),
|
||||||
|
])
|
||||||
|
resourceTree.value = tree
|
||||||
|
const nodes = flattenNodes(tree)
|
||||||
|
const selected = new Set<string>()
|
||||||
|
for (const id of assigned) {
|
||||||
|
const node = nodes.find((item) => item.id === id)
|
||||||
|
if (!node) continue
|
||||||
|
if (node.resourceType === 'DIR') {
|
||||||
|
descendants(node)
|
||||||
|
.filter((child) => child.resourceType !== 'DIR')
|
||||||
|
.forEach((child) => selected.add(child.id))
|
||||||
|
} else {
|
||||||
|
selected.add(id)
|
||||||
|
if (node.resourceType === 'BUTTON') {
|
||||||
|
const parent = nodes.find((item) => item.id === node.parentId)
|
||||||
|
if (parent) selected.add(parent.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissionIds.value = [...selected]
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('角色权限加载失败,请检查查看权限')
|
||||||
|
} finally {
|
||||||
|
permissionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermissionToggle(node: PermissionResourceNodeDto, checked: boolean) {
|
||||||
|
const selected = new Set(permissionIds.value)
|
||||||
|
const childPermissions = descendants(node).filter((child) => child.resourceType !== 'DIR')
|
||||||
|
if (node.resourceType === 'DIR') {
|
||||||
|
childPermissions.forEach((child) => {
|
||||||
|
if (!checked) selected.delete(child.id)
|
||||||
|
else if (!nodeIsEffectivelyDisabled(child)) selected.add(child.id)
|
||||||
|
})
|
||||||
|
} else if (node.resourceType === 'MENU') {
|
||||||
|
if (checked && !nodeIsEffectivelyDisabled(node)) selected.add(node.id)
|
||||||
|
else if (!checked) selected.delete(node.id)
|
||||||
|
childPermissions
|
||||||
|
.filter((child) => child.resourceType === 'BUTTON')
|
||||||
|
.forEach((child) => {
|
||||||
|
if (!checked) selected.delete(child.id)
|
||||||
|
else if (!nodeIsEffectivelyDisabled(child)) selected.add(child.id)
|
||||||
|
})
|
||||||
|
} else if (node.resourceType === 'BUTTON') {
|
||||||
|
const parent = flattenNodes(resourceTree.value).find((item) => item.id === node.parentId)
|
||||||
|
if (checked && !nodeIsEffectivelyDisabled(node)) {
|
||||||
|
selected.add(node.id)
|
||||||
|
if (parent && !nodeIsEffectivelyDisabled(parent)) selected.add(parent.id)
|
||||||
|
} else {
|
||||||
|
selected.delete(node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissionIds.value = [...selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermissionChange(node: PermissionResourceNodeDto, event: Event) {
|
||||||
|
const target = event.target
|
||||||
|
if (target instanceof HTMLInputElement) {
|
||||||
|
handlePermissionToggle(node, target.checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermissions() {
|
||||||
|
if (!activeRole.value || !canEditPermissions.value) return
|
||||||
|
permissionSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateRolePermissions(activeRole.value.id, permissionIds.value)
|
||||||
|
ElMessage.success('角色权限已保存')
|
||||||
|
permissionVisible.value = false
|
||||||
|
await loadRoles()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('角色权限保存失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
permissionSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScopeConfig(role: RoleRecord) {
|
||||||
|
if (!canEditScope.value || isSystemAdmin(role)) return
|
||||||
|
activeRole.value = role
|
||||||
|
scopeVisible.value = true
|
||||||
|
scopeLoading.value = true
|
||||||
|
try {
|
||||||
|
const [currentScope, campusOptions, departmentOptions] = await Promise.all([
|
||||||
|
getRoleDataScope(role.id),
|
||||||
|
getCampuses(),
|
||||||
|
getAllDepartments(),
|
||||||
|
])
|
||||||
|
Object.assign(scope, {
|
||||||
|
scopeType: currentScope.scopeType,
|
||||||
|
campusIds: [...currentScope.campusIds],
|
||||||
|
departmentIds: [...currentScope.departmentIds],
|
||||||
|
})
|
||||||
|
campuses.value = campusOptions
|
||||||
|
departments.value = departmentOptions
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('角色数据范围加载失败')
|
||||||
|
} finally {
|
||||||
|
scopeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScope() {
|
||||||
|
if (!activeRole.value || !canEditScope.value) return
|
||||||
|
if (scope.scopeType === 'CUSTOM' && !scope.campusIds.length && !scope.departmentIds.length) {
|
||||||
|
ElMessage.warning('自定义范围至少选择一个院区或科室')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scopeSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateRoleDataScope(activeRole.value.id, {
|
||||||
|
scopeType: scope.scopeType,
|
||||||
|
campusIds: scope.scopeType === 'CUSTOM' ? scope.campusIds : [],
|
||||||
|
departmentIds: scope.scopeType === 'CUSTOM' ? scope.departmentIds : [],
|
||||||
|
})
|
||||||
|
ElMessage.success('角色数据范围已保存')
|
||||||
|
scopeVisible.value = false
|
||||||
|
await loadRoles()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('角色数据范围保存失败')
|
||||||
|
} finally {
|
||||||
|
scopeSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRoles)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="roles-page">
|
||||||
|
<header class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h1>角色权限</h1>
|
||||||
|
<p>集中维护角色信息、菜单操作权限和数据范围;员工只负责绑定角色。</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="canCreateOrEdit" class="primary-button" type="button" @click="openCreateForm">
|
||||||
|
+新增角色
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="page-card">
|
||||||
|
<div class="role-toolbar">
|
||||||
|
<input v-model="keyword" type="search" placeholder="搜索角色名称或编码" />
|
||||||
|
<span>{{ filteredRoles.length }} 个角色</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="role-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>角色名称</th>
|
||||||
|
<th>角色编码</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>菜单权限</th>
|
||||||
|
<th>按钮权限</th>
|
||||||
|
<th>数据范围</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="role in filteredRoles" :key="role.id">
|
||||||
|
<td>
|
||||||
|
<strong>{{ role.name }}</strong
|
||||||
|
><span v-if="isSystemAdmin(role)" class="builtin-tag">内置</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ role.code }}</code>
|
||||||
|
</td>
|
||||||
|
<td>{{ role.description || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="status-tag"
|
||||||
|
:class="role.status === 'ENABLED' ? 'is-enabled' : 'is-disabled'"
|
||||||
|
>{{ role.status === 'ENABLED' ? '启用' : '停用' }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>{{ role.menuPermissionCount }}</td>
|
||||||
|
<td>{{ role.buttonPermissionCount }}</td>
|
||||||
|
<td>{{ scopeLabels[role.dataScope] ?? '未配置' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
v-if="canViewPermissions"
|
||||||
|
class="text-button"
|
||||||
|
type="button"
|
||||||
|
@click="openPermissionConfig(role)"
|
||||||
|
>
|
||||||
|
配置权限
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEditScope"
|
||||||
|
class="text-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSystemAdmin(role)"
|
||||||
|
@click="openScopeConfig(role)"
|
||||||
|
>
|
||||||
|
数据范围
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canCreateOrEdit"
|
||||||
|
class="text-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSystemAdmin(role)"
|
||||||
|
@click="openEditForm(role)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canChangeStatus"
|
||||||
|
class="text-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSystemAdmin(role)"
|
||||||
|
@click="toggleRoleStatus(role)"
|
||||||
|
>
|
||||||
|
{{ role.status === 'ENABLED' ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canDelete"
|
||||||
|
class="text-button danger"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSystemAdmin(role)"
|
||||||
|
@click="removeRole(role)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!loading && !filteredRoles.length">
|
||||||
|
<td colspan="8" class="empty-cell">
|
||||||
|
{{ roles.length ? '没有匹配的角色' : '暂无角色' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="loading">
|
||||||
|
<td colspan="8" class="empty-cell">正在加载角色…</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="formVisible"
|
||||||
|
:title="roleForm.id ? '编辑角色' : '新增角色'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="角色名称" required
|
||||||
|
><el-input v-model="roleForm.name" maxlength="80"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="角色编码" required
|
||||||
|
><el-input
|
||||||
|
v-model="roleForm.code"
|
||||||
|
:disabled="Boolean(roleForm.id)"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="例如 CLINIC_MANAGER"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="描述"
|
||||||
|
><el-input v-model="roleForm.description" type="textarea" :rows="3" maxlength="500"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="角色状态">
|
||||||
|
<el-select v-model="roleForm.status" :disabled="!canChangeStatus" class="full-width">
|
||||||
|
<el-option label="启用" value="ENABLED" />
|
||||||
|
<el-option label="停用" value="DISABLED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="formVisible = false">取消</el-button
|
||||||
|
><el-button type="primary" :loading="formSaving" @click="saveRole"
|
||||||
|
>保存</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="permissionVisible"
|
||||||
|
:title="`角色权限 · ${activeRole?.name ?? ''}`"
|
||||||
|
size="680px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="permissionLoading" class="permission-drawer">
|
||||||
|
<p class="help-text">
|
||||||
|
目录选择会包含全部下级;选择菜单会包含其按钮;单独选择按钮会同时保留所属菜单。
|
||||||
|
</p>
|
||||||
|
<div class="permission-tree">
|
||||||
|
<section v-for="directory in resourceTree" :key="directory.id" class="permission-group">
|
||||||
|
<label class="permission-row directory-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isPermissionChecked(directory)"
|
||||||
|
:indeterminate="isPermissionIndeterminate(directory)"
|
||||||
|
:disabled="!canEditPermissions || isPermissionDisabled(directory)"
|
||||||
|
@change="handlePermissionChange(directory, $event)"
|
||||||
|
/>
|
||||||
|
<span>{{ directory.name }}</span>
|
||||||
|
<small>目录</small>
|
||||||
|
<em v-if="nodeIsEffectivelyDisabled(directory)">已停用</em>
|
||||||
|
</label>
|
||||||
|
<div v-for="menu in directory.children ?? []" :key="menu.id" class="permission-branch">
|
||||||
|
<label class="permission-row menu-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isPermissionChecked(menu)"
|
||||||
|
:disabled="!canEditPermissions || isPermissionDisabled(menu)"
|
||||||
|
@change="handlePermissionChange(menu, $event)"
|
||||||
|
/>
|
||||||
|
<span>{{ menu.name }}</span>
|
||||||
|
<small>菜单</small>
|
||||||
|
<em v-if="nodeIsEffectivelyDisabled(menu)">已停用</em>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-for="button in menu.children ?? []"
|
||||||
|
:key="button.id"
|
||||||
|
class="permission-row button-row"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isPermissionChecked(button)"
|
||||||
|
:disabled="!canEditPermissions || isPermissionDisabled(button)"
|
||||||
|
@change="handlePermissionChange(button, $event)"
|
||||||
|
/>
|
||||||
|
<span>{{ button.name }}</span>
|
||||||
|
<small>按钮</small>
|
||||||
|
<em v-if="nodeIsEffectivelyDisabled(button)">已停用</em>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p v-if="!resourceTree.length" class="empty-cell">暂无可配置菜单资源</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="permissionVisible = false">取消</el-button
|
||||||
|
><el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!canEditPermissions"
|
||||||
|
:loading="permissionSaving"
|
||||||
|
@click="savePermissions"
|
||||||
|
>保存权限</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="scopeVisible"
|
||||||
|
:title="`数据范围 · ${activeRole?.name ?? ''}`"
|
||||||
|
width="560px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="scopeLoading" class="scope-form">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="范围类型">
|
||||||
|
<el-select v-model="scope.scopeType" class="full-width">
|
||||||
|
<el-option label="全院" value="ALL" />
|
||||||
|
<el-option label="指定院区 / 科室" value="CUSTOM" />
|
||||||
|
<el-option label="本科室" value="DEPARTMENT" />
|
||||||
|
<el-option label="本科室及下级" value="DEPARTMENT_AND_CHILDREN" />
|
||||||
|
<el-option label="仅本人" value="SELF" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<template v-if="scope.scopeType === 'CUSTOM'">
|
||||||
|
<el-form-item label="院区"
|
||||||
|
><el-select
|
||||||
|
v-model="scope.campusIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
class="full-width"
|
||||||
|
@change="
|
||||||
|
scope.departmentIds = scope.departmentIds.filter((id) =>
|
||||||
|
availableDepartments.some((department) => department.id === id),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><el-option
|
||||||
|
v-for="campus in campuses"
|
||||||
|
:key="campus.id"
|
||||||
|
:label="campus.name"
|
||||||
|
:value="campus.id" /></el-select
|
||||||
|
></el-form-item>
|
||||||
|
<el-form-item label="科室"
|
||||||
|
><el-select
|
||||||
|
v-model="scope.departmentIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
class="full-width"
|
||||||
|
><el-option
|
||||||
|
v-for="department in availableDepartments"
|
||||||
|
:key="department.id"
|
||||||
|
:label="department.name"
|
||||||
|
:value="department.id" /></el-select
|
||||||
|
></el-form-item>
|
||||||
|
</template>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="scopeVisible = false">取消</el-button
|
||||||
|
><el-button type="primary" :loading="scopeSaving" @click="saveScope"
|
||||||
|
>保存</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roles-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.page-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
.page-heading p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.page-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
.role-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.role-toolbar input {
|
||||||
|
width: min(360px, 100%);
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.role-toolbar span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.role-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1040px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.role-table th {
|
||||||
|
padding: 11px 10px;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #f5f9fa;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.role-table td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-bottom: 1px solid #eef4f6;
|
||||||
|
}
|
||||||
|
.role-table td code {
|
||||||
|
color: #627c88;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.builtin-tag,
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 7px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.builtin-tag {
|
||||||
|
color: #7257bd;
|
||||||
|
background: #f1edff;
|
||||||
|
}
|
||||||
|
.is-enabled {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
.is-disabled {
|
||||||
|
color: #819099;
|
||||||
|
background: #edf1f2;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.text-button {
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.text-button.danger {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
.text-button:disabled {
|
||||||
|
color: #a7b1b5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.primary-button {
|
||||||
|
padding: 8px 13px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--brand);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.empty-cell {
|
||||||
|
padding: 40px !important;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.permission-drawer {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
.permission-tree {
|
||||||
|
max-height: calc(100vh - 250px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.permission-group {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #eef4f6;
|
||||||
|
}
|
||||||
|
.permission-branch {
|
||||||
|
margin-left: 23px;
|
||||||
|
}
|
||||||
|
.permission-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.permission-row input {
|
||||||
|
accent-color: var(--brand);
|
||||||
|
}
|
||||||
|
.permission-row small {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.permission-row em {
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.permission-row:has(input:disabled) {
|
||||||
|
color: #8b989e;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.button-row {
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
.directory-row {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.help-text {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.resource-node {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.resource-node small {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.resource-node em {
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPermissionResource,
|
||||||
|
deletePermissionResource,
|
||||||
|
getPermissionResources,
|
||||||
|
setPermissionResourceStatus,
|
||||||
|
sortPermissionResource,
|
||||||
|
updatePermissionResource,
|
||||||
|
} from '@/api/management/permissions'
|
||||||
|
import type {
|
||||||
|
CreatePermissionResourceRequest,
|
||||||
|
PermissionResourceNodeDto,
|
||||||
|
UpdatePermissionResourceRequest,
|
||||||
|
} from '@/api/management/types'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const tree = ref<PermissionResourceNodeDto[]>([])
|
||||||
|
const selectedId = ref('')
|
||||||
|
const expanded = ref(true)
|
||||||
|
const treeKey = ref(0)
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const form = ref({
|
||||||
|
resourceType: 'DIR' as PermissionResourceNodeDto['resourceType'],
|
||||||
|
parentId: null as string | null,
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
routePath: '',
|
||||||
|
componentPath: '',
|
||||||
|
icon: '',
|
||||||
|
sortNo: 1,
|
||||||
|
status: 'ENABLED' as 'ENABLED' | 'DISABLED',
|
||||||
|
visible: true,
|
||||||
|
cacheable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canManage = computed(() => loginStore.hasPermission('system:config:menu'))
|
||||||
|
const currentNode = computed(
|
||||||
|
() => flatten(tree.value).find((node) => node.id === selectedId.value) ?? null,
|
||||||
|
)
|
||||||
|
const currentParentName = computed(() => {
|
||||||
|
const node = currentNode.value
|
||||||
|
if (!node?.parentId) return '根目录'
|
||||||
|
return flatten(tree.value).find((item) => item.id === node.parentId)?.name ?? '—'
|
||||||
|
})
|
||||||
|
const formParentName = computed(() => {
|
||||||
|
if (!form.value.parentId) return '根目录'
|
||||||
|
return flatten(tree.value).find((node) => node.id === form.value.parentId)?.name ?? '—'
|
||||||
|
})
|
||||||
|
const editorTitle = computed(
|
||||||
|
() => `${editingId.value ? '编辑' : '新增'}${typeName(form.value.resourceType)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
function flatten(nodes: PermissionResourceNodeDto[]): PermissionResourceNodeDto[] {
|
||||||
|
return nodes.flatMap((node) => [node, ...flatten(node.children ?? [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyDisabled(node: PermissionResourceNodeDto): boolean {
|
||||||
|
if (node.status === 'DISABLED') return true
|
||||||
|
if (!node.parentId) return false
|
||||||
|
const parent = flatten(tree.value).find((item) => item.id === node.parentId)
|
||||||
|
return parent ? isEffectivelyDisabled(parent) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeName(type: PermissionResourceNodeDto['resourceType']) {
|
||||||
|
return type === 'DIR' ? '目录' : type === 'MENU' ? '菜单' : '按钮'
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNode(node: PermissionResourceNodeDto) {
|
||||||
|
selectedId.value = node.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
if (!canManage.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tree.value = await getPermissionResources()
|
||||||
|
if (!flatten(tree.value).some((node) => node.id === selectedId.value)) {
|
||||||
|
selectedId.value = tree.value[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
treeKey.value += 1
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('菜单资源加载失败,请检查菜单配置权限')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(type: PermissionResourceNodeDto['resourceType'], parentId: string | null) {
|
||||||
|
editingId.value = ''
|
||||||
|
const siblings = parentId
|
||||||
|
? (flatten(tree.value).find((node) => node.id === parentId)?.children ?? [])
|
||||||
|
: tree.value
|
||||||
|
form.value = {
|
||||||
|
resourceType: type,
|
||||||
|
parentId,
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
routePath: '',
|
||||||
|
componentPath: '',
|
||||||
|
icon: '',
|
||||||
|
sortNo: siblings.length + 1,
|
||||||
|
status: 'ENABLED',
|
||||||
|
visible: type === 'MENU',
|
||||||
|
cacheable: false,
|
||||||
|
}
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(node: PermissionResourceNodeDto) {
|
||||||
|
editingId.value = node.id
|
||||||
|
form.value = {
|
||||||
|
resourceType: node.resourceType,
|
||||||
|
parentId: node.parentId,
|
||||||
|
code: node.code ?? '',
|
||||||
|
name: node.name,
|
||||||
|
routePath: node.routePath ?? '',
|
||||||
|
componentPath: node.componentPath ?? '',
|
||||||
|
icon: node.icon ?? '',
|
||||||
|
sortNo: node.sortNo,
|
||||||
|
status: node.status,
|
||||||
|
visible: node.visible,
|
||||||
|
cacheable: node.cacheable,
|
||||||
|
}
|
||||||
|
editorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!form.value.name.trim()) return '请输入资源名称'
|
||||||
|
if (form.value.resourceType !== 'DIR' && !form.value.code.trim()) return '请输入权限标识'
|
||||||
|
if (
|
||||||
|
form.value.resourceType === 'MENU' &&
|
||||||
|
(!form.value.routePath.trim() || !form.value.componentPath.trim())
|
||||||
|
) {
|
||||||
|
return '菜单必须配置路由和组件路径'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.value.code &&
|
||||||
|
!/^[a-z][a-z0-9_-]*:[a-z0-9_-]+:[a-z0-9_-]+$/.test(form.value.code.trim())
|
||||||
|
) {
|
||||||
|
return '权限标识格式应为 domain:resource:action'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResource() {
|
||||||
|
const validationError = validateForm()
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.warning(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
const existingNode = editingId.value
|
||||||
|
? flatten(tree.value).find((node) => node.id === editingId.value)
|
||||||
|
: undefined
|
||||||
|
const oldStatus = existingNode?.status ?? 'ENABLED'
|
||||||
|
let resourceSaved = false
|
||||||
|
let statusChanged = false
|
||||||
|
const shared = {
|
||||||
|
code: form.value.resourceType === 'DIR' ? null : form.value.code.trim(),
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
routePath: form.value.resourceType === 'MENU' ? form.value.routePath.trim() : null,
|
||||||
|
componentPath: form.value.resourceType === 'MENU' ? form.value.componentPath.trim() : null,
|
||||||
|
icon: form.value.icon.trim() || null,
|
||||||
|
sortNo: Number(form.value.sortNo),
|
||||||
|
visible: form.value.resourceType === 'MENU' && form.value.visible,
|
||||||
|
cacheable: form.value.resourceType === 'MENU' && form.value.cacheable,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
const payload: UpdatePermissionResourceRequest = shared
|
||||||
|
await updatePermissionResource(editingId.value, payload)
|
||||||
|
resourceSaved = true
|
||||||
|
selectedId.value = editingId.value
|
||||||
|
if (oldStatus !== form.value.status) {
|
||||||
|
await setPermissionResourceStatus(editingId.value, form.value.status === 'ENABLED')
|
||||||
|
statusChanged = true
|
||||||
|
}
|
||||||
|
ElMessage.success('菜单资源已更新')
|
||||||
|
} else {
|
||||||
|
const payload: CreatePermissionResourceRequest = {
|
||||||
|
...shared,
|
||||||
|
resourceType: form.value.resourceType,
|
||||||
|
parentId: form.value.parentId,
|
||||||
|
}
|
||||||
|
const createdId = await createPermissionResource(payload)
|
||||||
|
selectedId.value = createdId
|
||||||
|
editingId.value = createdId
|
||||||
|
resourceSaved = true
|
||||||
|
if (form.value.status === 'DISABLED') {
|
||||||
|
await setPermissionResourceStatus(createdId, false)
|
||||||
|
statusChanged = true
|
||||||
|
}
|
||||||
|
ElMessage.success(`${typeName(form.value.resourceType)}已创建`)
|
||||||
|
}
|
||||||
|
editorVisible.value = false
|
||||||
|
await loadTree()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch {
|
||||||
|
if (resourceSaved) {
|
||||||
|
ElMessage.error('资源已保存,但状态更新失败;请检查状态后重试')
|
||||||
|
await loadTree()
|
||||||
|
if (statusChanged) await loginStore.refreshAuthorization()
|
||||||
|
} else {
|
||||||
|
ElMessage.error('保存失败,请检查权限标识是否重复以及父级资源是否启用')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProtectedDescendant(node: PermissionResourceNodeDto) {
|
||||||
|
return flatten(node.children ?? []).some((child) => child.protectedNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStatus(node: PermissionResourceNodeDto) {
|
||||||
|
const enabled = node.status !== 'ENABLED'
|
||||||
|
if (node.protectedNode || (!enabled && hasProtectedDescendant(node))) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定${enabled ? '启用' : '停用'}${typeName(node.resourceType)}“${node.name}”吗?`,
|
||||||
|
'确认状态变更',
|
||||||
|
{ type: 'warning', confirmButtonText: enabled ? '启用' : '停用', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
await setPermissionResourceStatus(node.id, enabled)
|
||||||
|
ElMessage.success(`${typeName(node.resourceType)}已${enabled ? '启用' : '停用'}`)
|
||||||
|
await loadTree()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('资源状态更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeResource(node: PermissionResourceNodeDto) {
|
||||||
|
if (node.protectedNode || (node.resourceType === 'DIR' && node.children.length > 0)) return
|
||||||
|
const cascade = node.resourceType === 'MENU' && node.children.length > 0
|
||||||
|
if (cascade && hasProtectedDescendant(node)) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
cascade
|
||||||
|
? `删除菜单“${node.name}”会同时删除其下的 ${node.children.length} 个按钮,是否继续?`
|
||||||
|
: `确定删除${typeName(node.resourceType)}“${node.name}”吗?`,
|
||||||
|
'删除菜单资源',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
await deletePermissionResource(node.id, cascade)
|
||||||
|
selectedId.value = node.parentId ?? ''
|
||||||
|
ElMessage.success('菜单资源已删除')
|
||||||
|
await loadTree()
|
||||||
|
await loginStore.refreshAuthorization()
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error('删除失败,请先处理下级资源')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function siblingsOf(node: PermissionResourceNodeDto) {
|
||||||
|
return node.parentId
|
||||||
|
? (flatten(tree.value).find((item) => item.id === node.parentId)?.children ?? [])
|
||||||
|
: tree.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveNode(node: PermissionResourceNodeDto, direction: -1 | 1) {
|
||||||
|
const siblings = [...siblingsOf(node)].sort((a, b) => a.sortNo - b.sortNo)
|
||||||
|
const index = siblings.findIndex((item) => item.id === node.id)
|
||||||
|
const neighbor = siblings[index + direction]
|
||||||
|
if (!neighbor) return
|
||||||
|
const temporarySort = Math.max(...siblings.map((item) => item.sortNo), 0) + siblings.length + 10
|
||||||
|
try {
|
||||||
|
await sortPermissionResource(node.id, temporarySort)
|
||||||
|
await sortPermissionResource(neighbor.id, node.sortNo)
|
||||||
|
await sortPermissionResource(node.id, neighbor.sortNo)
|
||||||
|
await loadTree()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('调整顺序失败')
|
||||||
|
await loadTree()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpansion() {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
treeKey.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTree)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="menu-settings">
|
||||||
|
<header class="menu-toolbar">
|
||||||
|
<div>
|
||||||
|
<strong>菜单资源</strong>
|
||||||
|
<p>菜单资源同时作为导航、路由和角色授权的唯一来源。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button :disabled="!canManage" @click="toggleExpansion">{{
|
||||||
|
expanded ? '全部收起' : '全部展开'
|
||||||
|
}}</el-button>
|
||||||
|
<el-button type="primary" :disabled="!canManage" @click="openCreate('DIR', null)"
|
||||||
|
>新增目录</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="
|
||||||
|
!canManage ||
|
||||||
|
!currentNode ||
|
||||||
|
currentNode.resourceType === 'BUTTON' ||
|
||||||
|
isEffectivelyDisabled(currentNode)
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
currentNode &&
|
||||||
|
openCreate(currentNode.resourceType === 'DIR' ? 'MENU' : 'BUTTON', currentNode.id)
|
||||||
|
"
|
||||||
|
>新增下级</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="resource-workspace">
|
||||||
|
<aside class="resource-tree-card">
|
||||||
|
<el-tree
|
||||||
|
:key="treeKey"
|
||||||
|
:data="tree"
|
||||||
|
:props="{ children: 'children', label: 'name' }"
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="expanded"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
highlight-current
|
||||||
|
:current-node-key="selectedId"
|
||||||
|
@node-click="selectNode"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<span class="tree-node">
|
||||||
|
<span class="type-icon" :class="`type-${data.resourceType.toLowerCase()}`">{{
|
||||||
|
data.resourceType === 'DIR' ? '目' : data.resourceType === 'MENU' ? '页' : '钮'
|
||||||
|
}}</span>
|
||||||
|
<span class="tree-name">{{ data.name }}</span>
|
||||||
|
<small>#{{ data.sortNo }}</small>
|
||||||
|
<em :class="data.status === 'ENABLED' ? 'enabled' : 'disabled'">{{
|
||||||
|
data.status === 'ENABLED' ? '启用' : '停用'
|
||||||
|
}}</em>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
<div v-if="!loading && !tree.length" class="tree-empty">暂无菜单资源</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section v-if="currentNode" class="resource-detail-card">
|
||||||
|
<header class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<span class="detail-type">{{ typeName(currentNode.resourceType) }}</span>
|
||||||
|
<h2>{{ currentNode.name }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button
|
||||||
|
:disabled="!canManage || currentNode.protectedNode"
|
||||||
|
@click="openEdit(currentNode)"
|
||||||
|
>编辑</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
:disabled="
|
||||||
|
!canManage ||
|
||||||
|
currentNode.protectedNode ||
|
||||||
|
(currentNode.status === 'ENABLED' && hasProtectedDescendant(currentNode))
|
||||||
|
"
|
||||||
|
@click="changeStatus(currentNode)"
|
||||||
|
>{{ currentNode.status === 'ENABLED' ? '停用' : '启用' }}</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
:disabled="
|
||||||
|
!canManage ||
|
||||||
|
currentNode.protectedNode ||
|
||||||
|
(currentNode.resourceType === 'DIR' && currentNode.children.length > 0) ||
|
||||||
|
hasProtectedDescendant(currentNode)
|
||||||
|
"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="removeResource(currentNode)"
|
||||||
|
>删除</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<dl class="resource-facts">
|
||||||
|
<div>
|
||||||
|
<dt>权限标识</dt>
|
||||||
|
<dd>{{ currentNode.code ?? '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>上级资源</dt>
|
||||||
|
<dd>
|
||||||
|
{{ currentParentName }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>排序</dt>
|
||||||
|
<dd>
|
||||||
|
{{ currentNode.sortNo }}
|
||||||
|
<span class="sort-actions"
|
||||||
|
><button
|
||||||
|
type="button"
|
||||||
|
:disabled="!canManage"
|
||||||
|
aria-label="上移"
|
||||||
|
@click="moveNode(currentNode, -1)"
|
||||||
|
>
|
||||||
|
↑</button
|
||||||
|
><button
|
||||||
|
type="button"
|
||||||
|
:disabled="!canManage"
|
||||||
|
aria-label="下移"
|
||||||
|
@click="moveNode(currentNode, 1)"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button></span
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentNode.resourceType === 'MENU'">
|
||||||
|
<dt>路由</dt>
|
||||||
|
<dd>{{ currentNode.routePath || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentNode.resourceType === 'MENU'">
|
||||||
|
<dt>组件</dt>
|
||||||
|
<dd>{{ currentNode.componentPath || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>图标</dt>
|
||||||
|
<dd>{{ currentNode.icon || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentNode.resourceType === 'MENU'">
|
||||||
|
<dt>导航显示</dt>
|
||||||
|
<dd>{{ currentNode.visible ? '显示' : '隐藏(仍可通过授权路由访问)' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentNode.resourceType === 'MENU'">
|
||||||
|
<dt>缓存</dt>
|
||||||
|
<dd>{{ currentNode.cacheable ? '开启' : '关闭' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>核心资源</dt>
|
||||||
|
<dd>{{ currentNode.protectedNode ? '是(不可删除或停用)' : '否' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section v-else class="resource-detail-card empty-detail">
|
||||||
|
从左侧选择资源,查看或维护配置。
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="editorVisible" :title="editorTitle" width="560px" destroy-on-close>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="资源类型"
|
||||||
|
><el-input :model-value="typeName(form.resourceType)" disabled
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="上级资源"
|
||||||
|
><el-input :model-value="formParentName" disabled
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="资源名称" required
|
||||||
|
><el-input v-model="form.name" maxlength="100"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item v-if="form.resourceType !== 'DIR'" label="权限标识" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.code"
|
||||||
|
:disabled="Boolean(editingId)"
|
||||||
|
placeholder="如 system:role:view"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.resourceType === 'MENU'" label="路由路径" required
|
||||||
|
><el-input v-model="form.routePath" placeholder="如 /system/role"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item v-if="form.resourceType === 'MENU'" label="组件路径" required
|
||||||
|
><el-input v-model="form.componentPath" placeholder="如 system/role/index"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="图标"
|
||||||
|
><el-input v-model="form.icon" placeholder="图标名称或符号"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="排序"
|
||||||
|
><el-input-number v-model="form.sortNo" :min="0" :max="9999"
|
||||||
|
/></el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="form.status">
|
||||||
|
<el-option label="启用" value="ENABLED" />
|
||||||
|
<el-option label="停用" value="DISABLED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.resourceType === 'MENU'" label="菜单选项">
|
||||||
|
<el-checkbox v-model="form.visible">在侧边导航中显示</el-checkbox>
|
||||||
|
<el-checkbox v-model="form.cacheable">缓存页面</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer
|
||||||
|
><el-button @click="editorVisible = false">取消</el-button
|
||||||
|
><el-button type="primary" :loading="saving" @click="saveResource"
|
||||||
|
>保存</el-button
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-settings {
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
.menu-toolbar,
|
||||||
|
.detail-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.menu-toolbar {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.menu-toolbar strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.menu-toolbar p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.resource-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.85fr) minmax(380px, 1.5fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
.resource-tree-card,
|
||||||
|
.resource-detail-card {
|
||||||
|
min-height: 420px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
}
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.type-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 10px;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.type-button {
|
||||||
|
color: #a06c1c;
|
||||||
|
background: #fff4dd;
|
||||||
|
}
|
||||||
|
.tree-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tree-node small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #98a8ae;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.tree-node em {
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: normal;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.tree-node .enabled {
|
||||||
|
color: #18875d;
|
||||||
|
background: #e7f6ef;
|
||||||
|
}
|
||||||
|
.tree-node .disabled {
|
||||||
|
color: #849198;
|
||||||
|
background: #eef1f2;
|
||||||
|
}
|
||||||
|
.detail-heading {
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.detail-heading h2 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.detail-type {
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.resource-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.resource-facts div {
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid #eef3f5;
|
||||||
|
}
|
||||||
|
.resource-facts dt {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.resource-facts dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.sort-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.sort-actions button {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--brand);
|
||||||
|
background: #f2f9fb;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.sort-actions button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.tree-empty,
|
||||||
|
.empty-detail {
|
||||||
|
display: grid;
|
||||||
|
color: var(--mut);
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.tree-empty {
|
||||||
|
padding: 50px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.resource-workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.menu-toolbar,
|
||||||
|
.detail-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.resource-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
|
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
|
||||||
|
import MenuResourceSettings from './components/MenuResourceSettings.vue'
|
||||||
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
|
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
|
||||||
import SmsSettingsCard from './components/SmsSettingsCard.vue'
|
import SmsSettingsCard from './components/SmsSettingsCard.vue'
|
||||||
import TabletDevicesCard from './components/TabletDevicesCard.vue'
|
import TabletDevicesCard from './components/TabletDevicesCard.vue'
|
||||||
@@ -15,6 +18,12 @@ import {
|
|||||||
import type { SettingToggleKey, SignatureSettings, SmsChannel, TabletDevice } from './types'
|
import type { SettingToggleKey, SignatureSettings, SmsChannel, TabletDevice } from './types'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const activeTab = ref('general')
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
const canManageMenus = computed(() => loginStore.hasPermission('system:config:menu'))
|
||||||
|
watch(canManageMenus, (canManage) => {
|
||||||
|
if (!canManage && activeTab.value === 'menus') activeTab.value = 'general'
|
||||||
|
})
|
||||||
const signatureSettings = reactive<SignatureSettings>({ ...mockSignatureSettings })
|
const signatureSettings = reactive<SignatureSettings>({ ...mockSignatureSettings })
|
||||||
const smsChannel = ref<SmsChannel>(defaultSmsChannel)
|
const smsChannel = ref<SmsChannel>(defaultSmsChannel)
|
||||||
const tabletDevices = ref<TabletDevice[]>([])
|
const tabletDevices = ref<TabletDevice[]>([])
|
||||||
@@ -71,6 +80,8 @@ onMounted(loadSettings)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="settings-page">
|
<section class="settings-page">
|
||||||
|
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||||
|
<el-tab-pane label="系统参数" name="general">
|
||||||
<div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
|
<div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
|
||||||
<span v-for="index in 4" :key="index" />
|
<span v-for="index in 4" :key="index" />
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +101,11 @@ onMounted(loadSettings)
|
|||||||
<TabletDevicesCard :devices="tabletDevices" @reconnect="reconnectDevice" />
|
<TabletDevicesCard :devices="tabletDevices" @reconnect="reconnectDevice" />
|
||||||
<EmbedSettingsCard @copy="copyEmbedUrl" @rotate-key="rotateKey" />
|
<EmbedSettingsCard @copy="copyEmbedUrl" @rotate-key="rotateKey" />
|
||||||
</div>
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane v-if="canManageMenus" label="菜单设置" name="menus">
|
||||||
|
<MenuResourceSettings />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -100,6 +116,10 @@ onMounted(loadSettings)
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-grid,
|
.settings-grid,
|
||||||
.loading-grid {
|
.loading-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import type { ApiPermissionRecord, RoleRecord } from '@/api/management/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
roles: RoleRecord[]
|
|
||||||
permissions: ApiPermissionRecord[]
|
|
||||||
selectedRoleId: string
|
|
||||||
selectedPermissionIds: string[]
|
|
||||||
loading: boolean
|
|
||||||
saving: boolean
|
|
||||||
error: string
|
|
||||||
canEdit?: boolean
|
|
||||||
drawer?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
selectRole: [roleId: string]
|
|
||||||
togglePermission: [permissionId: string]
|
|
||||||
save: []
|
|
||||||
close: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const groupedPermissions = computed(() => {
|
|
||||||
const groups = new Map<string, ApiPermissionRecord[]>()
|
|
||||||
|
|
||||||
for (const permission of props.permissions) {
|
|
||||||
const group = groups.get(permission.resourceType) ?? []
|
|
||||||
group.push(permission)
|
|
||||||
groups.set(permission.resourceType, group)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...groups.entries()]
|
|
||||||
})
|
|
||||||
|
|
||||||
function isSelected(permissionId: string) {
|
|
||||||
return props.selectedPermissionIds.includes(permissionId)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
:class="['api-permission-card', { 'drawer-panel': props.drawer, 'page-card': !props.drawer }]"
|
|
||||||
>
|
|
||||||
<div class="card-heading">
|
|
||||||
<div>
|
|
||||||
<h2>角色 API 权限</h2>
|
|
||||||
<p>维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
|
|
||||||
</div>
|
|
||||||
<span class="heading-spacer" />
|
|
||||||
<button
|
|
||||||
v-if="props.drawer"
|
|
||||||
type="button"
|
|
||||||
class="close-button"
|
|
||||||
aria-label="关闭角色 API 权限"
|
|
||||||
@click="emit('close')"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="props.canEdit !== false"
|
|
||||||
type="button"
|
|
||||||
class="table-button"
|
|
||||||
:disabled="saving || loading || !selectedRoleId"
|
|
||||||
@click="emit('save')"
|
|
||||||
>
|
|
||||||
{{ saving ? '保存中…' : '保存角色权限' }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="readonly-note">当前账号仅可查看</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
|
|
||||||
<div v-else-if="error" class="error-state">{{ error }}</div>
|
|
||||||
<div v-else-if="!roles.length" class="empty-state">暂无可配置角色</div>
|
|
||||||
<div v-else-if="!permissions.length" class="empty-state">
|
|
||||||
暂无 API 权限目录或当前账号无查询权限
|
|
||||||
</div>
|
|
||||||
<div v-else class="api-permission-layout">
|
|
||||||
<label class="role-selector">
|
|
||||||
<span>角色</span>
|
|
||||||
<select
|
|
||||||
:value="selectedRoleId"
|
|
||||||
:disabled="saving"
|
|
||||||
@change="emit('selectRole', ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
|
||||||
{{ role.name }}({{ role.code }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div class="permission-groups">
|
|
||||||
<fieldset v-for="[resourceType, records] in groupedPermissions" :key="resourceType">
|
|
||||||
<legend>{{ resourceType }}</legend>
|
|
||||||
<label v-for="permission in records" :key="permission.id" class="permission-item">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="isSelected(permission.id)"
|
|
||||||
:disabled="saving || props.canEdit === false"
|
|
||||||
@change="emit('togglePermission', permission.id)"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>{{ permission.name }}</strong>
|
|
||||||
<small>
|
|
||||||
{{ permission.code }} · {{ permission.action }}
|
|
||||||
<template v-if="permission.status === 'DISABLED'"> · 已停用</template>
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.api-permission-card {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 必须是 height 而不是 min-height:min-height 只有下限没有上限,
|
|
||||||
权限目录一多,面板会跟着内容一起长高,被抽屉的 overflow: hidden 直接裁掉,
|
|
||||||
内部就没有任何可滚动区域了。 */
|
|
||||||
.drawer-panel {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-heading {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-panel .card-heading {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-heading h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-heading p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
padding: 0 2px;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button:hover {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.readonly-note {
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-button {
|
|
||||||
padding: 5px 10px;
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--brand);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state,
|
|
||||||
.error-state,
|
|
||||||
.empty-state {
|
|
||||||
padding: 28px 16px;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-state {
|
|
||||||
color: var(--err);
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-permission-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
|
||||||
gap: 18px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* min-height: 0 让这个 flex 子项能被压缩到内容高度以下,
|
|
||||||
否则它会撑到内容高度,overflow: auto 永远触发不了 */
|
|
||||||
.drawer-panel .api-permission-layout {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-selector {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-selector span {
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-selector select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: var(--ink);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-groups {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-groups fieldset {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-groups legend {
|
|
||||||
padding: 0 5px;
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 6px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-item input {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-item span {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-item strong {
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-item small {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 10.5px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.api-permission-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RoleSummary } from '../types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
rows: RoleSummary[]
|
|
||||||
interactive?: boolean
|
|
||||||
selectedRoleId?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [role: RoleSummary]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="rows.length" class="role-cards">
|
|
||||||
<button
|
|
||||||
v-for="role in rows"
|
|
||||||
:key="role.id"
|
|
||||||
type="button"
|
|
||||||
class="role-card"
|
|
||||||
:class="{ 'is-selected': selectedRoleId === role.id }"
|
|
||||||
:disabled="!interactive"
|
|
||||||
:aria-label="`${role.name},${interactive ? '配置角色 API 权限' : '无角色权限配置权限'}`"
|
|
||||||
@click="emit('select', role)"
|
|
||||||
>
|
|
||||||
<h3>
|
|
||||||
<span class="role-color" :style="{ backgroundColor: role.color }" aria-hidden="true" />
|
|
||||||
{{ role.name }}
|
|
||||||
</h3>
|
|
||||||
<p>{{ role.description }}</p>
|
|
||||||
<span class="role-count">
|
|
||||||
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
|
|
||||||
</span>
|
|
||||||
<span v-if="interactive" class="role-action">配置角色权限 →</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="role-empty">暂无角色概览</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.role-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card {
|
|
||||||
width: 100%;
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
padding: 13px 14px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
transition:
|
|
||||||
border-color 0.15s ease,
|
|
||||||
box-shadow 0.15s ease,
|
|
||||||
transform 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card:not(:disabled):hover,
|
|
||||||
.role-card.is-selected {
|
|
||||||
border-color: var(--brand);
|
|
||||||
box-shadow: var(--sh-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card h3 {
|
|
||||||
display: flex;
|
|
||||||
gap: 7px;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 13.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-color {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card p {
|
|
||||||
min-height: 35px;
|
|
||||||
margin: 6px 0;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-count {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 9px;
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 11px;
|
|
||||||
background: var(--brand-l);
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-action {
|
|
||||||
display: block;
|
|
||||||
margin-top: 9px;
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-empty {
|
|
||||||
padding: 26px 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
background: #f8fbfc;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.role-cards {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.role-cards {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|||||||
campuses: CampusRecord[]
|
campuses: CampusRecord[]
|
||||||
departments: DepartmentRecord[]
|
departments: DepartmentRecord[]
|
||||||
roles: RoleRecord[]
|
roles: RoleRecord[]
|
||||||
|
canAssignRoles: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -38,7 +39,6 @@ function createEmptyForm(): UserEditorForm {
|
|||||||
campusId: props.campuses[0]?.id ?? '',
|
campusId: props.campuses[0]?.id ?? '',
|
||||||
departmentId: '',
|
departmentId: '',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
dataScope: 'DEPARTMENT',
|
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
clearPhone: false,
|
clearPhone: false,
|
||||||
clearEmail: false,
|
clearEmail: false,
|
||||||
@@ -48,7 +48,13 @@ function createEmptyForm(): UserEditorForm {
|
|||||||
function resetForm() {
|
function resetForm() {
|
||||||
Object.assign(
|
Object.assign(
|
||||||
form,
|
form,
|
||||||
props.initial ? { ...props.initial, roleIds: [...props.initial.roleIds] } : createEmptyForm(),
|
props.initial
|
||||||
|
? {
|
||||||
|
...props.initial,
|
||||||
|
roleIds: [...props.initial.roleIds],
|
||||||
|
roleLabels: [...(props.initial.roleLabels ?? [])],
|
||||||
|
}
|
||||||
|
: createEmptyForm(),
|
||||||
)
|
)
|
||||||
error.message = ''
|
error.message = ''
|
||||||
}
|
}
|
||||||
@@ -97,9 +103,9 @@ function submit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledRole = props.roles.find(
|
const disabledRole = props.canAssignRoles
|
||||||
(role) => form.roleIds.includes(role.id) && role.status === 'DISABLED',
|
? props.roles.find((role) => form.roleIds.includes(role.id) && role.status === 'DISABLED')
|
||||||
)
|
: undefined
|
||||||
|
|
||||||
if (disabledRole) {
|
if (disabledRole) {
|
||||||
error.message = `角色“${disabledRole.name}”已停用,请移除后再保存`
|
error.message = `角色“${disabledRole.name}”已停用,请移除后再保存`
|
||||||
@@ -147,7 +153,13 @@ watch(
|
|||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
|
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
|
||||||
<p>{{ isEditing ? '更新用户基础资料与数据范围' : '创建可登录医签通的医护用户' }}</p>
|
<p>
|
||||||
|
{{
|
||||||
|
isEditing
|
||||||
|
? '更新用户基础资料;权限和数据范围由绑定角色决定'
|
||||||
|
: '创建可登录医签通的医护用户'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||||
×
|
×
|
||||||
@@ -231,16 +243,7 @@ watch(
|
|||||||
<option value="locked">锁定</option>
|
<option value="locked">锁定</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label v-if="canAssignRoles" class="form-grid__full">
|
||||||
<span>数据范围</span>
|
|
||||||
<select v-model="form.dataScope">
|
|
||||||
<option value="ALL">全院</option>
|
|
||||||
<option value="CAMPUS">院区</option>
|
|
||||||
<option value="DEPARTMENT">本科室</option>
|
|
||||||
<option value="READ_ONLY_ALL">全院只读</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="form-grid__full">
|
|
||||||
<span>角色</span>
|
<span>角色</span>
|
||||||
<select v-model="form.roleIds" multiple size="4">
|
<select v-model="form.roleIds" multiple size="4">
|
||||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||||
@@ -251,6 +254,11 @@ watch(
|
|||||||
</select>
|
</select>
|
||||||
<small>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
|
<small>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-else class="form-grid__full">
|
||||||
|
<span>已绑定角色</span>
|
||||||
|
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
||||||
|
<small>员工角色绑定权限由组织管理单独控制。</small>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||||
<p class="form-tip">
|
<p class="form-tip">
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
<th>院区</th>
|
<th>院区</th>
|
||||||
<th>科室</th>
|
<th>科室</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>数据范围</th>
|
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -68,7 +67,6 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
{{ row.role }}
|
{{ row.role }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.dataScope }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="status-tag" :class="'status-' + row.status">
|
<span class="status-tag" :class="'status-' + row.status">
|
||||||
{{ statusLabels[row.status] }}
|
{{ statusLabels[row.status] }}
|
||||||
@@ -103,7 +101,7 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!rows.length">
|
<tr v-if="!rows.length">
|
||||||
<td colspan="8" class="empty-cell">没有找到匹配的用户</td>
|
<td colspan="7" class="empty-cell">没有找到匹配的用户</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,11 @@
|
|||||||
import type { RoleSummary, UserTableRow } from './types'
|
import type { UserTableRow } from './types'
|
||||||
|
|
||||||
export const roleSummaries: RoleSummary[] = [
|
export const roleColors: Record<string, string> = {
|
||||||
{
|
系统管理员: '#6b5bd2',
|
||||||
id: 'system-admin',
|
医务管理: '#0e6e8c',
|
||||||
name: '系统管理员',
|
科室签署员: '#0f9d6c',
|
||||||
color: '#6b5bd2',
|
查询审计员: '#d9821f',
|
||||||
description: '系统配置、用户与权限管理、审计日志',
|
}
|
||||||
userCount: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'medical-management',
|
|
||||||
name: '医务管理',
|
|
||||||
color: '#0e6e8c',
|
|
||||||
description: '全院文档库维护、报表分析、任务监督',
|
|
||||||
userCount: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'department-signer',
|
|
||||||
name: '科室签署员',
|
|
||||||
color: '#0f9d6c',
|
|
||||||
description: '本科室文档发起签署、手写板 / 短信操作',
|
|
||||||
userCount: 23,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'audit-viewer',
|
|
||||||
name: '查询审计员',
|
|
||||||
color: '#d9821f',
|
|
||||||
description: '只读查询报表与审计留痕,不可发起',
|
|
||||||
userCount: 3,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const users: UserTableRow[] = [
|
export const users: UserTableRow[] = [
|
||||||
{
|
{
|
||||||
@@ -39,7 +15,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '医务处',
|
department: '医务处',
|
||||||
role: '医务管理',
|
role: '医务管理',
|
||||||
dataScope: '全院',
|
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,7 +24,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '放射科',
|
department: '放射科',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
dataScope: '本科室',
|
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,7 +33,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '心内科',
|
department: '心内科',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
dataScope: '本科室',
|
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +42,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '东院区',
|
campus: '东院区',
|
||||||
department: '骨科',
|
department: '骨科',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
dataScope: '本科室',
|
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,7 +51,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '信息科',
|
department: '信息科',
|
||||||
role: '系统管理员',
|
role: '系统管理员',
|
||||||
dataScope: '全院',
|
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +60,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '东院区',
|
campus: '东院区',
|
||||||
department: '健康管理中心',
|
department: '健康管理中心',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
dataScope: '本科室',
|
|
||||||
status: 'disabled',
|
status: 'disabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,11 +69,6 @@ export const users: UserTableRow[] = [
|
|||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '纪检监察室',
|
department: '纪检监察室',
|
||||||
role: '查询审计员',
|
role: '查询审计员',
|
||||||
dataScope: '全院只读',
|
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const roleColors = Object.fromEntries(
|
|
||||||
roleSummaries.map((role) => [role.name, role.color]),
|
|
||||||
) as Record<string, string>
|
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import type { BackendDataScope } from '@/api/management/types'
|
|
||||||
|
|
||||||
export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
||||||
|
|
||||||
export interface RoleSummary {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
description: string
|
|
||||||
userCount: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserTableRow {
|
export interface UserTableRow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -24,7 +14,6 @@ export interface UserTableRow {
|
|||||||
role: string
|
role: string
|
||||||
roleNames?: string[]
|
roleNames?: string[]
|
||||||
roleIds?: string[]
|
roleIds?: string[]
|
||||||
dataScope: string
|
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
version?: number
|
version?: number
|
||||||
}
|
}
|
||||||
@@ -42,8 +31,8 @@ export interface UserEditorForm {
|
|||||||
campusId: string
|
campusId: string
|
||||||
departmentId: string
|
departmentId: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
dataScope: BackendDataScope
|
|
||||||
roleIds: string[]
|
roleIds: string[]
|
||||||
|
roleLabels?: string[]
|
||||||
clearPhone?: boolean
|
clearPhone?: boolean
|
||||||
clearEmail?: boolean
|
clearEmail?: boolean
|
||||||
version?: number
|
version?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user