feat: 接入用户与文档权限管理
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
VITE_APP_NAME=medical-sign-clinical
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_USE_MOCK=true
|
||||
# 联调默认走真实后端;仅在演示原型时改为 true。
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: '系统设置' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+475
@@ -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>
|
||||
+93
-175
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
|
||||
+49
-6
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user