feat: 接入权限接口

This commit is contained in:
xy
2026-09-24 08:31:21 +08:00
parent 93fb5fd2c4
commit e5b5412322
24 changed files with 2398 additions and 1738 deletions
+16 -16
View File
@@ -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 补充用户继承预览;
+6
View File
@@ -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)
+41 -195
View File
@@ -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)
} }
+71 -116
View File
@@ -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 || '角色删除失败')
}
})
} }
+50 -92
View File
@@ -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'
+7 -28
View File
@@ -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(
+10 -2
View File
@@ -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),
+31 -59
View File
@@ -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 {
+61 -1
View File
@@ -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>
+38 -105
View File
@@ -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
+90 -20
View File
@@ -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,
+30 -5
View File
@@ -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