feat: 接入用户与文档权限管理

This commit is contained in:
xy
2026-09-03 18:43:42 +08:00
parent 6d56bc0075
commit f45864e817
24 changed files with 2139 additions and 775 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
VITE_APP_NAME=medical-sign-clinical
VITE_API_BASE_URL=/api
VITE_USE_MOCK=true
# 联调默认走真实后端;仅在演示原型时改为 true。
VITE_USE_MOCK=false
+2 -2
View File
@@ -178,6 +178,6 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。除登录外,当前业务 API 模块默认返回 Mock 数据,设置 `VITE_USE_MOCK=false` 后切换已实现的真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。文档权限和用户权限相关管理接口默认调用真实后端,仅在显式设置 `VITE_USE_MOCK=true` 时使用 Mock 数据。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。默认 Mock 模式仍可用于演示完整交互,联调时设置 `VITE_USE_MOCK=false`。
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。需要演示完整交互时再设置 `VITE_USE_MOCK=true`。
@@ -80,6 +80,8 @@
## 当前核验结论
文档权限页面仅在显式设置 `VITE_USE_MOCK=true` 时使用演示数据;未设置该变量或设置为其他值时,页面调用真实权限接口。修改环境变量后需要重启 Vite。
本轮新增接口已覆盖当前文档权限页面的后端缺口,暂无阻断前端接入的缺失接口。跨模板矩阵查询最多支持 100 个模板,批量保存最多支持 200 项;页面当前采用“模板选择 + 当前模板矩阵”的交互,没有臆造跨模板对比表。
联调验收至少包括:
@@ -6,9 +6,16 @@ import type {
BackendCollection,
BackendPage,
CreateTemplatePermissionRequest,
DepartmentPermissionPolicyRecord,
PermissionListResponse,
PermissionPolicyLevel,
PermissionPoliciesRecord,
PermissionPoliciesResponseDto,
PermissionPolicyDepartmentDto,
PermissionPolicyRoleDto,
PermissionQuery,
PermissionRecord,
RolePermissionPolicyRecord,
TemplatePermissionBatchRequest,
TemplatePermissionBatchResponse,
TemplatePermissionBatchResponseDto,
@@ -19,9 +26,12 @@ import type {
TemplatePermissionMatrixResponse,
TemplatePermissionRecord,
TemplatePermissionResponseDto,
UpdateDepartmentPermissionPolicyRequest,
UpdateRolePermissionPolicyRequest,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示数据,避免联调环境因未注入变量而误入 Mock 分支。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
export const isPermissionMockEnabled = useMockData
@@ -44,6 +54,473 @@ const mockRecords: PermissionRecord[] = [
},
]
const mockPermissionPolicies: PermissionPoliciesRecord = {
campusId: 'mock-main',
roles: [
{
roleId: 'system-admin',
code: 'SYSTEM_ADMIN',
name: '系统管理员',
description: '系统配置、用户与权限管理、审计日志',
userCount: 2,
campusId: 'mock-main',
view: true,
use: true,
maintain: true,
permissionLevel: 'MAINTAIN',
},
{
roleId: 'medical-management',
code: 'MEDICAL_MANAGEMENT',
name: '医务管理',
description: '全院文档库维护、报表分析、任务监督',
userCount: 4,
campusId: 'mock-main',
view: true,
use: true,
maintain: true,
permissionLevel: 'MAINTAIN',
},
{
roleId: 'department-signer',
code: 'DEPARTMENT_SIGNER',
name: '科室签署员',
description: '本科室文档发起签署、手写板 / 短信操作',
userCount: 23,
campusId: 'mock-main',
view: true,
use: true,
maintain: false,
permissionLevel: 'USE',
},
{
roleId: 'audit-viewer',
code: 'AUDIT_VIEWER',
name: '查询审计员',
description: '只读查询报表与审计留痕,不可发起',
userCount: 3,
campusId: 'mock-main',
view: true,
use: false,
maintain: false,
permissionLevel: 'VIEW',
},
],
departments: [
{
departmentId: 'mock-department-radiology',
code: 'RADIOLOGY',
name: '放射科',
campusId: 'mock-main',
own: true,
common: true,
other: false,
ownPermission: 'USE',
commonPermission: 'USE',
otherPermission: 'NONE',
},
{
departmentId: 'mock-department-cardiology',
code: 'CARDIOLOGY',
name: '心内科',
campusId: 'mock-main',
own: true,
common: true,
other: false,
ownPermission: 'USE',
commonPermission: 'USE',
otherPermission: 'NONE',
},
{
departmentId: 'mock-department-orthopedics',
code: 'ORTHOPEDICS',
name: '骨科',
campusId: 'mock-main',
own: true,
common: true,
other: false,
ownPermission: 'USE',
commonPermission: 'USE',
otherPermission: 'NONE',
},
{
departmentId: 'mock-department-health',
code: 'HEALTH_MANAGEMENT',
name: '健康管理中心',
campusId: 'mock-main',
own: true,
common: true,
other: false,
ownPermission: 'USE',
commonPermission: 'USE',
otherPermission: 'NONE',
},
],
templateCount: 2,
}
type PermissionPolicyObject = Record<string, unknown>
function asObject(value: unknown): PermissionPolicyObject {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as PermissionPolicyObject)
: {}
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
function readString(source: PermissionPolicyObject, keys: string[], fallback = '') {
for (const key of keys) {
const value = source[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return fallback
}
function readNumber(source: PermissionPolicyObject, keys: string[]) {
for (const key of keys) {
const value = source[key]
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) {
return Number(value)
}
}
return undefined
}
function readBoolean(source: PermissionPolicyObject, keys: string[]) {
for (const key of keys) {
const value = source[key]
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value !== 0
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (['true', '1', 'yes', 'allow', 'allowed', 'enabled', 'on'].includes(normalized)) {
return true
}
if (['false', '0', 'no', 'deny', 'denied', 'disabled', 'off'].includes(normalized)) {
return false
}
}
if (value !== null && typeof value === 'object') {
const nested = asObject(value)
const nestedValue = nested.enabled ?? nested.allowed ?? nested.value
if (typeof nestedValue === 'boolean') {
return nestedValue
}
}
}
return false
}
function readVersion(source: PermissionPolicyObject) {
return readNumber(source, ['version', 'policyVersion', 'revision'])
}
const policyLevelRank: Record<PermissionPolicyLevel, number> = {
NONE: 0,
VIEW: 1,
USE: 2,
MAINTAIN: 3,
}
function normalizePolicyLevel(value: unknown): PermissionPolicyLevel | undefined {
if (typeof value !== 'string') {
return undefined
}
const level = value.trim().toUpperCase() as PermissionPolicyLevel
return level in policyLevelRank ? level : undefined
}
function policyLevelFromFlags(source: PermissionPolicyObject, keys: string[]) {
let level: PermissionPolicyLevel = 'NONE'
keys.forEach((key, index) => {
if (readBoolean(source, [key])) {
const nextLevel = (['VIEW', 'USE', 'MAINTAIN'] as const)[index]
if (policyLevelRank[nextLevel] > policyLevelRank[level]) {
level = nextLevel
}
}
})
return level
}
function readPolicyLevel(
source: PermissionPolicyObject,
levelKeys: string[],
flagKeys: string[],
): PermissionPolicyLevel {
for (const key of levelKeys) {
const level = normalizePolicyLevel(source[key])
if (level) {
return level
}
}
return policyLevelFromFlags(source, flagKeys)
}
function flagsForPolicyLevel(level: PermissionPolicyLevel) {
const rank = policyLevelRank[level]
return {
view: rank >= policyLevelRank.VIEW,
use: rank >= policyLevelRank.USE,
maintain: rank >= policyLevelRank.MAINTAIN,
}
}
function departmentFlagsForPolicyLevel(level: PermissionPolicyLevel) {
return level !== 'NONE'
}
function mapRolePolicy(
dto: PermissionPolicyRoleDto | PermissionPolicyObject,
fallbackRoleId = '',
fallbackCampusId = '',
): RolePermissionPolicyRecord | null {
const source = asObject(dto)
const roleId = readString(source, ['roleId', 'roleID', 'id'], fallbackRoleId)
if (!roleId) {
return null
}
const permissionLevel = readPolicyLevel(
source,
['permissionLevel', 'maxPermissionLevel', 'level'],
['view', 'use', 'maintain'],
)
return {
roleId,
campusId: readString(source, ['campusId', 'campusID'], fallbackCampusId),
code: readString(source, ['code', 'roleCode'], roleId),
name: readString(source, ['name', 'roleName'], roleId),
description: readString(source, ['description'], '—'),
userCount: readNumber(source, ['userCount', 'users', 'memberCount']) ?? null,
...flagsForPolicyLevel(permissionLevel),
permissionLevel,
version: readVersion(source),
}
}
function mapDepartmentPolicy(
dto: PermissionPolicyDepartmentDto | PermissionPolicyObject,
fallbackCampusId = '',
): DepartmentPermissionPolicyRecord | null {
const source = asObject(dto)
const departmentId = readString(source, ['departmentId', 'departmentID', 'id'])
if (!departmentId) {
return null
}
const ownPermission = readPolicyLevel(
source,
['ownPermission', 'ownLevel'],
['own', 'ownDocuments', 'ownDocument', 'ownEnabled'],
)
const commonPermission = readPolicyLevel(
source,
['commonPermission', 'commonLevel'],
['common', 'commonDocuments', 'commonDocument', 'commonEnabled'],
)
const otherPermission = readPolicyLevel(
source,
['otherPermission', 'otherLevel'],
['other', 'otherDocuments', 'otherDocument', 'otherEnabled'],
)
return {
departmentId,
code: readString(source, ['code', 'departmentCode'], departmentId),
name: readString(source, ['name', 'departmentName'], departmentId),
campusId: readString(source, ['campusId', 'campusID'], fallbackCampusId),
own: departmentFlagsForPolicyLevel(ownPermission),
common: departmentFlagsForPolicyLevel(commonPermission),
other: departmentFlagsForPolicyLevel(otherPermission),
ownPermission,
commonPermission,
otherPermission,
version: readVersion(source),
}
}
function normalizePermissionPolicies(
data: PermissionPoliciesResponseDto | PermissionPolicyObject,
requestedCampusId = '',
): PermissionPoliciesRecord {
const source = asObject(data)
const campusId = readString(source, ['campusId', 'campusID'], requestedCampusId)
const roleDtos = asArray(source.roles ?? source.rolePolicies ?? source.rolePermissionPolicies)
const departmentDtos = asArray(
source.departments ?? source.departmentPolicies ?? source.departmentPermissionPolicies,
)
const roles = roleDtos
.map((role) => mapRolePolicy(role as PermissionPolicyObject, '', campusId))
.filter((role): role is RolePermissionPolicyRecord => Boolean(role))
const departments = departmentDtos
.map((department) => mapDepartmentPolicy(department as PermissionPolicyObject, campusId))
.filter((department): department is DepartmentPermissionPolicyRecord => Boolean(department))
return {
campusId,
roles,
departments,
templateCount: readNumber(source, ['templateCount', 'templatesCount', 'count']) ?? 0,
}
}
function mapUpdatedRolePolicy(
data: PermissionPolicyRoleDto | PermissionPolicyObject,
roleId: string,
previous: RolePermissionPolicyRecord,
) {
return (
mapRolePolicy(data, roleId, previous.campusId) ?? {
...previous,
roleId,
}
)
}
function mapUpdatedDepartmentPolicy(
data: PermissionPolicyDepartmentDto | PermissionPolicyObject,
departmentId: string,
previous: DepartmentPermissionPolicyRecord,
) {
return (
mapDepartmentPolicy(data, previous.campusId) ?? {
...previous,
departmentId,
}
)
}
export async function getPermissionPolicies(campusId?: string): Promise<PermissionPoliciesRecord> {
if (useMockData) {
return {
...mockPermissionPolicies,
campusId: campusId ?? mockPermissionPolicies.campusId,
roles: mockPermissionPolicies.roles.map((role) => ({ ...role })),
departments: mockPermissionPolicies.departments.map((department) => ({ ...department })),
}
}
const response = await request.get<ApiResponse<PermissionPoliciesResponseDto>>(
'/v1/templates/permission-policies',
{ params: campusId ? { campusId } : undefined },
)
return normalizePermissionPolicies(unwrapApiResponse(response), campusId)
}
export async function updateRolePermissionPolicy(
roleId: string,
payload: UpdateRolePermissionPolicyRequest,
previous?: RolePermissionPolicyRecord,
): Promise<RolePermissionPolicyRecord> {
if (useMockData) {
const role = mockPermissionPolicies.roles.find((item) => item.roleId === roleId)
if (!role) {
throw new Error('角色默认策略不存在')
}
Object.assign(role, flagsForPolicyLevel(payload.permissionLevel), {
permissionLevel: payload.permissionLevel,
campusId: payload.campusId,
version: (role.version ?? 0) + 1,
})
return { ...role }
}
const response = await request.put<ApiResponse<PermissionPolicyRoleDto>>(
`/v1/templates/permission-policies/roles/${encodeURIComponent(roleId)}`,
payload,
)
return mapUpdatedRolePolicy(
unwrapApiResponse(response),
roleId,
previous ?? {
roleId,
campusId: payload.campusId,
code: roleId,
name: roleId,
description: '—',
userCount: null,
...flagsForPolicyLevel(payload.permissionLevel),
permissionLevel: payload.permissionLevel,
version: payload.expectedVersion,
},
)
}
export async function updateDepartmentPermissionPolicy(
departmentId: string,
payload: UpdateDepartmentPermissionPolicyRequest,
previous?: DepartmentPermissionPolicyRecord,
): Promise<DepartmentPermissionPolicyRecord> {
if (useMockData) {
const department = mockPermissionPolicies.departments.find(
(item) => item.departmentId === departmentId,
)
if (!department) {
throw new Error('科室默认策略不存在')
}
Object.assign(department, {
own: departmentFlagsForPolicyLevel(payload.ownPermission),
common: departmentFlagsForPolicyLevel(payload.commonPermission),
other: departmentFlagsForPolicyLevel(payload.otherPermission),
ownPermission: payload.ownPermission,
commonPermission: payload.commonPermission,
otherPermission: payload.otherPermission,
campusId: payload.campusId,
version: (department.version ?? 0) + 1,
})
return { ...department }
}
const response = await request.put<ApiResponse<PermissionPolicyDepartmentDto>>(
`/v1/templates/permission-policies/departments/${encodeURIComponent(departmentId)}`,
payload,
)
return mapUpdatedDepartmentPolicy(
unwrapApiResponse(response),
departmentId,
previous ?? {
departmentId,
code: departmentId,
name: departmentId,
campusId: payload.campusId,
own: departmentFlagsForPolicyLevel(payload.ownPermission),
common: departmentFlagsForPolicyLevel(payload.commonPermission),
other: departmentFlagsForPolicyLevel(payload.otherPermission),
ownPermission: payload.ownPermission,
commonPermission: payload.commonPermission,
otherPermission: payload.otherPermission,
version: payload.expectedVersion,
},
)
}
export function getDocumentPermissions(query: PermissionQuery): Promise<PermissionListResponse> {
if (!useMockData) {
return Promise.reject(
+2 -1
View File
@@ -17,7 +17,8 @@ import type {
TemplateVersionResponseDto,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示数据,避免联调环境因未注入变量而误入 Mock 分支。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
export const isDocumentMockEnabled = useMockData
@@ -13,7 +13,8 @@ import type {
UpdateDepartmentRequest,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示数据,避免联调环境因未注入变量而误入 Mock 分支。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
function getRecords(data: BackendCollection<OrganizationResponseDto>) {
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
@@ -14,7 +14,8 @@ import type {
UpdateApiPermissionRequest,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示权限目录。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
export const isApiPermissionMockEnabled = useMockData
+2 -1
View File
@@ -14,7 +14,8 @@ import type {
UpdateRoleRequest,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示数据,避免联调环境因未注入变量而误入 Mock 分支。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d']
+112
View File
@@ -538,6 +538,118 @@ export interface RolePermissionsUpdateRequest {
expectedVersion?: number
}
export type PermissionPolicyLevel = 'NONE' | 'VIEW' | 'USE' | 'MAINTAIN'
/** 默认文档权限策略的后端 DTO。字段保留常见别名,由 API 边界统一为页面模型。 */
export interface PermissionPolicyRoleDto {
roleId?: string | null
id?: string | null
campusId?: string | null
code?: string | null
roleCode?: string | null
name?: string | null
roleName?: string | null
description?: string | null
userCount?: number | null
view?: unknown
canView?: unknown
viewEnabled?: unknown
use?: unknown
canUse?: unknown
useEnabled?: unknown
maintain?: unknown
canMaintain?: unknown
maintainEnabled?: unknown
permissionLevel?: PermissionPolicyLevel | string | null
maxPermissionLevel?: PermissionPolicyLevel | string | null
version?: number | null
}
export interface PermissionPolicyDepartmentDto {
departmentId?: string | null
id?: string | null
code?: string | null
departmentCode?: string | null
name?: string | null
departmentName?: string | null
campusId?: string | null
ownPermission?: PermissionPolicyLevel | string | null
commonPermission?: PermissionPolicyLevel | string | null
otherPermission?: PermissionPolicyLevel | string | null
own?: unknown
ownDocuments?: unknown
ownDocument?: unknown
ownEnabled?: unknown
common?: unknown
commonDocuments?: unknown
commonDocument?: unknown
commonEnabled?: unknown
other?: unknown
otherDocuments?: unknown
otherDocument?: unknown
otherEnabled?: unknown
version?: number | null
}
export interface PermissionPoliciesResponseDto {
campusId?: string | null
roles?: PermissionPolicyRoleDto[] | null
rolePolicies?: PermissionPolicyRoleDto[] | null
departments?: PermissionPolicyDepartmentDto[] | null
departmentPolicies?: PermissionPolicyDepartmentDto[] | null
templateCount?: number | null
templatesCount?: number | null
}
export interface RolePermissionPolicyRecord {
roleId: string
campusId: string
code: string
name: string
description: string
userCount: number | null
view: boolean
use: boolean
maintain: boolean
permissionLevel: PermissionPolicyLevel
version?: number
}
export interface DepartmentPermissionPolicyRecord {
departmentId: string
version?: number
code: string
name: string
campusId: string
own: boolean
common: boolean
other: boolean
ownPermission: PermissionPolicyLevel
commonPermission: PermissionPolicyLevel
otherPermission: PermissionPolicyLevel
}
export interface PermissionPoliciesRecord {
campusId: string
roles: RolePermissionPolicyRecord[]
departments: DepartmentPermissionPolicyRecord[]
templateCount: number
}
export interface UpdateRolePermissionPolicyRequest {
campusId: string
permissionLevel: PermissionPolicyLevel
expectedVersion?: number
}
export interface UpdateDepartmentPermissionPolicyRequest {
campusId: string
ownPermission: PermissionPolicyLevel
commonPermission: PermissionPolicyLevel
otherPermission: PermissionPolicyLevel
expectedVersion?: number
}
export interface PasswordResetRequest {
newPassword: string
forceChangeOnNextLogin?: boolean
+2 -1
View File
@@ -22,7 +22,8 @@ import type {
UserRolesUpdateRequest,
} from './types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示数据,避免联调环境因未注入变量而误入 Mock 分支。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
export const isUserMockEnabled = useMockData
+5 -1
View File
@@ -32,7 +32,11 @@ const menuGroups: MenuGroup[] = [
{ path: '/management/documents', label: '文档库管理' },
{ path: '/management/reports', label: '报表分析' },
{ path: '/management/users', label: '用户与权限', permission: 'system:user:query' },
{ path: '/management/document-permissions', label: '文档权限' },
{
path: '/management/document-permissions',
label: '文档权限',
permission: 'sign:template:permission:query',
},
{ path: '/management/settings', label: '系统设置' },
],
},
+5 -1
View File
@@ -76,7 +76,11 @@ const router = createRouter({
path: 'document-permissions',
name: 'management-document-permissions',
component: DocumentPermissionsView,
meta: { title: '文档权限', group: '管理' },
meta: {
title: '文档权限',
group: '管理',
permission: 'sign:template:permission:query',
},
},
{
path: 'settings',
+3 -1
View File
@@ -5,7 +5,9 @@ import { getCampuses } from '@/api/management/organization'
import type { CampusRecord } from '@/api/management/types'
import type { WorkbenchCampus } from '@/api/workbench/types'
const useMockData = import.meta.env.VITE_USE_MOCK !== 'false'
// 仅显式开启 VITE_USE_MOCK=true 时使用演示院区,避免真实接口联调时把 mock UUID
// 作为 campusId 发给后端。
const useMockData = import.meta.env.VITE_USE_MOCK === 'true'
const mockCampusRecords: CampusRecord[] = [
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
@@ -0,0 +1,475 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
import TemplatePermissionPanel from './TemplatePermissionPanel.vue'
import type {
DepartmentPermissionRow,
PermissionSubjectOption,
TemplatePermissionForm,
} from '../types'
const props = defineProps<{
visible: boolean
department: DepartmentPermissionRow | null
templates: DocumentRecord[]
selectedTemplateId: string
permissions: TemplatePermissionRecord[]
subjects: PermissionSubjectOption[]
policySaving: boolean
templateLoading: boolean
templateCatalogLoading: boolean
templateSaving: boolean
templateError: boolean
templateErrorMessage?: string
templateCatalogError?: string
previewUserId: string
canEditPolicy: boolean
canEditTemplate: boolean
}>()
const emit = defineEmits<{
close: []
savePolicy: [payload: { own: boolean; common: boolean; other: boolean; expectedVersion?: number }]
'update:selectedTemplateId': [value: string]
'update:previewUserId': [value: string]
add: [form: TemplatePermissionForm]
delete: [permissionId: string]
retry: []
}>()
const form = reactive({
own: false,
common: false,
other: false,
})
const departmentTitle = computed(() => props.department?.department ?? '科室权限')
function syncForm() {
form.own = props.department?.own ?? false
form.common = props.department?.common ?? false
form.other = props.department?.other ?? false
}
function savePolicy() {
if (!props.department || props.policySaving || !props.canEditPolicy) {
return
}
emit('savePolicy', {
...form,
expectedVersion: props.department.version,
})
}
watch(
() => [props.visible, props.department] as const,
([visible]) => {
if (visible) {
syncForm()
}
},
{ immediate: true },
)
</script>
<template>
<Teleport to="body">
<div v-if="props.visible" class="drawer-mask" @click.self="emit('close')">
<aside
class="permission-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
>
<header class="drawer-header">
<div>
<strong id="drawer-title">调整{{ departmentTitle }}文档权限</strong>
<p>默认策略适用于当前院区;模板例外仅覆盖指定模板。</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="drawer-body">
<section class="drawer-section">
<div class="section-heading">
<div>
<h2>默认策略</h2>
<p>未配置模板例外时,科室按以下范围访问文档。</p>
</div>
<span v-if="department?.campus" class="section-meta">{{ department.campus }}</span>
</div>
<div v-if="department" class="policy-options">
<button
type="button"
class="policy-option"
:class="{ active: form.own }"
:disabled="!props.canEditPolicy || props.policySaving"
:aria-pressed="form.own"
@click="form.own = !form.own"
>
<span class="policy-switch" aria-hidden="true"><i /></span>
<span>
<strong>本科室文档</strong>
<small>本部门维护的文档模板</small>
</span>
</button>
<button
type="button"
class="policy-option"
:class="{ active: form.common }"
:disabled="!props.canEditPolicy || props.policySaving"
:aria-pressed="form.common"
@click="form.common = !form.common"
>
<span class="policy-switch" aria-hidden="true"><i /></span>
<span>
<strong>全院通用文档</strong>
<small>标记为全院通用的文档模板</small>
</span>
</button>
<button
type="button"
class="policy-option"
:class="{ active: form.other }"
:disabled="!props.canEditPolicy || props.policySaving"
:aria-pressed="form.other"
@click="form.other = !form.other"
>
<span class="policy-switch" aria-hidden="true"><i /></span>
<span>
<strong>其他科室文档</strong>
<small>其他科室维护的非通用文档</small>
</span>
</button>
</div>
<div v-else class="empty-state">暂无科室信息</div>
<p v-if="!props.canEditPolicy" class="readonly-tip">当前账号仅可查看默认策略。</p>
</section>
<section class="drawer-section template-section">
<div class="section-heading">
<div>
<h2>模板例外</h2>
<p>对单个模板新增或撤销直接权限;继承权限只读。</p>
</div>
<span class="section-meta">
{{ props.templates.length ? `${props.templates.length} 个模板` : '暂无模板' }}
</span>
</div>
<TemplatePermissionPanel
:templates="props.templates"
:selected-template-id="props.selectedTemplateId"
:permissions="props.permissions"
:subjects="props.subjects"
:loading="props.templateLoading"
:saving="props.templateSaving"
:error="props.templateError"
:error-message="props.templateErrorMessage"
:catalog-error="props.templateCatalogError"
:catalog-loading="props.templateCatalogLoading"
:preview-user-id="props.previewUserId"
:can-edit="props.canEditTemplate"
@update:selected-template-id="emit('update:selectedTemplateId', $event)"
@update:preview-user-id="emit('update:previewUserId', $event)"
@add="emit('add', $event)"
@delete="emit('delete', $event)"
@retry="emit('retry')"
/>
</section>
</div>
<footer class="drawer-footer">
<span class="footer-note">
{{
props.canEditPolicy
? '调整后即时生效并记录审计日志。'
: '默认策略由有权限的管理员调整。'
}}
</span>
<button type="button" class="button button--ghost" @click="emit('close')">关闭</button>
<button
v-if="props.canEditPolicy"
type="button"
class="button"
:disabled="!department || props.policySaving"
@click="savePolicy"
>
{{ props.policySaving ? '保存中…' : '保存默认策略' }}
</button>
</footer>
</aside>
</div>
</Teleport>
</template>
<style scoped>
.drawer-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgb(9 40 52 / 45%);
}
.permission-drawer {
display: flex;
width: min(720px, 96vw);
height: 100%;
flex-direction: column;
background: var(--card);
box-shadow: var(--sh-modal);
animation: slide-in 0.18s ease-out;
}
.drawer-header,
.drawer-footer {
display: flex;
gap: 12px;
align-items: center;
padding: 18px 22px;
}
.drawer-header {
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
.drawer-header strong {
color: var(--ink);
font-size: 15px;
}
.drawer-header p,
.section-heading p {
margin: 4px 0 0;
color: var(--mut);
font-size: 11.5px;
}
.close-button {
padding: 0 2px;
margin-left: auto;
color: var(--mut);
font-size: 24px;
line-height: 1;
background: transparent;
}
.close-button:hover {
color: var(--ink);
}
.drawer-body {
display: grid;
gap: 16px;
padding: 18px 22px;
overflow-y: auto;
}
.drawer-section {
padding: 16px;
border: 1px solid var(--line);
border-radius: 9px;
}
.section-heading {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 14px;
}
.section-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14px;
}
.section-meta {
flex-shrink: 0;
color: var(--mut);
font-size: 11.5px;
}
.policy-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.policy-option {
display: flex;
gap: 9px;
align-items: flex-start;
min-height: 72px;
padding: 11px;
color: var(--ink);
text-align: left;
background: #f8fbfc;
border: 1px solid var(--line);
border-radius: 7px;
}
.policy-option:not(:disabled) {
cursor: pointer;
}
.policy-option.active {
background: var(--brand-l);
border-color: var(--brand);
}
.policy-option:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.policy-switch {
position: relative;
display: block;
width: 36px;
height: 20px;
flex-shrink: 0;
margin-top: 1px;
background: #cbd8de;
border-radius: 20px;
}
.policy-switch i {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: left 0.2s ease;
}
.policy-option.active .policy-switch {
background: var(--ok);
}
.policy-option.active .policy-switch i {
left: 18px;
}
.policy-option strong,
.policy-option small {
display: block;
}
.policy-option strong {
font-size: 12px;
}
.policy-option small {
margin-top: 4px;
color: var(--mut);
font-size: 10.5px;
line-height: 1.45;
}
.readonly-tip {
padding: 9px 10px;
margin: 10px 0 0;
color: var(--mut);
font-size: 11.5px;
background: #f8fbfc;
border-radius: 6px;
}
.empty-state {
padding: 24px 12px;
color: var(--mut);
font-size: 12px;
text-align: center;
background: #f8fbfc;
border-radius: 6px;
}
.template-section :deep(.permission-panel) {
gap: 12px;
}
.template-section :deep(.panel-toolbar) {
padding-bottom: 12px;
}
.drawer-footer {
flex-shrink: 0;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.footer-note {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--mut);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.button {
padding: 8px 16px;
color: #fff;
font-size: 12.5px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@keyframes slide-in {
from {
transform: translateX(18px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 640px) {
.permission-drawer {
width: 100%;
}
.drawer-header,
.drawer-footer,
.drawer-body {
padding-right: 16px;
padding-left: 16px;
}
.policy-options {
grid-template-columns: 1fr;
}
.footer-note {
display: none;
}
}
</style>
@@ -1,109 +1,71 @@
<script setup lang="ts">
import type {
PermissionCellState,
PermissionColumn,
PermissionKey,
DepartmentPermissionRow,
} from '../types'
import type { DepartmentPermissionRow } from '../types'
const props = defineProps<{
rows: DepartmentPermissionRow[]
columns: PermissionColumn[]
saving?: boolean
canEdit?: boolean
}>()
const emit = defineEmits<{
toggle: [departmentId: string, permission: PermissionKey]
adjust: [row: DepartmentPermissionRow]
}>()
function getState(row: DepartmentPermissionRow, permission: PermissionKey): PermissionCellState {
return (
row.permissionStates?.[permission] ?? (row.permissions[permission] ? 'direct-allow' : 'none')
)
}
function stateLabel(state: PermissionCellState) {
const labels: Record<PermissionCellState, string> = {
none: '未授权',
'direct-allow': '直接允许',
'direct-deny': '直接拒绝',
'inherited-allow': '继承允许',
'inherited-deny': '继承拒绝',
}
return labels[state]
}
function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
return props.saving || getState(row, permission) === 'inherited-deny'
function policyLabel(enabled: boolean) {
return enabled ? '可用' : '不可见'
}
</script>
<template>
<div v-if="!rows.length" class="empty-state">暂无科室数据</div>
<template v-else>
<div class="table-scroll">
<table class="permission-table" aria-label="按科室授权矩阵">
<thead>
<tr>
<th>科室</th>
<th
v-for="column in columns"
:key="column.key"
class="permission-heading"
:title="column.description"
<div v-if="!props.rows.length" class="empty-state">暂无科室默认策略</div>
<div v-else class="table-scroll">
<table class="permission-table" aria-label="按科室授权">
<thead>
<tr>
<th>科室</th>
<th>本科室文档</th>
<th>全院通用文档</th>
<th>其他科室文档</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in props.rows" :key="row.id">
<td>
<div class="department-name">
<strong>{{ row.department }}</strong>
<small v-if="row.campus">{{ row.campus }}</small>
</div>
</td>
<td>
<span class="policy-tag" :class="row.own ? 'is-enabled' : 'is-disabled'">
{{ policyLabel(row.own) }}
</span>
</td>
<td>
<span class="policy-tag" :class="row.common ? 'is-enabled' : 'is-disabled'">
{{ policyLabel(row.common) }}
</span>
</td>
<td>
<span class="policy-tag" :class="row.other ? 'is-enabled' : 'is-disabled'">
{{ policyLabel(row.other) }}
</span>
</td>
<td class="operation-cell">
<button
v-if="props.canEdit"
type="button"
class="adjust-button"
@click="emit('adjust', row)"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<div class="department-name">
<strong>{{ row.department }}</strong>
<small v-if="row.campus">{{ row.campus }}</small>
</div>
</td>
<td v-for="column in columns" :key="column.key" class="permission-cell">
<button
type="button"
class="permission-toggle"
:class="{
active: row.permissions[column.key],
inherited: getState(row, column.key).startsWith('inherited'),
denied: getState(row, column.key).endsWith('deny'),
}"
:disabled="isDisabled(row, column.key)"
:aria-pressed="row.permissions[column.key]"
:aria-label="
row.department +
':' +
column.label +
',' +
stateLabel(getState(row, column.key))
"
:title="
column.label +
':' +
column.description +
'(' +
stateLabel(getState(row, column.key)) +
')'
"
@click="emit('toggle', row.id, column.key)"
>
<span class="switch-track" aria-hidden="true" />
</button>
<span class="state-label">{{ stateLabel(getState(row, column.key)) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="legend">
继承允许可通过开关新增直接拒绝;继承拒绝由上级规则控制,当前接口不支持直接覆盖。
</p>
</template>
调整
</button>
<span v-else class="readonly-label">无调整权限</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
@@ -114,15 +76,15 @@ function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
.permission-table {
width: 100%;
min-width: 680px;
font-size: 12.5px;
min-width: 760px;
font-size: 13px;
border-collapse: collapse;
}
.permission-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12px;
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
@@ -130,10 +92,6 @@ function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
border-bottom: 1px solid var(--line);
}
.permission-table th.permission-heading {
text-align: center;
}
.permission-table td {
padding: 9px 12px;
vertical-align: middle;
@@ -156,86 +114,46 @@ function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
font-size: 11px;
}
.permission-cell {
width: 23%;
text-align: center;
}
.permission-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 28px;
padding: 0;
vertical-align: middle;
background: transparent;
border-radius: 16px;
}
.permission-toggle:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.switch-track {
position: relative;
display: block;
width: 36px;
height: 20px;
background: #cbd8de;
border-radius: 20px;
transition: background-color 0.2s ease;
}
.switch-track::after {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
content: '';
background: #fff;
border-radius: 50%;
transition: left 0.2s ease;
}
.permission-toggle.active .switch-track {
background: var(--ok);
}
.permission-toggle.inherited.active .switch-track {
background: #4eaa8a;
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 45%);
}
.permission-toggle.denied .switch-track {
background: #cbd8de;
}
.permission-toggle.active .switch-track::after {
left: 18px;
}
.permission-toggle:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.state-label {
.policy-tag {
display: inline-block;
min-width: 52px;
margin-left: 3px;
color: var(--mut);
font-size: 10.5px;
text-align: left;
min-width: 48px;
padding: 2px 9px;
font-size: 11.5px;
font-weight: 700;
text-align: center;
border-radius: 20px;
}
.legend {
margin: 10px 0 0;
.policy-tag.is-enabled {
color: var(--ok);
background: var(--ok-l);
}
.policy-tag.is-disabled {
color: #8a9aa3;
background: #eceef0;
}
.operation-cell {
white-space: nowrap;
}
.adjust-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.adjust-button:hover {
background: var(--brand-l);
}
.readonly-label {
color: var(--mut);
font-size: 11px;
line-height: 1.6;
font-size: 11.5px;
}
.empty-state {
@@ -10,6 +10,7 @@ const props = defineProps<{
rows: RolePermissionRow[]
columns: PermissionColumn[]
saving?: boolean
canEdit?: boolean
}>()
const emit = defineEmits<{
@@ -34,7 +35,7 @@ function stateLabel(state: PermissionCellState) {
}
function isDisabled(row: RolePermissionRow, permission: PermissionKey) {
return props.saving || getState(row, permission) === 'inherited-deny'
return props.saving || props.canEdit === false || getState(row, permission) === 'inherited-deny'
}
</script>
@@ -13,7 +13,11 @@ const props = defineProps<{
loading: boolean
saving: boolean
error: boolean
errorMessage?: string
catalogError?: string
catalogLoading?: boolean
previewUserId: string
canEdit?: boolean
}>()
const emit = defineEmits<{
@@ -107,7 +111,7 @@ watch(
<template>
<section class="permission-panel">
<div class="panel-toolbar">
<label class="template-select">
<label v-if="templates.length" class="template-select">
<span>选择模板</span>
<select :value="selectedTemplateId" @change="handleTemplateChange">
<option value="" disabled>请选择模板</option>
@@ -116,6 +120,7 @@ watch(
</option>
</select>
</label>
<span v-else class="template-empty-label">当前院区暂无可配置模板</span>
<span v-if="selectedTemplate" class="template-meta">
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
</span>
@@ -130,10 +135,19 @@ watch(
</label>
</div>
<div v-if="!selectedTemplateId" class="empty-state">暂无模板,请先选择一个模板。</div>
<div v-if="catalogError" class="error-state">
<span>{{ catalogError }}</span>
<button type="button" class="retry-button" @click="emit('retry')">重新加载</button>
</div>
<div v-else-if="catalogLoading" class="loading-state">正在加载模板目录…</div>
<div v-else-if="!templates.length" class="empty-state">
暂无模板例外
<small>当前院区还没有可配置的文档模板。</small>
</div>
<div v-else-if="!selectedTemplateId" class="empty-state">请选择一个模板查看例外权限。</div>
<div v-else-if="loading" class="loading-state">正在加载模板权限…</div>
<div v-else-if="error" class="error-state">
<span>模板权限加载失败</span>
<span>{{ errorMessage || '模板权限加载失败,请稍后重试' }}</span>
<button type="button" class="retry-button" @click="emit('retry')">重新加载</button>
</div>
<template v-else>
@@ -179,8 +193,14 @@ watch(
<button
type="button"
class="delete-button"
:disabled="permission.inherited || saving"
:title="permission.inherited ? '继承权限不可直接删除' : '删除权限'"
:disabled="permission.inherited || saving || props.canEdit === false"
:title="
permission.inherited
? '继承权限不可直接删除'
: props.canEdit === false
? '当前账号无直接权限修改权限'
: '删除权限'
"
@click="deletePermission(permission.id)"
>
删除
@@ -193,7 +213,7 @@ watch(
<p v-else class="empty-state compact">当前模板暂无直接权限配置。</p>
</div>
<form class="add-form" @submit.prevent="submit">
<form v-if="props.canEdit !== false" class="add-form" @submit.prevent="submit">
<div class="list-heading">
<span>新增权限</span>
<span>服务端会再次校验授权范围</span>
@@ -237,6 +257,7 @@ watch(
</button>
</div>
</form>
<p v-else class="readonly-tip">当前账号仅可查看模板例外,新增和删除操作已禁用。</p>
</template>
</section>
</template>
@@ -299,6 +320,12 @@ select:disabled {
font-size: 11.5px;
}
.template-empty-label {
padding-bottom: 8px;
color: var(--mut);
font-size: 12px;
}
.list-heading {
display: flex;
gap: 12px;
@@ -406,6 +433,13 @@ td {
text-align: center;
}
.empty-state small {
display: block;
margin-top: 5px;
color: var(--mut);
font-size: 11px;
}
.error-state {
display: flex;
gap: 10px;
@@ -435,6 +469,15 @@ td {
background: #f8fbfc;
}
.readonly-tip {
padding: 10px 12px;
margin: 0;
color: var(--mut);
font-size: 11.5px;
background: #f8fbfc;
border-radius: 6px;
}
@media (max-width: 960px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
File diff suppressed because it is too large Load Diff
@@ -73,26 +73,36 @@ export const departmentPermissionRows: DepartmentPermissionRow[] = [
{
id: 'radiology',
department: '放射科',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
own: true,
common: true,
other: false,
},
{
id: 'cardiology',
department: '心内科',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
own: true,
common: true,
other: false,
},
{
id: 'orthopedics',
department: '骨科',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
own: true,
common: true,
other: false,
},
{
id: 'health-management',
department: '健康管理中心',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
own: true,
common: true,
other: false,
},
{
id: 'general',
department: '全院通用',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
own: false,
common: true,
other: false,
},
]
@@ -17,14 +17,19 @@ export interface RolePermissionRow {
userCount: number
permissions: Record<PermissionKey, boolean>
permissionStates?: Record<PermissionKey, PermissionCellState>
version?: number
}
export interface DepartmentPermissionRow {
id: string
code?: string
department: string
campus?: string
permissions: Record<PermissionKey, boolean>
permissionStates?: Record<PermissionKey, PermissionCellState>
campusId?: string
own: boolean
common: boolean
other: boolean
version?: number
}
export interface PermissionSubjectOption {
@@ -11,12 +11,15 @@ const props = defineProps<{
loading: boolean
saving: boolean
error: string
canEdit?: boolean
drawer?: boolean
}>()
const emit = defineEmits<{
selectRole: [roleId: string]
togglePermission: [permissionId: string]
save: []
close: []
}>()
const groupedPermissions = computed(() => {
@@ -37,14 +40,26 @@ function isSelected(permissionId: string) {
</script>
<template>
<section class="page-card api-permission-card">
<section
:class="['api-permission-card', { 'drawer-panel': props.drawer, 'page-card': !props.drawer }]"
>
<div class="card-heading">
<div>
<h2>角色 API 权限</h2>
<p>这里维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
<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"
@@ -52,6 +67,7 @@ function isSelected(permissionId: string) {
>
{{ saving ? '保存中…' : '保存角色权限' }}
</button>
<span v-else class="readonly-note">当前账号仅可查看</span>
</div>
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
@@ -80,7 +96,7 @@ function isSelected(permissionId: string) {
<input
type="checkbox"
:checked="isSelected(permission.id)"
:disabled="saving"
:disabled="saving || props.canEdit === false"
@change="emit('togglePermission', permission.id)"
/>
<span>
@@ -102,6 +118,15 @@ function isSelected(permissionId: string) {
margin-top: 14px;
}
.drawer-panel {
display: flex;
min-height: 100%;
flex-direction: column;
margin-top: 0;
overflow: hidden;
background: var(--card);
}
.card-heading {
display: flex;
gap: 12px;
@@ -110,6 +135,11 @@ function isSelected(permissionId: string) {
border-bottom: 1px solid var(--line);
}
.drawer-panel .card-heading {
flex-shrink: 0;
padding: 18px 20px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
@@ -126,6 +156,23 @@ function isSelected(permissionId: string) {
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);
@@ -160,6 +207,11 @@ function isSelected(permissionId: string) {
padding: 16px;
}
.drawer-panel .api-permission-layout {
flex: 1;
overflow: auto;
}
.role-selector {
display: grid;
align-content: start;
@@ -3,12 +3,27 @@ import type { RoleSummary } from '../types'
defineProps<{
rows: RoleSummary[]
interactive?: boolean
selectedRoleId?: string
}>()
const emit = defineEmits<{
select: [role: RoleSummary]
}>()
</script>
<template>
<div class="role-cards">
<article v-for="role in rows" :key="role.id" class="role-card">
<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 }}
@@ -17,8 +32,10 @@ defineProps<{
<span class="role-count">
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
</span>
</article>
<span v-if="interactive" class="role-action">配置角色权限 →</span>
</button>
</div>
<div v-else class="role-empty">暂无角色概览</div>
</template>
<style scoped>
@@ -30,10 +47,32 @@ defineProps<{
}
.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 {
@@ -69,6 +108,25 @@ defineProps<{
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));
@@ -1,10 +1,13 @@
<script setup lang="ts">
import type { UserStatus, UserTableRow } from '../types'
defineProps<{
const props = defineProps<{
rows: UserTableRow[]
roleColors: Record<string, string>
disablingId?: string | null
canEdit?: boolean
canResetPassword?: boolean
canDeactivate?: boolean
}>()
const emit = defineEmits<{
@@ -72,12 +75,24 @@ const statusLabels: Record<UserStatus, string> = {
</span>
</td>
<td class="actions-cell">
<button type="button" class="table-button" @click="emit('edit', row)">编辑</button>
<button type="button" class="table-button" @click="emit('resetPassword', row)">
<button
v-if="props.canEdit"
type="button"
class="table-button"
@click="emit('edit', row)"
>
编辑
</button>
<button
v-if="props.canResetPassword"
type="button"
class="table-button"
@click="emit('resetPassword', row)"
>
重置密码
</button>
<button
v-if="row.status === 'enabled'"
v-if="props.canDeactivate && row.status === 'enabled'"
type="button"
class="table-button table-button--danger"
:disabled="disablingId === row.id"
+143 -19
View File
@@ -34,6 +34,7 @@ import type {
UserRecord as ApiUserRecord,
} from '@/api/management/types'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import RoleSummaryCards from './components/RoleSummaryCards.vue'
import UserTable from './components/UserTable.vue'
@@ -45,6 +46,7 @@ import { roleColors, roleSummaries, users } from './mock'
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
const appStore = useAppStore()
const loginStore = useLoginStore()
const loading = ref(true)
const keyword = ref('')
const roleRows = ref<RoleSummary[]>([])
@@ -74,8 +76,33 @@ const rolePermissionVersion = ref<number | undefined>()
const apiPermissionLoading = ref(false)
const apiPermissionSaving = ref(false)
const apiPermissionError = ref('')
const rolePermissionDrawerVisible = ref(false)
let userRequestId = 0
const canEditUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:update'),
)
const canCreateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:create'),
)
const canDeactivateUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:disable'),
)
const canResetPasswords = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:password:reset'),
)
const canImportUsers = computed(
() => isUserMockEnabled || loginStore.hasPermission('system:user:import'),
)
const canViewRoleApiPermissions = computed(
() =>
!isUserMockEnabled &&
loginStore.hasPermission(['system:permission:query', 'system:role:permission:query']),
)
const canEditRoleApiPermissions = computed(
() => !isUserMockEnabled && loginStore.hasPermission('system:role:permission:update'),
)
const mockCampuses: CampusRecord[] = [
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
@@ -229,7 +256,7 @@ async function loadSelectedRolePermissions() {
}
async function loadApiPermissionCatalog() {
if (isUserMockEnabled || apiPermissionLoading.value) {
if (isUserMockEnabled || !canViewRoleApiPermissions.value || apiPermissionLoading.value) {
return
}
@@ -255,6 +282,10 @@ async function loadApiPermissionCatalog() {
}
async function selectRoleForApiPermissions(roleId: string) {
if (!canViewRoleApiPermissions.value) {
return
}
selectedRoleId.value = roleId
apiPermissionLoading.value = true
apiPermissionError.value = ''
@@ -282,7 +313,7 @@ function toggleApiPermission(permissionId: string) {
}
async function saveRoleApiPermissions() {
if (!selectedRoleId.value || apiPermissionSaving.value) {
if (!canEditRoleApiPermissions.value || !selectedRoleId.value || apiPermissionSaving.value) {
return
}
@@ -363,11 +394,19 @@ function mapRoleSummary(role: RoleRecord): RoleSummary {
}
function showAddUserMessage() {
if (!canCreateUsers.value) {
return
}
editorInitial.value = null
editorVisible.value = true
}
function showImportMessage() {
if (!canImportUsers.value) {
return
}
if (isUserMockEnabled) {
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
@@ -379,6 +418,10 @@ function showImportMessage() {
}
async function editUser(row: UserTableRow) {
if (!canEditUsers.value) {
return
}
try {
const user = isUserMockEnabled
? userRows.value.find((item) => item.id === row.id)
@@ -419,6 +462,10 @@ async function editUser(row: UserTableRow) {
}
function resetPassword(user: UserTableRow) {
if (!canResetPasswords.value) {
return
}
if (isUserMockEnabled) {
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
@@ -515,7 +562,11 @@ function changeUserPage(nextPage: number) {
}
async function deactivateUser(row: UserTableRow) {
if (row.status !== 'enabled' || !window.confirm(`确定停用用户“${row.name}”吗?`)) {
if (
!canDeactivateUsers.value ||
row.status !== 'enabled' ||
!window.confirm(`确定停用用户“${row.name}”吗?`)
) {
return
}
@@ -663,6 +714,15 @@ watch(
)
onMounted(loadUsers)
function openRolePermissionDrawer(role: RoleSummary) {
if (!canViewRoleApiPermissions.value) {
return
}
rolePermissionDrawerVisible.value = true
void selectRoleForApiPermissions(role.id)
}
</script>
<template>
@@ -672,7 +732,12 @@ onMounted(loadUsers)
</div>
<template v-else>
<RoleSummaryCards :rows="roleRows" />
<RoleSummaryCards
:rows="roleRows"
:interactive="canViewRoleApiPermissions"
:selected-role-id="selectedRoleId"
@select="openRolePermissionDrawer"
/>
<section class="page-card">
<div class="card-heading">
@@ -687,13 +752,30 @@ onMounted(loadUsers)
@keydown.enter.prevent="searchUsers"
/>
<button type="button" class="table-button" @click="searchUsers">查询</button>
<button type="button" class="table-button" @click="showAddUserMessage">+新增用户</button>
<button type="button" class="table-button" @click="showImportMessage">批量导入</button>
<button
v-if="canCreateUsers"
type="button"
class="table-button"
@click="showAddUserMessage"
>
+新增用户
</button>
<button
v-if="canImportUsers"
type="button"
class="table-button"
@click="showImportMessage"
>
批量导入
</button>
</div>
<UserTable
:rows="visibleUsers"
:role-colors="roleColorMap"
:disabling-id="disablingUserId"
:can-edit="canEditUsers"
:can-reset-password="canResetPasswords"
:can-deactivate="canDeactivateUsers"
@edit="editUser"
@reset-password="resetPassword"
@deactivate="deactivateUser"
@@ -720,19 +802,31 @@ onMounted(loadUsers)
</div>
</section>
<RoleApiPermissionPanel
v-if="!isUserMockEnabled"
:roles="roleOptions"
:permissions="apiPermissions"
:selected-role-id="selectedRoleId"
:selected-permission-ids="selectedRolePermissionIds"
:loading="apiPermissionLoading"
:saving="apiPermissionSaving"
:error="apiPermissionError"
@select-role="selectRoleForApiPermissions"
@toggle-permission="toggleApiPermission"
@save="saveRoleApiPermissions"
/>
<Teleport to="body">
<div
v-if="rolePermissionDrawerVisible && canViewRoleApiPermissions"
class="role-permission-mask"
@click.self="rolePermissionDrawerVisible = false"
>
<aside class="role-permission-drawer" role="dialog" aria-modal="true">
<RoleApiPermissionPanel
:roles="roleOptions"
:permissions="apiPermissions"
:selected-role-id="selectedRoleId"
:selected-permission-ids="selectedRolePermissionIds"
:loading="apiPermissionLoading"
:saving="apiPermissionSaving"
:error="apiPermissionError"
:can-edit="canEditRoleApiPermissions"
drawer
@select-role="selectRoleForApiPermissions"
@toggle-permission="toggleApiPermission"
@save="saveRoleApiPermissions"
@close="rolePermissionDrawerVisible = false"
/>
</aside>
</div>
</Teleport>
</template>
<UserEditorDialog
@@ -841,6 +935,24 @@ onMounted(loadUsers)
opacity: 0.55;
}
.role-permission-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgb(9 40 52 / 45%);
}
.role-permission-drawer {
width: min(760px, 96vw);
height: 100%;
overflow: hidden;
background: var(--card);
box-shadow: var(--sh-modal);
animation: slide-in 0.18s ease-out;
}
.loading-card {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -867,6 +979,18 @@ onMounted(loadUsers)
}
}
@keyframes slide-in {
from {
transform: translateX(12px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 900px) {
.card-heading {
align-items: flex-start;