统一项目角色与接口权限配置
This commit is contained in:
@@ -12,8 +12,8 @@ vi.mock("@/api/projectPermissions", () => ({
|
||||
data: [
|
||||
{ category: "PM", name: "PM", description: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
|
||||
{ category: "CRA", name: "CRA", description: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
|
||||
{ category: "IMP", name: "CTA", description: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
|
||||
{ category: "MEDICAL_REVIEW", name: "QA", description: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
|
||||
{ category: "CTA", name: "CTA", description: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
|
||||
{ category: "QA", name: "QA", description: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
|
||||
{ category: "PV", name: "PV", description: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
|
||||
],
|
||||
}),
|
||||
@@ -39,18 +39,18 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
|
||||
const mockMatrix: ApiEndpointPermissionsResponse = {
|
||||
PM: {
|
||||
"POST:/subjects": true,
|
||||
"GET:/subjects": true,
|
||||
"GET:/subjects/{id}": true,
|
||||
"PATCH:/subjects/{id}": true,
|
||||
"DELETE:/subjects/{id}": true,
|
||||
"subjects:create": true,
|
||||
"subjects:list": true,
|
||||
"subjects:read": true,
|
||||
"subjects:update": true,
|
||||
"subjects:delete": true,
|
||||
},
|
||||
CRA: {
|
||||
"POST:/subjects": true,
|
||||
"GET:/subjects": true,
|
||||
"GET:/subjects/{id}": true,
|
||||
"PATCH:/subjects/{id}": true,
|
||||
"DELETE:/subjects/{id}": false,
|
||||
"subjects:create": true,
|
||||
"subjects:list": true,
|
||||
"subjects:read": true,
|
||||
"subjects:update": true,
|
||||
"subjects:delete": false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,7 +64,6 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
ElCheckbox: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -72,24 +71,283 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
expect(wrapper.find(".api-permissions").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits update event when permission changes", async () => {
|
||||
it("keeps matrix read-only and renders authorization states", async () => {
|
||||
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
||||
|
||||
expect(source).toContain('emit("update", updatedMatrix)');
|
||||
expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
|
||||
expect(source).not.toContain("只读查看各角色权限覆盖");
|
||||
expect(source).toContain("permission-state--allowed");
|
||||
expect(source).toContain("permission-state--disabled");
|
||||
expect(source).toContain("CircleCloseFilled");
|
||||
expect(source).not.toContain('? "✓" : "—"');
|
||||
expect(source).not.toContain("defineEmits");
|
||||
expect(source).not.toContain("el-checkbox");
|
||||
});
|
||||
|
||||
it("renders role headers from template display names while keeping matrix role keys", () => {
|
||||
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("compareRolesByTemplateOrder");
|
||||
expect(source).toContain(':label="roleLabel(role)"');
|
||||
expect(source).toContain(':key="role"');
|
||||
expect(source).toContain("isOperationAllowed(row.operation_key, role)");
|
||||
expect(source).toContain("onPermissionChange(row.operation_key, role, val)");
|
||||
expect(source).toContain("permission-state");
|
||||
expect(source).toContain('label="权限类型"');
|
||||
expect(source).not.toContain(':label="role"');
|
||||
});
|
||||
|
||||
it("sorts matrix role columns with the role list order", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: {
|
||||
CRA: {},
|
||||
CTA: {},
|
||||
QA: {},
|
||||
PM: {},
|
||||
PV: {},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.dynamicImportSettled();
|
||||
|
||||
expect((wrapper.vm as any).roles).toEqual(["PM", "CRA", "CTA", "QA", "PV"]);
|
||||
});
|
||||
|
||||
it("sorts project permission modules by sidebar menu order", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(wrapper.vm as any).operations = [
|
||||
{ operation_key: "subjects:read", module: "subjects", action: "read", description: "查询参与者详情", default_roles: [] },
|
||||
{ operation_key: "sites:read", module: "sites", action: "read", description: "查询中心详情", default_roles: [] },
|
||||
{ operation_key: "audit_logs:read", module: "audit_export", action: "read", description: "查询审计日志", default_roles: [] },
|
||||
{ operation_key: "project_overview:read", module: "project_overview", action: "read", description: "查询项目总览", default_roles: [] },
|
||||
{ operation_key: "startup_auth:read", module: "startup_auth", action: "read", description: "查询启动授权", default_roles: [] },
|
||||
{ operation_key: "fees_contracts:update", module: "fees", action: "write", description: "更新合同费用", default_roles: [] },
|
||||
{ operation_key: "fees_contracts:read", module: "fees", action: "read", description: "查询合同费用", default_roles: [] },
|
||||
{ operation_key: "monitoring_issues:read", module: "risk_issues", action: "read", description: "查询监查访视问题详情", default_roles: [] },
|
||||
];
|
||||
|
||||
expect((wrapper.vm as any).filteredOperations.map((operation: any) => operation.module)).toEqual([
|
||||
"sites",
|
||||
"audit_export",
|
||||
"project_overview",
|
||||
"fees",
|
||||
"fees",
|
||||
"startup_auth",
|
||||
"subjects",
|
||||
"risk_issues",
|
||||
]);
|
||||
expect((wrapper.vm as any).filteredOperations.map((operation: any) => operation.operation_key)).toEqual([
|
||||
"sites:read",
|
||||
"audit_logs:read",
|
||||
"project_overview:read",
|
||||
"fees_contracts:update",
|
||||
"fees_contracts:read",
|
||||
"startup_auth:read",
|
||||
"subjects:read",
|
||||
"monitoring_issues:read",
|
||||
]);
|
||||
expect((wrapper.vm as any).uniqueModules).toEqual([
|
||||
"sites",
|
||||
"audit_export",
|
||||
"project_overview",
|
||||
"fees",
|
||||
"startup_auth",
|
||||
"subjects",
|
||||
"risk_issues",
|
||||
]);
|
||||
expect((wrapper.vm as any).getPermissionCategoryLabel({ module: "sites" })).toBe("通用权限");
|
||||
expect((wrapper.vm as any).getPermissionCategoryLabel({ module: "fees" })).toBe("业务权限");
|
||||
});
|
||||
|
||||
it("groups participant permissions by section labels", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "subject_aes:create",
|
||||
module: "subjects",
|
||||
action: "write",
|
||||
description: "创建参与者AE",
|
||||
default_roles: [],
|
||||
})).toBe("AE");
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "subject_pds:create",
|
||||
module: "subjects",
|
||||
action: "write",
|
||||
description: "创建参与者PD",
|
||||
default_roles: [],
|
||||
})).toBe("PD");
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "visits:list",
|
||||
module: "subjects",
|
||||
action: "read",
|
||||
description: "查询访视列表",
|
||||
default_roles: [],
|
||||
})).toBe("访视");
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "monitoring_issues:create",
|
||||
module: "risk_issues",
|
||||
action: "write",
|
||||
description: "创建监查访视问题",
|
||||
default_roles: [],
|
||||
})).toBe("监查访视问题");
|
||||
});
|
||||
|
||||
it("projects AE/SAE and PD permissions into risk issues while reusing participant operation keys", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(wrapper.vm as any).operations = [
|
||||
{ operation_key: "subject_aes:list", module: "subjects", action: "read", description: "查询参与者AE列表", default_roles: [] },
|
||||
{ operation_key: "subject_pds:list", module: "subjects", action: "read", description: "查询参与者PD列表", default_roles: [] },
|
||||
{ operation_key: "monitoring_issues:list", module: "risk_issues", action: "read", description: "查询监查访视问题列表", default_roles: [] },
|
||||
];
|
||||
|
||||
const riskRows = (wrapper.vm as any).filteredOperations.filter((operation: any) => operation.module === "risk_issues");
|
||||
|
||||
expect(riskRows.map((operation: any) => operation.operation_key)).toEqual([
|
||||
"subject_aes:list",
|
||||
"subject_pds:list",
|
||||
"monitoring_issues:list",
|
||||
]);
|
||||
expect(riskRows.map((operation: any) => (wrapper.vm as any).getOperationSection(operation))).toEqual([
|
||||
"AE/SAE",
|
||||
"PD",
|
||||
"监查访视问题",
|
||||
]);
|
||||
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:list")).toHaveLength(2);
|
||||
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps contract fee permissions as one module section", () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "fees_contracts:create",
|
||||
module: "fees",
|
||||
action: "write",
|
||||
description: "创建合同费用",
|
||||
default_roles: [],
|
||||
})).toBe("合同费用");
|
||||
|
||||
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
|
||||
expect(source).not.toContain("finance_contracts");
|
||||
expect(source).not.toContain("fees_payments");
|
||||
expect(source).not.toContain("fees_attachments");
|
||||
expect(source).not.toContain('contract_fees: "合同费用"');
|
||||
expect(source).not.toContain("FEE_SECTION_LABELS");
|
||||
});
|
||||
|
||||
it("groups materials permissions into drug flow and equipment sections", () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "drug_shipments:read",
|
||||
module: "materials",
|
||||
action: "read",
|
||||
description: "查询药品流向详情",
|
||||
default_roles: [],
|
||||
})).toBe("药品流向管理");
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "material_equipments:read",
|
||||
module: "materials",
|
||||
action: "read",
|
||||
description: "查询设备详情",
|
||||
default_roles: [],
|
||||
})).toBe("设备管理");
|
||||
});
|
||||
|
||||
it("groups startup ethics permissions with visible business names", () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
project: { id: "test-id", name: "Test Project" },
|
||||
matrix: mockMatrix,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "startup_initiation:read",
|
||||
module: "startup_ethics",
|
||||
action: "read",
|
||||
description: "查询立项记录详情",
|
||||
default_roles: [],
|
||||
})).toBe("立项记录");
|
||||
expect((wrapper.vm as any).getOperationSection({
|
||||
operation_key: "startup_ethics:read",
|
||||
module: "startup_ethics",
|
||||
action: "read",
|
||||
description: "查询伦理记录详情",
|
||||
default_roles: [],
|
||||
})).toBe("伦理记录");
|
||||
});
|
||||
|
||||
it("filters endpoints by search text", async () => {
|
||||
const wrapper = mount(ApiEndpointPermissions, {
|
||||
props: {
|
||||
@@ -100,7 +358,6 @@ describe("ApiEndpointPermissions.vue", () => {
|
||||
stubs: {
|
||||
ElTable: false,
|
||||
ElTableColumn: false,
|
||||
ElCheckbox: false,
|
||||
ElInput: false,
|
||||
ElSelect: false,
|
||||
},
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<div class="api-permissions">
|
||||
<div class="api-permissions-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">接口权限矩阵</span>
|
||||
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
|
||||
<span class="toolbar-title">项目级权限矩阵</span>
|
||||
</div>
|
||||
<div class="toolbar-filters">
|
||||
<el-input
|
||||
@@ -34,14 +33,40 @@
|
||||
</div>
|
||||
|
||||
<div class="api-permissions-table-wrapper">
|
||||
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
|
||||
<el-table-column prop="module" label="模块" width="140">
|
||||
<el-table
|
||||
:data="filteredOperations"
|
||||
border
|
||||
stripe
|
||||
:span-method="spanMethod"
|
||||
class="perm-matrix-table"
|
||||
>
|
||||
<el-table-column prop="permission_category" label="权限类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="module-label">{{ moduleLabel(row.module) }}</span>
|
||||
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
|
||||
<span class="hierarchy-bar" />
|
||||
<span class="category-label">{{ getPermissionCategoryLabel(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="operation_key" label="权限操作" width="200">
|
||||
<el-table-column prop="module" label="模块" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="hierarchy-cell hierarchy-cell--module">
|
||||
<span class="module-dot" />
|
||||
<span class="module-label">{{ moduleLabel(row.module) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="section" label="模块细分" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="hierarchy-cell hierarchy-cell--section">
|
||||
<span class="section-chip">{{ getOperationSection(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="operation_key" label="权限操作" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="operation-cell">
|
||||
<el-tag :type="getActionType(row)" size="small" class="action-tag">
|
||||
@@ -60,11 +85,17 @@
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-checkbox
|
||||
:model-value="isOperationAllowed(row.operation_key, role)"
|
||||
:disabled="!isRoleEditable(role)"
|
||||
@change="(val: boolean) => onPermissionChange(row.operation_key, role, val)"
|
||||
/>
|
||||
<span
|
||||
class="permission-state"
|
||||
:class="{
|
||||
'permission-state--allowed': isOperationAllowed(row.operation_key, role),
|
||||
'permission-state--disabled': !isOperationAllowed(row.operation_key, role),
|
||||
}"
|
||||
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
|
||||
>
|
||||
<template v-if="isOperationAllowed(row.operation_key, role)">✓</template>
|
||||
<CircleCloseFilled v-else />
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -74,14 +105,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { ApiEndpointPermissionsResponse } from "@/types/api";
|
||||
import { fetchApiOperations } from "@/api/projectPermissions";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import {
|
||||
compareProjectPermissionModules,
|
||||
projectPermissionCategory,
|
||||
projectPermissionCategoryLabel,
|
||||
projectPermissionModuleLabel,
|
||||
} from "@/utils/projectPermissionModules";
|
||||
|
||||
interface Operation {
|
||||
operation_key: string;
|
||||
@@ -91,61 +126,65 @@ interface Operation {
|
||||
default_roles: string[];
|
||||
}
|
||||
|
||||
interface DisplayOperation extends Operation {
|
||||
source_module: string;
|
||||
source_index: number;
|
||||
display_section?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: any;
|
||||
matrix: ApiEndpointPermissionsResponse | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update", matrix: ApiEndpointPermissionsResponse): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const isRoleEditable = (role: string) => {
|
||||
if (role === "ADMIN") return false;
|
||||
if (isAdmin.value) return true;
|
||||
return role !== "PM";
|
||||
};
|
||||
const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
|
||||
const searchText = ref("");
|
||||
const filterModule = ref("");
|
||||
const filterAction = ref("");
|
||||
const operations = ref<Operation[]>([]);
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
subjects: "参与者管理",
|
||||
sites: "中心管理",
|
||||
risk_issues: "风险问题",
|
||||
monitoring_audit: "监查稽查",
|
||||
project_members: "项目成员",
|
||||
project_milestones: "项目里程碑",
|
||||
project_overview: "项目总览",
|
||||
setup_config: "立项配置",
|
||||
fees: "合同费用",
|
||||
materials: "物资管理",
|
||||
material_equipments: "物资设备",
|
||||
documents: "文件版本",
|
||||
startup_ethics: "立项与伦理",
|
||||
startup_auth: "启动与授权",
|
||||
audit_export: "审计日志",
|
||||
faq: "FAQ",
|
||||
shared_library: "共享库",
|
||||
attachments: "附件管理",
|
||||
dashboard: "仪表板",
|
||||
subject_histories: "参与者历史",
|
||||
const moduleLabel = projectPermissionModuleLabel;
|
||||
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
|
||||
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
|
||||
|
||||
const RISK_ISSUE_REUSED_SECTIONS: Record<string, string> = {
|
||||
subject_aes: "AE/SAE",
|
||||
subject_pds: "PD",
|
||||
};
|
||||
|
||||
const moduleLabel = (module: string): string => MODULE_LABELS[module] || module;
|
||||
const getOperationPrefix = (row: Operation): string => row.operation_key.split(":")[0] || row.module;
|
||||
|
||||
const displayOperations = computed<DisplayOperation[]>(() => {
|
||||
const rows: DisplayOperation[] = operations.value.map((operation, index) => ({
|
||||
...operation,
|
||||
source_module: operation.module,
|
||||
source_index: index,
|
||||
}));
|
||||
|
||||
operations.value.forEach((operation, index) => {
|
||||
const prefix = getOperationPrefix(operation);
|
||||
const displaySection = RISK_ISSUE_REUSED_SECTIONS[prefix];
|
||||
if (!displaySection) return;
|
||||
if (rows.some((row) => row.module === "risk_issues" && row.operation_key === operation.operation_key)) return;
|
||||
rows.push({
|
||||
...operation,
|
||||
module: "risk_issues",
|
||||
source_module: operation.module,
|
||||
source_index: index,
|
||||
display_section: displaySection,
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
// 从矩阵中提取角色列表
|
||||
const roles = computed(() => {
|
||||
if (!props.matrix) return [];
|
||||
return Object.keys(props.matrix).sort();
|
||||
return Object.keys(props.matrix).sort(compareRolesByTemplateOrder);
|
||||
});
|
||||
|
||||
// 加载权限操作列表
|
||||
@@ -161,17 +200,19 @@ const loadOperations = async () => {
|
||||
|
||||
// 获取唯一的模块列表
|
||||
const uniqueModules = computed(() => {
|
||||
return [...new Set(operations.value.map((o) => o.module))].sort();
|
||||
return [...new Set(displayOperations.value.map((o) => o.module))].sort(compareProjectPermissionModules);
|
||||
});
|
||||
|
||||
// 过滤并按模块分组排序
|
||||
const filteredOperations = computed(() => {
|
||||
const filtered = operations.value.filter((operation) => {
|
||||
const filtered = displayOperations.value.filter((operation) => {
|
||||
const sectionLabel = getOperationSection(operation);
|
||||
const matchSearch =
|
||||
!searchText.value ||
|
||||
operation.operation_key.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
||||
operation.description.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
||||
moduleLabel(operation.module).includes(searchText.value);
|
||||
moduleLabel(operation.module).includes(searchText.value) ||
|
||||
sectionLabel.includes(searchText.value);
|
||||
|
||||
const matchModule = !filterModule.value || operation.module === filterModule.value;
|
||||
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
|
||||
@@ -179,20 +220,36 @@ const filteredOperations = computed(() => {
|
||||
return matchSearch && matchModule && matchAction;
|
||||
});
|
||||
|
||||
// 按模块分组排序
|
||||
// 按权限类型、模块分组排序
|
||||
filtered.sort((a, b) => {
|
||||
const categoryDiff = Number(getPermissionCategory(a) === "business") - Number(getPermissionCategory(b) === "business");
|
||||
if (categoryDiff !== 0) return categoryDiff;
|
||||
if (a.module !== b.module) {
|
||||
return moduleLabel(a.module).localeCompare(moduleLabel(b.module), "zh-CN");
|
||||
return compareProjectPermissionModules(a.module, b.module);
|
||||
}
|
||||
return a.operation_key.localeCompare(b.operation_key);
|
||||
const sectionDiff = compareOperationSections(a, b);
|
||||
if (sectionDiff !== 0) return sectionDiff;
|
||||
return a.source_index - b.source_index;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// 模块列合并行
|
||||
const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex: number; column: any; columnIndex: number }) => {
|
||||
// 权限类型、模块与模块细分列合并行
|
||||
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
|
||||
if (columnIndex === 0) {
|
||||
const list = filteredOperations.value;
|
||||
const category = getPermissionCategory(row);
|
||||
if (rowIndex === 0 || getPermissionCategory(list[rowIndex - 1]) !== category) {
|
||||
let count = 1;
|
||||
for (let i = rowIndex + 1; i < list.length && getPermissionCategory(list[i]) === category; i++) {
|
||||
count++;
|
||||
}
|
||||
return { rowspan: count, colspan: 1 };
|
||||
}
|
||||
return { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
if (columnIndex === 1) {
|
||||
const list = filteredOperations.value;
|
||||
if (rowIndex === 0 || list[rowIndex - 1].module !== row.module) {
|
||||
let count = 1;
|
||||
@@ -203,6 +260,28 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
|
||||
}
|
||||
return { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
if (columnIndex === 2) {
|
||||
const list = filteredOperations.value;
|
||||
const section = getOperationSection(row);
|
||||
if (
|
||||
rowIndex === 0 ||
|
||||
list[rowIndex - 1].module !== row.module ||
|
||||
getOperationSection(list[rowIndex - 1]) !== section
|
||||
) {
|
||||
let count = 1;
|
||||
for (
|
||||
let i = rowIndex + 1;
|
||||
i < list.length &&
|
||||
list[i].module === row.module &&
|
||||
getOperationSection(list[i]) === section;
|
||||
i++
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
return { rowspan: count, colspan: 1 };
|
||||
}
|
||||
return { rowspan: 0, colspan: 0 };
|
||||
}
|
||||
return { rowspan: 1, colspan: 1 };
|
||||
};
|
||||
|
||||
@@ -252,19 +331,53 @@ const getActionType = (row: Operation): string => {
|
||||
return ACTION_TAG_TYPES[verb] || "info";
|
||||
};
|
||||
|
||||
// 权限变更处理
|
||||
const onPermissionChange = (operation_key: string, role: string, allowed: boolean) => {
|
||||
if (!props.matrix) return;
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
subjects: "参与者基础信息",
|
||||
visits: "访视",
|
||||
subject_aes: "AE",
|
||||
subject_pds: "PD",
|
||||
subject_histories: "病史",
|
||||
monitoring_issues: "监查访视问题",
|
||||
project_milestones: "项目里程碑",
|
||||
drug_shipments: "药品流向管理",
|
||||
material_equipments: "设备管理",
|
||||
startup_initiation: "立项记录",
|
||||
startup_ethics: "伦理记录",
|
||||
precautions: "注意事项",
|
||||
precautions_attachments: "注意事项",
|
||||
faq: "医学咨询/FAQ",
|
||||
faq_category: "医学咨询/FAQ",
|
||||
faq_reply: "医学咨询/FAQ",
|
||||
faq_attachments: "医学咨询/FAQ",
|
||||
};
|
||||
|
||||
const updatedMatrix: ApiEndpointPermissionsResponse = JSON.parse(JSON.stringify(props.matrix));
|
||||
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
|
||||
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
|
||||
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
|
||||
shared_library: ["医学咨询/FAQ", "注意事项"],
|
||||
};
|
||||
|
||||
if (!updatedMatrix[role]) {
|
||||
updatedMatrix[role] = {};
|
||||
const getOperationSection = (row: Operation): string => {
|
||||
const displaySection = (row as Partial<DisplayOperation>).display_section;
|
||||
if (displaySection) return displaySection;
|
||||
const prefix = getOperationPrefix(row);
|
||||
return SECTION_LABELS[prefix] || moduleLabel(row.module);
|
||||
};
|
||||
|
||||
const compareOperationSections = (a: Operation, b: Operation): number => {
|
||||
const aSection = getOperationSection(a);
|
||||
const bSection = getOperationSection(b);
|
||||
const orderedSections = a.module === b.module ? SECTION_ORDER_BY_MODULE[a.module] : undefined;
|
||||
if (orderedSections) {
|
||||
const aIndex = orderedSections.indexOf(aSection);
|
||||
const bIndex = orderedSections.indexOf(bSection);
|
||||
if (aIndex !== -1 || bIndex !== -1) {
|
||||
if (aIndex === -1) return 1;
|
||||
if (bIndex === -1) return -1;
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
}
|
||||
|
||||
updatedMatrix[role][operation_key] = allowed;
|
||||
|
||||
emit("update", updatedMatrix);
|
||||
return aSection.localeCompare(bSection, "zh-CN");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -272,7 +385,7 @@ onMounted(() => {
|
||||
loadRoleTemplates();
|
||||
});
|
||||
|
||||
defineExpose({ searchText });
|
||||
defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermissionCategoryLabel });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -305,11 +418,6 @@ defineExpose({ searchText });
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.toolbar-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -327,10 +435,73 @@ defineExpose({ searchText });
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hierarchy-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 权限类型:左侧渐变色条 + 加粗大字 */
|
||||
.hierarchy-cell--category {
|
||||
position: relative;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.hierarchy-bar {
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 36px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
|
||||
}
|
||||
|
||||
.hierarchy-cell--category[data-category="common"] .hierarchy-bar {
|
||||
background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%);
|
||||
}
|
||||
|
||||
.hierarchy-cell--category[data-category="business"] .hierarchy-bar {
|
||||
background: linear-gradient(180deg, #8b5cf6 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
.category-label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* 模块:实心圆点 + 中等加粗字 */
|
||||
.module-dot {
|
||||
flex-shrink: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.module-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #1a2332;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 模块细分:胶囊样式 */
|
||||
.section-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operation-cell {
|
||||
@@ -349,4 +520,31 @@ defineExpose({ searchText });
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.permission-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.permission-state--allowed {
|
||||
color: #0f766e;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #99f6e4;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.permission-state--disabled {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.permission-state--disabled :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,11 +29,10 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).not.toContain("fetchIpLocations");
|
||||
});
|
||||
|
||||
it("does not expose QA in role filter presets", () => {
|
||||
it("exposes QA and CTA in role filter presets", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain('label="QA" value="QA"');
|
||||
expect(source).not.toContain('QA: "QA"');
|
||||
expect(source).toContain('const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];');
|
||||
});
|
||||
|
||||
it("uses permission template names for role filters and log lines", () => {
|
||||
@@ -45,7 +44,7 @@ describe("PermissionAccessLogs", () => {
|
||||
expect(source).toContain('v-for="role in roleFilterOptions"');
|
||||
expect(source).toContain("role=${roleLabel(row.role)}");
|
||||
expect(source).not.toContain("const ROLE_LABELS");
|
||||
expect(source).not.toContain('label="医学审核" value="MEDICAL_REVIEW"');
|
||||
expect(source).not.toContain('label="医学审核" value="QA"');
|
||||
});
|
||||
|
||||
it("uses narrow metric cards without helper subtitles", () => {
|
||||
|
||||
@@ -219,7 +219,7 @@ const filters = reactive({
|
||||
allowed: undefined as boolean | undefined,
|
||||
});
|
||||
|
||||
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP"];
|
||||
const ROLE_FILTER_KEYS = ["ADMIN", "PM", "CRA", "PV", "QA", "CTA"];
|
||||
const roleFilterOptions = computed(() => roleOptionsFor(ROLE_FILTER_KEYS));
|
||||
|
||||
const SECURITY_AUTH_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -32,10 +32,11 @@ describe("PermissionTemplateSelector", () => {
|
||||
expect(source).toContain("emit('edit-role', template.category!)");
|
||||
});
|
||||
|
||||
it("does not define QA as a preset role label or icon", () => {
|
||||
it("does not keep hard-coded preset role labels or icons", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain('QA: "QA"');
|
||||
expect(source).not.toContain('CTA: "CTA"');
|
||||
expect(source).not.toContain('QA: "✅"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="selector-header">
|
||||
<div class="selector-title-group">
|
||||
<h3>角色权限概览</h3>
|
||||
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
|
||||
</div>
|
||||
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
|
||||
</div>
|
||||
@@ -85,7 +84,7 @@ const templates = ref<PermissionTemplate[]>([]);
|
||||
const roleIcon = (category: string | null) => {
|
||||
const icons: Record<string, string> = {
|
||||
PM: "👑", CRA: "📋", PV: "🔍",
|
||||
MEDICAL_REVIEW: "🏥", IMP: "📦",
|
||||
QA: "🏥", CTA: "📦",
|
||||
};
|
||||
return icons[category ?? ""] ?? "📄";
|
||||
};
|
||||
@@ -162,12 +161,6 @@ watch(() => props.refreshKey, loadTemplates);
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.selector-desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user