feat: 接入用户与文档权限管理
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
VITE_APP_NAME=medical-sign-clinical
|
VITE_APP_NAME=medical-sign-clinical
|
||||||
VITE_API_BASE_URL=/api
|
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 开发服务。
|
开发环境默认使用 `/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 项;页面当前采用“模板选择 + 当前模板矩阵”的交互,没有臆造跨模板对比表。
|
本轮新增接口已覆盖当前文档权限页面的后端缺口,暂无阻断前端接入的缺失接口。跨模板矩阵查询最多支持 100 个模板,批量保存最多支持 200 项;页面当前采用“模板选择 + 当前模板矩阵”的交互,没有臆造跨模板对比表。
|
||||||
|
|
||||||
联调验收至少包括:
|
联调验收至少包括:
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ import type {
|
|||||||
BackendCollection,
|
BackendCollection,
|
||||||
BackendPage,
|
BackendPage,
|
||||||
CreateTemplatePermissionRequest,
|
CreateTemplatePermissionRequest,
|
||||||
|
DepartmentPermissionPolicyRecord,
|
||||||
PermissionListResponse,
|
PermissionListResponse,
|
||||||
|
PermissionPolicyLevel,
|
||||||
|
PermissionPoliciesRecord,
|
||||||
|
PermissionPoliciesResponseDto,
|
||||||
|
PermissionPolicyDepartmentDto,
|
||||||
|
PermissionPolicyRoleDto,
|
||||||
PermissionQuery,
|
PermissionQuery,
|
||||||
PermissionRecord,
|
PermissionRecord,
|
||||||
|
RolePermissionPolicyRecord,
|
||||||
TemplatePermissionBatchRequest,
|
TemplatePermissionBatchRequest,
|
||||||
TemplatePermissionBatchResponse,
|
TemplatePermissionBatchResponse,
|
||||||
TemplatePermissionBatchResponseDto,
|
TemplatePermissionBatchResponseDto,
|
||||||
@@ -19,9 +26,12 @@ import type {
|
|||||||
TemplatePermissionMatrixResponse,
|
TemplatePermissionMatrixResponse,
|
||||||
TemplatePermissionRecord,
|
TemplatePermissionRecord,
|
||||||
TemplatePermissionResponseDto,
|
TemplatePermissionResponseDto,
|
||||||
|
UpdateDepartmentPermissionPolicyRequest,
|
||||||
|
UpdateRolePermissionPolicyRequest,
|
||||||
} from './types'
|
} 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
|
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> {
|
export function getDocumentPermissions(query: PermissionQuery): Promise<PermissionListResponse> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type {
|
|||||||
TemplateVersionResponseDto,
|
TemplateVersionResponseDto,
|
||||||
} from './types'
|
} 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
|
export const isDocumentMockEnabled = useMockData
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import type {
|
|||||||
UpdateDepartmentRequest,
|
UpdateDepartmentRequest,
|
||||||
} from './types'
|
} 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>) {
|
function getRecords(data: BackendCollection<OrganizationResponseDto>) {
|
||||||
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import type {
|
|||||||
UpdateApiPermissionRequest,
|
UpdateApiPermissionRequest,
|
||||||
} from './types'
|
} 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
|
export const isApiPermissionMockEnabled = useMockData
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import type {
|
|||||||
UpdateRoleRequest,
|
UpdateRoleRequest,
|
||||||
} from './types'
|
} 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']
|
const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d']
|
||||||
|
|
||||||
|
|||||||
@@ -538,6 +538,118 @@ export interface RolePermissionsUpdateRequest {
|
|||||||
expectedVersion?: number
|
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 {
|
export interface PasswordResetRequest {
|
||||||
newPassword: string
|
newPassword: string
|
||||||
forceChangeOnNextLogin?: boolean
|
forceChangeOnNextLogin?: boolean
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import type {
|
|||||||
UserRolesUpdateRequest,
|
UserRolesUpdateRequest,
|
||||||
} from './types'
|
} 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
|
export const isUserMockEnabled = useMockData
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ const menuGroups: MenuGroup[] = [
|
|||||||
{ path: '/management/documents', label: '文档库管理' },
|
{ path: '/management/documents', label: '文档库管理' },
|
||||||
{ path: '/management/reports', label: '报表分析' },
|
{ path: '/management/reports', label: '报表分析' },
|
||||||
{ path: '/management/users', label: '用户与权限', permission: 'system:user:query' },
|
{ 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: '系统设置' },
|
{ path: '/management/settings', label: '系统设置' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ const router = createRouter({
|
|||||||
path: 'document-permissions',
|
path: 'document-permissions',
|
||||||
name: 'management-document-permissions',
|
name: 'management-document-permissions',
|
||||||
component: DocumentPermissionsView,
|
component: DocumentPermissionsView,
|
||||||
meta: { title: '文档权限', group: '管理' },
|
meta: {
|
||||||
|
title: '文档权限',
|
||||||
|
group: '管理',
|
||||||
|
permission: 'sign:template:permission:query',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { getCampuses } from '@/api/management/organization'
|
|||||||
import type { CampusRecord } from '@/api/management/types'
|
import type { CampusRecord } from '@/api/management/types'
|
||||||
import type { WorkbenchCampus } from '@/api/workbench/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[] = [
|
const mockCampusRecords: CampusRecord[] = [
|
||||||
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
{ 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>
|
||||||
+73
-155
@@ -1,109 +1,71 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DepartmentPermissionRow } from '../types'
|
||||||
PermissionCellState,
|
|
||||||
PermissionColumn,
|
|
||||||
PermissionKey,
|
|
||||||
DepartmentPermissionRow,
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
rows: DepartmentPermissionRow[]
|
rows: DepartmentPermissionRow[]
|
||||||
columns: PermissionColumn[]
|
canEdit?: boolean
|
||||||
saving?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
toggle: [departmentId: string, permission: PermissionKey]
|
adjust: [row: DepartmentPermissionRow]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function getState(row: DepartmentPermissionRow, permission: PermissionKey): PermissionCellState {
|
function policyLabel(enabled: boolean) {
|
||||||
return (
|
return enabled ? '可用' : '不可见'
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!rows.length" class="empty-state">暂无科室数据</div>
|
<div v-if="!props.rows.length" class="empty-state">暂无科室默认策略</div>
|
||||||
<template v-else>
|
<div v-else class="table-scroll">
|
||||||
<div class="table-scroll">
|
<table class="permission-table" aria-label="按科室授权">
|
||||||
<table class="permission-table" aria-label="按科室授权矩阵">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>科室</th>
|
<th>科室</th>
|
||||||
<th
|
<th>本科室文档</th>
|
||||||
v-for="column in columns"
|
<th>全院通用文档</th>
|
||||||
:key="column.key"
|
<th>其他科室文档</th>
|
||||||
class="permission-heading"
|
<th>操作</th>
|
||||||
:title="column.description"
|
|
||||||
>
|
|
||||||
{{ column.label }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in rows" :key="row.id">
|
<tr v-for="row in props.rows" :key="row.id">
|
||||||
<td>
|
<td>
|
||||||
<div class="department-name">
|
<div class="department-name">
|
||||||
<strong>{{ row.department }}</strong>
|
<strong>{{ row.department }}</strong>
|
||||||
<small v-if="row.campus">{{ row.campus }}</small>
|
<small v-if="row.campus">{{ row.campus }}</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-for="column in columns" :key="column.key" class="permission-cell">
|
<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
|
<button
|
||||||
|
v-if="props.canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-toggle"
|
class="adjust-button"
|
||||||
:class="{
|
@click="emit('adjust', row)"
|
||||||
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>
|
</button>
|
||||||
<span class="state-label">{{ stateLabel(getState(row, column.key)) }}</span>
|
<span v-else class="readonly-label">无调整权限</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="legend">
|
|
||||||
继承允许可通过开关新增直接拒绝;继承拒绝由上级规则控制,当前接口不支持直接覆盖。
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -114,15 +76,15 @@ function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
|
|||||||
|
|
||||||
.permission-table {
|
.permission-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 680px;
|
min-width: 760px;
|
||||||
font-size: 12.5px;
|
font-size: 13px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-table th {
|
.permission-table th {
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
font-size: 12px;
|
font-size: 12.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -130,10 +92,6 @@ function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-table th.permission-heading {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-table td {
|
.permission-table td {
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
@@ -156,86 +114,46 @@ function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-cell {
|
.policy-tag {
|
||||||
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 {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 52px;
|
min-width: 48px;
|
||||||
margin-left: 3px;
|
padding: 2px 9px;
|
||||||
color: var(--mut);
|
font-size: 11.5px;
|
||||||
font-size: 10.5px;
|
font-weight: 700;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.policy-tag.is-enabled {
|
||||||
margin: 10px 0 0;
|
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);
|
color: var(--mut);
|
||||||
font-size: 11px;
|
font-size: 11.5px;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,7 @@ const props = defineProps<{
|
|||||||
rows: RolePermissionRow[]
|
rows: RolePermissionRow[]
|
||||||
columns: PermissionColumn[]
|
columns: PermissionColumn[]
|
||||||
saving?: boolean
|
saving?: boolean
|
||||||
|
canEdit?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -34,7 +35,7 @@ function stateLabel(state: PermissionCellState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isDisabled(row: RolePermissionRow, permission: PermissionKey) {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+49
-6
@@ -13,7 +13,11 @@ const props = defineProps<{
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
error: boolean
|
error: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
catalogError?: string
|
||||||
|
catalogLoading?: boolean
|
||||||
previewUserId: string
|
previewUserId: string
|
||||||
|
canEdit?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -107,7 +111,7 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<section class="permission-panel">
|
<section class="permission-panel">
|
||||||
<div class="panel-toolbar">
|
<div class="panel-toolbar">
|
||||||
<label class="template-select">
|
<label v-if="templates.length" class="template-select">
|
||||||
<span>选择模板</span>
|
<span>选择模板</span>
|
||||||
<select :value="selectedTemplateId" @change="handleTemplateChange">
|
<select :value="selectedTemplateId" @change="handleTemplateChange">
|
||||||
<option value="" disabled>请选择模板</option>
|
<option value="" disabled>请选择模板</option>
|
||||||
@@ -116,6 +120,7 @@ watch(
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<span v-else class="template-empty-label">当前院区暂无可配置模板</span>
|
||||||
<span v-if="selectedTemplate" class="template-meta">
|
<span v-if="selectedTemplate" class="template-meta">
|
||||||
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
|
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,10 +135,19 @@ watch(
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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="loading" class="loading-state">正在加载模板权限…</div>
|
||||||
<div v-else-if="error" class="error-state">
|
<div v-else-if="error" class="error-state">
|
||||||
<span>模板权限加载失败</span>
|
<span>{{ errorMessage || '模板权限加载失败,请稍后重试' }}</span>
|
||||||
<button type="button" class="retry-button" @click="emit('retry')">重新加载</button>
|
<button type="button" class="retry-button" @click="emit('retry')">重新加载</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -179,8 +193,14 @@ watch(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="delete-button"
|
class="delete-button"
|
||||||
:disabled="permission.inherited || saving"
|
:disabled="permission.inherited || saving || props.canEdit === false"
|
||||||
:title="permission.inherited ? '继承权限不可直接删除' : '删除权限'"
|
:title="
|
||||||
|
permission.inherited
|
||||||
|
? '继承权限不可直接删除'
|
||||||
|
: props.canEdit === false
|
||||||
|
? '当前账号无直接权限修改权限'
|
||||||
|
: '删除权限'
|
||||||
|
"
|
||||||
@click="deletePermission(permission.id)"
|
@click="deletePermission(permission.id)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -193,7 +213,7 @@ watch(
|
|||||||
<p v-else class="empty-state compact">当前模板暂无直接权限配置。</p>
|
<p v-else class="empty-state compact">当前模板暂无直接权限配置。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="add-form" @submit.prevent="submit">
|
<form v-if="props.canEdit !== false" class="add-form" @submit.prevent="submit">
|
||||||
<div class="list-heading">
|
<div class="list-heading">
|
||||||
<span>新增权限</span>
|
<span>新增权限</span>
|
||||||
<span>服务端会再次校验授权范围</span>
|
<span>服务端会再次校验授权范围</span>
|
||||||
@@ -237,6 +257,7 @@ watch(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<p v-else class="readonly-tip">当前账号仅可查看模板例外,新增和删除操作已禁用。</p>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -299,6 +320,12 @@ select:disabled {
|
|||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-empty-label {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.list-heading {
|
.list-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -406,6 +433,13 @@ td {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.error-state {
|
.error-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -435,6 +469,15 @@ td {
|
|||||||
background: #f8fbfc;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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',
|
id: 'radiology',
|
||||||
department: '放射科',
|
department: '放射科',
|
||||||
permissions: { VIEW: true, USE: true, MAINTAIN: false },
|
own: true,
|
||||||
|
common: true,
|
||||||
|
other: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cardiology',
|
id: 'cardiology',
|
||||||
department: '心内科',
|
department: '心内科',
|
||||||
permissions: { VIEW: true, USE: true, MAINTAIN: false },
|
own: true,
|
||||||
|
common: true,
|
||||||
|
other: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'orthopedics',
|
id: 'orthopedics',
|
||||||
department: '骨科',
|
department: '骨科',
|
||||||
permissions: { VIEW: true, USE: true, MAINTAIN: false },
|
own: true,
|
||||||
|
common: true,
|
||||||
|
other: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'health-management',
|
id: 'health-management',
|
||||||
department: '健康管理中心',
|
department: '健康管理中心',
|
||||||
permissions: { VIEW: true, USE: true, MAINTAIN: false },
|
own: true,
|
||||||
|
common: true,
|
||||||
|
other: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'general',
|
id: 'general',
|
||||||
department: '全院通用',
|
department: '全院通用',
|
||||||
permissions: { VIEW: true, USE: true, MAINTAIN: false },
|
own: false,
|
||||||
|
common: true,
|
||||||
|
other: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,14 +17,19 @@ export interface RolePermissionRow {
|
|||||||
userCount: number
|
userCount: number
|
||||||
permissions: Record<PermissionKey, boolean>
|
permissions: Record<PermissionKey, boolean>
|
||||||
permissionStates?: Record<PermissionKey, PermissionCellState>
|
permissionStates?: Record<PermissionKey, PermissionCellState>
|
||||||
|
version?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DepartmentPermissionRow {
|
export interface DepartmentPermissionRow {
|
||||||
id: string
|
id: string
|
||||||
|
code?: string
|
||||||
department: string
|
department: string
|
||||||
campus?: string
|
campus?: string
|
||||||
permissions: Record<PermissionKey, boolean>
|
campusId?: string
|
||||||
permissionStates?: Record<PermissionKey, PermissionCellState>
|
own: boolean
|
||||||
|
common: boolean
|
||||||
|
other: boolean
|
||||||
|
version?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionSubjectOption {
|
export interface PermissionSubjectOption {
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ const props = defineProps<{
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
error: string
|
error: string
|
||||||
|
canEdit?: boolean
|
||||||
|
drawer?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
selectRole: [roleId: string]
|
selectRole: [roleId: string]
|
||||||
togglePermission: [permissionId: string]
|
togglePermission: [permissionId: string]
|
||||||
save: []
|
save: []
|
||||||
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const groupedPermissions = computed(() => {
|
const groupedPermissions = computed(() => {
|
||||||
@@ -37,14 +40,26 @@ function isSelected(permissionId: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 class="card-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>角色 API 权限</h2>
|
<h2>角色 API 权限</h2>
|
||||||
<p>这里维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
|
<p>维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="heading-spacer" />
|
<span class="heading-spacer" />
|
||||||
<button
|
<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"
|
type="button"
|
||||||
class="table-button"
|
class="table-button"
|
||||||
:disabled="saving || loading || !selectedRoleId"
|
:disabled="saving || loading || !selectedRoleId"
|
||||||
@@ -52,6 +67,7 @@ function isSelected(permissionId: string) {
|
|||||||
>
|
>
|
||||||
{{ saving ? '保存中…' : '保存角色权限' }}
|
{{ saving ? '保存中…' : '保存角色权限' }}
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else class="readonly-note">当前账号仅可查看</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
|
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
|
||||||
@@ -80,7 +96,7 @@ function isSelected(permissionId: string) {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isSelected(permission.id)"
|
:checked="isSelected(permission.id)"
|
||||||
:disabled="saving"
|
:disabled="saving || props.canEdit === false"
|
||||||
@change="emit('togglePermission', permission.id)"
|
@change="emit('togglePermission', permission.id)"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
@@ -102,6 +118,15 @@ function isSelected(permissionId: string) {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-panel {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
.card-heading {
|
.card-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -110,6 +135,11 @@ function isSelected(permissionId: string) {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-panel .card-heading {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-heading h2 {
|
.card-heading h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -126,6 +156,23 @@ function isSelected(permissionId: string) {
|
|||||||
flex: 1;
|
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 {
|
.table-button {
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
@@ -160,6 +207,11 @@ function isSelected(permissionId: string) {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-panel .api-permission-layout {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.role-selector {
|
.role-selector {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
|||||||
@@ -3,12 +3,27 @@ import type { RoleSummary } from '../types'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
rows: RoleSummary[]
|
rows: RoleSummary[]
|
||||||
|
interactive?: boolean
|
||||||
|
selectedRoleId?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [role: RoleSummary]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="role-cards">
|
<div v-if="rows.length" class="role-cards">
|
||||||
<article v-for="role in rows" :key="role.id" class="role-card">
|
<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>
|
<h3>
|
||||||
<span class="role-color" :style="{ backgroundColor: role.color }" aria-hidden="true" />
|
<span class="role-color" :style="{ backgroundColor: role.color }" aria-hidden="true" />
|
||||||
{{ role.name }}
|
{{ role.name }}
|
||||||
@@ -17,8 +32,10 @@ defineProps<{
|
|||||||
<span class="role-count">
|
<span class="role-count">
|
||||||
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
|
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
|
||||||
</span>
|
</span>
|
||||||
</article>
|
<span v-if="interactive" class="role-action">配置角色权限 →</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="role-empty">暂无角色概览</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -30,10 +47,32 @@ defineProps<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-card {
|
.role-card {
|
||||||
|
width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
padding: 13px 14px;
|
padding: 13px 14px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
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 {
|
.role-card h3 {
|
||||||
@@ -69,6 +108,25 @@ defineProps<{
|
|||||||
border-radius: 20px;
|
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) {
|
@media (max-width: 1200px) {
|
||||||
.role-cards {
|
.role-cards {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserStatus, UserTableRow } from '../types'
|
import type { UserStatus, UserTableRow } from '../types'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
rows: UserTableRow[]
|
rows: UserTableRow[]
|
||||||
roleColors: Record<string, string>
|
roleColors: Record<string, string>
|
||||||
disablingId?: string | null
|
disablingId?: string | null
|
||||||
|
canEdit?: boolean
|
||||||
|
canResetPassword?: boolean
|
||||||
|
canDeactivate?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -72,12 +75,24 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<button type="button" class="table-button" @click="emit('edit', row)">编辑</button>
|
<button
|
||||||
<button type="button" class="table-button" @click="emit('resetPassword', row)">
|
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>
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'enabled'"
|
v-if="props.canDeactivate && row.status === 'enabled'"
|
||||||
type="button"
|
type="button"
|
||||||
class="table-button table-button--danger"
|
class="table-button table-button--danger"
|
||||||
:disabled="disablingId === row.id"
|
:disabled="disablingId === row.id"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
UserRecord as ApiUserRecord,
|
UserRecord as ApiUserRecord,
|
||||||
} from '@/api/management/types'
|
} from '@/api/management/types'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
import RoleSummaryCards from './components/RoleSummaryCards.vue'
|
||||||
import UserTable from './components/UserTable.vue'
|
import UserTable from './components/UserTable.vue'
|
||||||
@@ -45,6 +46,7 @@ import { roleColors, roleSummaries, users } from './mock'
|
|||||||
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
|
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const loginStore = useLoginStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const roleRows = ref<RoleSummary[]>([])
|
const roleRows = ref<RoleSummary[]>([])
|
||||||
@@ -74,8 +76,33 @@ const rolePermissionVersion = ref<number | undefined>()
|
|||||||
const apiPermissionLoading = ref(false)
|
const apiPermissionLoading = ref(false)
|
||||||
const apiPermissionSaving = ref(false)
|
const apiPermissionSaving = ref(false)
|
||||||
const apiPermissionError = ref('')
|
const apiPermissionError = ref('')
|
||||||
|
const rolePermissionDrawerVisible = ref(false)
|
||||||
let userRequestId = 0
|
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[] = [
|
const mockCampuses: CampusRecord[] = [
|
||||||
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
{ id: 'mock-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
||||||
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
{ id: 'mock-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
||||||
@@ -229,7 +256,7 @@ async function loadSelectedRolePermissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadApiPermissionCatalog() {
|
async function loadApiPermissionCatalog() {
|
||||||
if (isUserMockEnabled || apiPermissionLoading.value) {
|
if (isUserMockEnabled || !canViewRoleApiPermissions.value || apiPermissionLoading.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +282,10 @@ async function loadApiPermissionCatalog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectRoleForApiPermissions(roleId: string) {
|
async function selectRoleForApiPermissions(roleId: string) {
|
||||||
|
if (!canViewRoleApiPermissions.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
selectedRoleId.value = roleId
|
selectedRoleId.value = roleId
|
||||||
apiPermissionLoading.value = true
|
apiPermissionLoading.value = true
|
||||||
apiPermissionError.value = ''
|
apiPermissionError.value = ''
|
||||||
@@ -282,7 +313,7 @@ function toggleApiPermission(permissionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveRoleApiPermissions() {
|
async function saveRoleApiPermissions() {
|
||||||
if (!selectedRoleId.value || apiPermissionSaving.value) {
|
if (!canEditRoleApiPermissions.value || !selectedRoleId.value || apiPermissionSaving.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,11 +394,19 @@ function mapRoleSummary(role: RoleRecord): RoleSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showAddUserMessage() {
|
function showAddUserMessage() {
|
||||||
|
if (!canCreateUsers.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
editorInitial.value = null
|
editorInitial.value = null
|
||||||
editorVisible.value = true
|
editorVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function showImportMessage() {
|
function showImportMessage() {
|
||||||
|
if (!canImportUsers.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isUserMockEnabled) {
|
if (isUserMockEnabled) {
|
||||||
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
||||||
return
|
return
|
||||||
@@ -379,6 +418,10 @@ function showImportMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function editUser(row: UserTableRow) {
|
async function editUser(row: UserTableRow) {
|
||||||
|
if (!canEditUsers.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = isUserMockEnabled
|
const user = isUserMockEnabled
|
||||||
? userRows.value.find((item) => item.id === row.id)
|
? userRows.value.find((item) => item.id === row.id)
|
||||||
@@ -419,6 +462,10 @@ async function editUser(row: UserTableRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetPassword(user: UserTableRow) {
|
function resetPassword(user: UserTableRow) {
|
||||||
|
if (!canResetPasswords.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isUserMockEnabled) {
|
if (isUserMockEnabled) {
|
||||||
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
|
||||||
return
|
return
|
||||||
@@ -515,7 +562,11 @@ function changeUserPage(nextPage: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deactivateUser(row: UserTableRow) {
|
async function deactivateUser(row: UserTableRow) {
|
||||||
if (row.status !== 'enabled' || !window.confirm(`确定停用用户“${row.name}”吗?`)) {
|
if (
|
||||||
|
!canDeactivateUsers.value ||
|
||||||
|
row.status !== 'enabled' ||
|
||||||
|
!window.confirm(`确定停用用户“${row.name}”吗?`)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,6 +714,15 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
|
|
||||||
|
function openRolePermissionDrawer(role: RoleSummary) {
|
||||||
|
if (!canViewRoleApiPermissions.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rolePermissionDrawerVisible.value = true
|
||||||
|
void selectRoleForApiPermissions(role.id)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -672,7 +732,12 @@ onMounted(loadUsers)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RoleSummaryCards :rows="roleRows" />
|
<RoleSummaryCards
|
||||||
|
:rows="roleRows"
|
||||||
|
:interactive="canViewRoleApiPermissions"
|
||||||
|
:selected-role-id="selectedRoleId"
|
||||||
|
@select="openRolePermissionDrawer"
|
||||||
|
/>
|
||||||
|
|
||||||
<section class="page-card">
|
<section class="page-card">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
@@ -687,13 +752,30 @@ onMounted(loadUsers)
|
|||||||
@keydown.enter.prevent="searchUsers"
|
@keydown.enter.prevent="searchUsers"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="table-button" @click="searchUsers">查询</button>
|
<button type="button" class="table-button" @click="searchUsers">查询</button>
|
||||||
<button type="button" class="table-button" @click="showAddUserMessage">+新增用户</button>
|
<button
|
||||||
<button type="button" class="table-button" @click="showImportMessage">批量导入</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>
|
</div>
|
||||||
<UserTable
|
<UserTable
|
||||||
:rows="visibleUsers"
|
:rows="visibleUsers"
|
||||||
:role-colors="roleColorMap"
|
:role-colors="roleColorMap"
|
||||||
:disabling-id="disablingUserId"
|
:disabling-id="disablingUserId"
|
||||||
|
:can-edit="canEditUsers"
|
||||||
|
:can-reset-password="canResetPasswords"
|
||||||
|
:can-deactivate="canDeactivateUsers"
|
||||||
@edit="editUser"
|
@edit="editUser"
|
||||||
@reset-password="resetPassword"
|
@reset-password="resetPassword"
|
||||||
@deactivate="deactivateUser"
|
@deactivate="deactivateUser"
|
||||||
@@ -720,8 +802,14 @@ onMounted(loadUsers)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<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
|
<RoleApiPermissionPanel
|
||||||
v-if="!isUserMockEnabled"
|
|
||||||
:roles="roleOptions"
|
:roles="roleOptions"
|
||||||
:permissions="apiPermissions"
|
:permissions="apiPermissions"
|
||||||
:selected-role-id="selectedRoleId"
|
:selected-role-id="selectedRoleId"
|
||||||
@@ -729,10 +817,16 @@ onMounted(loadUsers)
|
|||||||
:loading="apiPermissionLoading"
|
:loading="apiPermissionLoading"
|
||||||
:saving="apiPermissionSaving"
|
:saving="apiPermissionSaving"
|
||||||
:error="apiPermissionError"
|
:error="apiPermissionError"
|
||||||
|
:can-edit="canEditRoleApiPermissions"
|
||||||
|
drawer
|
||||||
@select-role="selectRoleForApiPermissions"
|
@select-role="selectRoleForApiPermissions"
|
||||||
@toggle-permission="toggleApiPermission"
|
@toggle-permission="toggleApiPermission"
|
||||||
@save="saveRoleApiPermissions"
|
@save="saveRoleApiPermissions"
|
||||||
|
@close="rolePermissionDrawerVisible = false"
|
||||||
/>
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UserEditorDialog
|
<UserEditorDialog
|
||||||
@@ -841,6 +935,24 @@ onMounted(loadUsers)
|
|||||||
opacity: 0.55;
|
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 {
|
.loading-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
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) {
|
@media (max-width: 900px) {
|
||||||
.card-heading {
|
.card-heading {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
Reference in New Issue
Block a user