统一项目角色与接口权限配置
This commit is contained in:
@@ -29,16 +29,15 @@ const entityTypeLabelMap: Record<string, string> = {
|
||||
subject: "参与者",
|
||||
visit: "访视",
|
||||
ae: "不良事件",
|
||||
finance_contract: "费用合同",
|
||||
contract_fee: "合同费用条目",
|
||||
contract_fee_payment: "合同费用回款",
|
||||
drug_shipment: "药品流向",
|
||||
startup_feasibility: "立项可行性",
|
||||
startup_ethics: "立项伦理",
|
||||
startup_feasibility: "立项记录",
|
||||
startup_ethics: "伦理记录",
|
||||
startup_kickoff: "启动会",
|
||||
training_authorization: "培训授权",
|
||||
knowledge_note: "注意事项",
|
||||
subject_history: "参与者历史",
|
||||
precaution: "注意事项",
|
||||
subject_history: "病史记录",
|
||||
faq_category: "医学咨询分类",
|
||||
faq_item: "医学咨询问题",
|
||||
faq_reply: "医学咨询回复",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { useRoleTemplateMeta } from "./useRoleTemplateMeta";
|
||||
|
||||
vi.mock("@/api/projectPermissions", () => ({
|
||||
fetchPermissionTemplates: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ category: "PM", name: "PM" },
|
||||
{ category: "CRA", name: "CRA" },
|
||||
{ category: "CTA", name: "CTA" },
|
||||
{ category: "QA", name: "QA" },
|
||||
{ category: "PV", name: "PV" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./useRoleTemplateMeta.ts"), "utf8");
|
||||
|
||||
describe("useRoleTemplateMeta fallback copy", () => {
|
||||
it("uses polished preset role labels and descriptions without introducing QA role key", () => {
|
||||
it("uses QA and CTA as first-class preset role keys", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('PM: { label: "PM"');
|
||||
expect(source).toContain("项目负责人,统筹项目全局,协调进度、资源与关键决策。");
|
||||
expect(source).toContain("负责各中心临床监查执行,跟进现场质量、数据和问题闭环。");
|
||||
expect(source).toContain("负责合同、药品及相关项目事务管理,保障执行支持与物资协同。");
|
||||
expect(source).toContain('MEDICAL_REVIEW: { label: "QA"');
|
||||
expect(source).toContain('QA: { label: "QA"');
|
||||
expect(source).toContain("负责医学审核与稽查,关注质量风险、合规性和医学一致性。");
|
||||
expect(source).toContain('IMP: { label: "CTA"');
|
||||
expect(source).toContain('CTA: { label: "CTA"');
|
||||
expect(source).toContain("负责药物警戒相关工作,跟踪安全性事件并支持风险评估。");
|
||||
expect(source).not.toContain("QA: {");
|
||||
});
|
||||
|
||||
it("sorts roles by the role template list order", async () => {
|
||||
const { compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
await loadRoleTemplates();
|
||||
|
||||
expect(["CRA", "PV", "QA", "PM", "CTA"].sort(compareRolesByTemplateOrder)).toEqual([
|
||||
"PM",
|
||||
"CRA",
|
||||
"CTA",
|
||||
"QA",
|
||||
"PV",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ const FALLBACK_ROLE_META: Record<string, { label: string; icon: string; desc: st
|
||||
PM: { label: "PM", icon: "👑", desc: "项目负责人,统筹项目全局,协调进度、资源与关键决策。" },
|
||||
CRA: { label: "CRA", icon: "📋", desc: "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。" },
|
||||
PV: { label: "PV", icon: "🔍", desc: "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。" },
|
||||
MEDICAL_REVIEW: { label: "QA", icon: "🏥", desc: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
|
||||
IMP: { label: "CTA", icon: "📦", desc: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
|
||||
QA: { label: "QA", icon: "🏥", desc: "负责医学审核与稽查,关注质量风险、合规性和医学一致性。" },
|
||||
CTA: { label: "CTA", icon: "📦", desc: "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。" },
|
||||
};
|
||||
|
||||
const templates = ref<PermissionTemplate[]>([]);
|
||||
@@ -47,6 +47,24 @@ export const useRoleTemplateMeta = () => {
|
||||
desc: roleDescription(role),
|
||||
}));
|
||||
|
||||
const roleOrder = computed(() => {
|
||||
const result: Record<string, number> = {};
|
||||
templates.value.forEach((template, index) => {
|
||||
if (template.category && result[template.category] === undefined) {
|
||||
result[template.category] = index;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
const compareRolesByTemplateOrder = (a: string, b: string) => {
|
||||
const order = roleOrder.value;
|
||||
const aOrder = order[a] ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = order[b] ?? Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
|
||||
const loadRoleTemplates = async () => {
|
||||
if (pendingLoad) return pendingLoad;
|
||||
loading.value = true;
|
||||
@@ -69,6 +87,7 @@ export const useRoleTemplateMeta = () => {
|
||||
roleDescription,
|
||||
roleIcon,
|
||||
roleOptionsFor,
|
||||
compareRolesByTemplateOrder,
|
||||
loadRoleTemplates,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ const items = [
|
||||
{ value: "PM", label: TEXT.enums.userRole.PM, order: 2 },
|
||||
{ value: "CRA", label: TEXT.enums.userRole.CRA, order: 3 },
|
||||
{ value: "PV", label: TEXT.enums.userRole.PV, order: 4 },
|
||||
{ value: "MEDICAL_REVIEW", label: TEXT.enums.userRole.MEDICAL_REVIEW, order: 5 },
|
||||
{ value: "IMP", label: TEXT.enums.userRole.IMP, order: 6 },
|
||||
{ value: "QA", label: TEXT.enums.userRole.QA, order: 5 },
|
||||
{ value: "CTA", label: TEXT.enums.userRole.CTA, order: 6 },
|
||||
];
|
||||
|
||||
export const roleDict: Dict = createDict(items);
|
||||
|
||||
@@ -89,6 +89,8 @@ export const TEXT = {
|
||||
userFallback: "用户",
|
||||
basicInfo: "基本信息",
|
||||
allSites: "所有中心",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
},
|
||||
units: {
|
||||
case: "例",
|
||||
@@ -261,7 +263,6 @@ export const TEXT = {
|
||||
projectOverview: "项目总览",
|
||||
projectMilestones: "项目里程碑",
|
||||
finance: "合同费用管理",
|
||||
financeContracts: "合同费用管理",
|
||||
feeContracts: "合同费用管理",
|
||||
drug: "药品管理",
|
||||
materialManagement: "物资管理",
|
||||
@@ -275,10 +276,7 @@ export const TEXT = {
|
||||
riskIssueSae: "AE/SAE",
|
||||
riskIssuePd: "PD",
|
||||
riskIssueMonitoringVisits: "监查访视问题",
|
||||
monitoringAudit: "监查稽查",
|
||||
etmf: "eTMF",
|
||||
monitoring: "监查",
|
||||
audit: "稽查",
|
||||
sharedLibrary: "共享库",
|
||||
knowledgeMedicalConsult: "医学咨询",
|
||||
knowledgeNotes: "注意事项",
|
||||
@@ -358,17 +356,6 @@ export const TEXT = {
|
||||
remark: "备注",
|
||||
},
|
||||
},
|
||||
financeContracts: {
|
||||
title: "合同费用",
|
||||
subtitle: "维护分中心合同费用与附件",
|
||||
empty: "暂无合同费用记录",
|
||||
newTitle: "新增合同费用",
|
||||
editTitle: "编辑合同费用",
|
||||
formSubtitle: "填写分中心合同信息并保存",
|
||||
uploadHint: "保存后可上传合同附件",
|
||||
detailTitle: "合同费用详情",
|
||||
detailSubtitle: "查看合同费用与附件",
|
||||
},
|
||||
feeContracts: {
|
||||
title: "合同费用管理",
|
||||
subtitle: "按中心维护合同费用与分期付款",
|
||||
@@ -378,6 +365,7 @@ export const TEXT = {
|
||||
formSubtitle: "维护合同费用与分期付款信息",
|
||||
detailTitle: "合同费用详情",
|
||||
detailSubtitle: "查看合同费用与分期付款信息",
|
||||
contractInfoTitle: "合同信息",
|
||||
overviewTitle: "费用总览",
|
||||
totalContractAmount: "合同金额总计",
|
||||
totalPaidAmount: "已付金额总计",
|
||||
@@ -387,8 +375,6 @@ export const TEXT = {
|
||||
contractAmount: "合同金额",
|
||||
contractCases: "合同例数",
|
||||
actualCases: "实际例数",
|
||||
settlementAmount: "结算费用",
|
||||
finalPaymentAmount: "尾款费用",
|
||||
caseProgress: "合同/实际例数",
|
||||
paidSummary: "已付/未付",
|
||||
paidTotal: "已付合计",
|
||||
@@ -722,29 +708,52 @@ export const TEXT = {
|
||||
listTitle: "说明文件列表",
|
||||
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
|
||||
},
|
||||
monitoring: {
|
||||
title: "监查",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "监查记录列表",
|
||||
emptyDescription: "监查功能正在搭建基础能力,敬请期待。",
|
||||
},
|
||||
audit: {
|
||||
title: "稽查",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "稽查记录列表",
|
||||
emptyDescription: "稽查模块正在准备基础框架,敬请期待。",
|
||||
},
|
||||
monitoringAudit: {
|
||||
title: "监查稽查",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "监查稽查列表",
|
||||
emptyDescription: "监查稽查模块正在建设中,敬请期待。",
|
||||
},
|
||||
etmf: {
|
||||
title: "eTMF",
|
||||
subtitle: "功能建设中",
|
||||
listTitle: "eTMF 列表",
|
||||
emptyDescription: "eTMF 模块正在建设中,敬请期待。",
|
||||
subtitle: "按 TMF 目录归档项目级与中心级文件",
|
||||
listTitle: "eTMF 目录",
|
||||
emptyDescription: "暂无 eTMF 目录",
|
||||
treeTitle: "TMF 目录",
|
||||
detailTitle: "归档状态",
|
||||
noNodeSelected: "未选择目录",
|
||||
selectNodeHint: "请选择左侧目录查看文件",
|
||||
emptyDocuments: "当前目录暂无文件",
|
||||
rootNode: "根目录",
|
||||
actions: {
|
||||
newNode: "新增目录",
|
||||
newDocument: "归档文件",
|
||||
},
|
||||
dialog: {
|
||||
nodeTitle: "新增 eTMF 目录",
|
||||
documentTitle: "归档文件",
|
||||
},
|
||||
fields: {
|
||||
parent: "上级目录",
|
||||
node: "归档目录",
|
||||
code: "目录编码",
|
||||
name: "目录名称",
|
||||
scope: "适用范围",
|
||||
required: "必需",
|
||||
documentCount: "文件数",
|
||||
effectiveCount: "生效版本",
|
||||
},
|
||||
status: {
|
||||
NOT_REQUIRED: "非必需",
|
||||
MISSING: "缺失",
|
||||
UPLOADED: "已上传",
|
||||
EFFECTIVE: "已生效",
|
||||
INACTIVE: "已停用",
|
||||
},
|
||||
statusDescriptions: {
|
||||
NOT_REQUIRED: "该目录不是必需项,当前没有归档文件。",
|
||||
MISSING: "该目录为必需项,尚未归档文件。",
|
||||
UPLOADED: "该目录已有文件,但暂无生效版本。",
|
||||
EFFECTIVE: "该目录已有生效版本,可满足当前归档要求。",
|
||||
INACTIVE: "该目录已停用,仅保留历史归档上下文。",
|
||||
},
|
||||
messages: {
|
||||
siteRequired: "中心级目录需选择分中心",
|
||||
},
|
||||
},
|
||||
adminUserApproval: {
|
||||
title: "注册审核",
|
||||
@@ -961,8 +970,8 @@ export const TEXT = {
|
||||
PM: "PM",
|
||||
CRA: "CRA",
|
||||
PV: "PV",
|
||||
MEDICAL_REVIEW: "QA",
|
||||
IMP: "CTA",
|
||||
QA: "QA",
|
||||
CTA: "CTA",
|
||||
},
|
||||
userStatus: {
|
||||
PENDING: "待审核",
|
||||
|
||||
@@ -43,4 +43,23 @@ describe("admin project route permissions", () => {
|
||||
expect(source).not.toContain(`path: "${removedPath.slice(1)}"`);
|
||||
expect(source).not.toContain(`"${removedPath}"`);
|
||||
});
|
||||
|
||||
it("does not register legacy finance contract routes", () => {
|
||||
const source = readRouter();
|
||||
|
||||
expect(source).not.toContain("FinanceContracts");
|
||||
expect(source).not.toContain("FinanceContract");
|
||||
expect(source).not.toContain("finance/contracts");
|
||||
});
|
||||
|
||||
it("uses precautions routes instead of knowledge note routes", () => {
|
||||
const source = readRouter();
|
||||
|
||||
expect(source).toContain('path: "knowledge/precautions"');
|
||||
expect(source).toContain('path: "knowledge/precautions/new"');
|
||||
expect(source).toContain('path: "knowledge/precautions/:id"');
|
||||
expect(source).toContain('path: "knowledge/precautions/:id/edit"');
|
||||
expect(source).not.toContain("KnowledgeNote");
|
||||
expect(source).not.toContain("/knowledge/notes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface LoginKeyResponse {
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export type UserRole = "PM" | "CRA" | "PV" | "MEDICAL_REVIEW" | "IMP" | "ADMIN";
|
||||
export type UserRole = "PM" | "CRA" | "PV" | "QA" | "CTA" | "ADMIN";
|
||||
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||
|
||||
export interface UserInfo {
|
||||
|
||||
@@ -13,6 +13,8 @@ describe("permission project role model", () => {
|
||||
expect(source).toContain("study.currentStudyRole");
|
||||
expect(source).toContain("study.currentPermissions");
|
||||
expect(source).toContain('"faq.edit": "faq:update"');
|
||||
expect(source).toContain('"ae.create": "subject_aes:create"');
|
||||
expect(source).toContain('"ae.close": "subject_aes:update"');
|
||||
expect(source).toContain('"fees.contract.create": "fees_contracts:create"');
|
||||
expect(source).toContain('"fees.contract.update": "fees_contracts:update"');
|
||||
expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"');
|
||||
|
||||
@@ -59,15 +59,14 @@ export const usePermission = () => {
|
||||
"subject.enroll": "subjects:update",
|
||||
"subject.complete": "subjects:update",
|
||||
"subject.drop": "subjects:update",
|
||||
"ae.create": "risk_issues:create",
|
||||
"ae.close": "risk_issues:update",
|
||||
"ae.create": "subject_aes:create",
|
||||
"ae.close": "subject_aes:update",
|
||||
"project.overview.read": "project_overview:read",
|
||||
"faq.edit": "faq:update",
|
||||
"faq.create": "faq:create",
|
||||
"faq.reply": "faq_reply:create",
|
||||
"knowledge.notes.write": "knowledge_notes:create",
|
||||
"shared.library.read": "knowledge_notes:read",
|
||||
"shared.library.write": "knowledge_notes:create",
|
||||
"precautions.read": "precautions:read",
|
||||
"precautions.write": "precautions:create",
|
||||
"project.members.manage": "project_members:update",
|
||||
"site.manage": "sites:update",
|
||||
"site.cra.bind": "sites:update",
|
||||
@@ -76,8 +75,6 @@ export const usePermission = () => {
|
||||
"fees.contract.delete": "fees_contracts:delete",
|
||||
"documents.create": "documents:create",
|
||||
"documents.delete": "documents:delete",
|
||||
"fees.attachment.delete": "fees_attachments:delete",
|
||||
"file.attachment.delete": "attachments:delete",
|
||||
"audit.export.read": "audit_logs:read",
|
||||
};
|
||||
const operationKey = permissionMap[action];
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export const PROJECT_PERMISSION_MODULE_LABELS: Record<string, string> = {
|
||||
subjects: "参与者管理",
|
||||
sites: "中心管理",
|
||||
risk_issues: "风险问题",
|
||||
project_members: "项目成员",
|
||||
project_milestones: "项目里程碑",
|
||||
project_overview: "项目总览",
|
||||
setup_config: "立项配置",
|
||||
fees: "合同费用",
|
||||
materials: "物资管理",
|
||||
documents: "文件版本",
|
||||
startup_ethics: "立项与伦理",
|
||||
startup_auth: "启动与授权",
|
||||
audit_export: "审计日志",
|
||||
shared_library: "共享库",
|
||||
subject_histories: "病史记录",
|
||||
};
|
||||
|
||||
const COMMON_PROJECT_PERMISSION_MODULES = new Set([
|
||||
"setup_config",
|
||||
"project_members",
|
||||
"sites",
|
||||
"audit_export",
|
||||
]);
|
||||
|
||||
export const PROJECT_PERMISSION_CATEGORY_LABELS: Record<"common" | "business", string> = {
|
||||
common: "通用权限",
|
||||
business: "业务权限",
|
||||
};
|
||||
|
||||
const PROJECT_MENU_MODULE_ORDER = [
|
||||
"setup_config",
|
||||
"project_members",
|
||||
"sites",
|
||||
"audit_export",
|
||||
"project_overview",
|
||||
"project_milestones",
|
||||
"fees",
|
||||
"materials",
|
||||
"documents",
|
||||
"startup_ethics",
|
||||
"startup_auth",
|
||||
"subjects",
|
||||
"subject_histories",
|
||||
"risk_issues",
|
||||
"shared_library",
|
||||
];
|
||||
|
||||
export const projectPermissionModuleLabel = (module: string): string =>
|
||||
PROJECT_PERMISSION_MODULE_LABELS[module] || module;
|
||||
|
||||
export const projectPermissionCategory = (module: string): "common" | "business" =>
|
||||
COMMON_PROJECT_PERMISSION_MODULES.has(module) ? "common" : "business";
|
||||
|
||||
export const projectPermissionCategoryLabel = (module: string): string =>
|
||||
PROJECT_PERMISSION_CATEGORY_LABELS[projectPermissionCategory(module)];
|
||||
|
||||
export const getProjectPermissionModuleOrder = (module: string): number => {
|
||||
const index = PROJECT_MENU_MODULE_ORDER.indexOf(module);
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
};
|
||||
|
||||
export const compareProjectPermissionModules = (a: string, b: string): number => {
|
||||
const categoryDiff = Number(projectPermissionCategory(a) === "business") - Number(projectPermissionCategory(b) === "business");
|
||||
if (categoryDiff !== 0) return categoryDiff;
|
||||
const orderDiff = getProjectPermissionModuleOrder(a) - getProjectPermissionModuleOrder(b);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return projectPermissionModuleLabel(a).localeCompare(projectPermissionModuleLabel(b), "zh-CN");
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const PROJECT_ROLE_RANK: Record<string, number> = {
|
||||
ADMIN: 100,
|
||||
PM: 80,
|
||||
QA: 60,
|
||||
PV: 50,
|
||||
CRA: 40,
|
||||
CTA: 40,
|
||||
};
|
||||
|
||||
export const compareProjectPermissionRoles = (a: string, b: string): number =>
|
||||
(PROJECT_ROLE_RANK[b] ?? 0) - (PROJECT_ROLE_RANK[a] ?? 0) || a.localeCompare(b);
|
||||
@@ -5,33 +5,41 @@ describe("project route permissions", () => {
|
||||
it("maps project routes to backend operation keys", () => {
|
||||
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
|
||||
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
|
||||
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
|
||||
expect(getProjectRoutePermission("/finance/contracts/new")).toEqual({ operationKey: "finance_contracts:create" });
|
||||
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toEqual({ operationKey: "finance_contracts:update" });
|
||||
expect(getProjectRoutePermission("/finance/contracts")).toBeNull();
|
||||
expect(getProjectRoutePermission("/finance/contracts/new")).toBeNull();
|
||||
expect(getProjectRoutePermission("/finance/contracts/abc/edit")).toBeNull();
|
||||
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
|
||||
expect(getProjectRoutePermission("/drug/shipments/new")).toEqual({ operationKey: "drug_shipments:create" });
|
||||
expect(getProjectRoutePermission("/drug/shipments/abc/edit")).toEqual({ operationKey: "drug_shipments:update" });
|
||||
expect(getProjectRoutePermission("/subjects/new")).toEqual({ operationKey: "subjects:create" });
|
||||
expect(getProjectRoutePermission("/subjects/abc/edit")).toEqual({ operationKey: "subjects:update" });
|
||||
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:create" });
|
||||
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "ethics:update" });
|
||||
expect(getProjectRoutePermission("/risk-issues")).toEqual({ operationKey: "subject_aes:list" });
|
||||
expect(getProjectRoutePermission("/risk-issues/sae")).toEqual({ operationKey: "subject_aes:list" });
|
||||
expect(getProjectRoutePermission("/risk-issues/pd")).toEqual({ operationKey: "subject_pds:list" });
|
||||
expect(getProjectRoutePermission("/risk-issues/monitoring-visits")).toEqual({ operationKey: "monitoring_issues:list" });
|
||||
expect(getProjectRoutePermission("/etmf")).toEqual({ operationKey: "documents:read" });
|
||||
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "startup_initiation:create" });
|
||||
expect(getProjectRoutePermission("/startup/feasibility/abc/edit")).toEqual({ operationKey: "startup_initiation:update" });
|
||||
expect(getProjectRoutePermission("/startup/ethics/new")).toEqual({ operationKey: "startup_ethics:create" });
|
||||
expect(getProjectRoutePermission("/startup/ethics/abc/edit")).toEqual({ operationKey: "startup_ethics:update" });
|
||||
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:create" });
|
||||
expect(getProjectRoutePermission("/startup/kickoff/abc/edit")).toEqual({ operationKey: "startup_auth:update" });
|
||||
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:create" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/abc/edit")).toEqual({ operationKey: "knowledge_notes:update" });
|
||||
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
|
||||
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
|
||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects project routes when the role lacks operation permission", () => {
|
||||
const matrix = {
|
||||
CRA: {
|
||||
"project_overview:read": { allowed: false },
|
||||
"knowledge_notes:read": { allowed: true },
|
||||
"precautions:read": { allowed: true },
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/project/overview"), false)).toBe(false);
|
||||
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/notes"), false)).toBe(true);
|
||||
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/notes");
|
||||
expect(hasProjectPermission(matrix, "CRA", getProjectRoutePermission("/knowledge/precautions"), false)).toBe(true);
|
||||
expect(findFirstAccessibleProjectPath(matrix, "CRA", false)).toBe("/knowledge/precautions");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,39 +7,39 @@ export type ProjectRoutePermission = {
|
||||
|
||||
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
|
||||
{ prefixes: ["/fees/contracts/new"], permission: { operationKey: "fees_contracts:create" } },
|
||||
{ prefixes: ["/finance/contracts/new"], permission: { operationKey: "finance_contracts:create" } },
|
||||
{ prefixes: ["/drug/shipments/new"], permission: { operationKey: "drug_shipments:create" } },
|
||||
{ prefixes: ["/startup/feasibility/new", "/startup/ethics/new"], permission: { operationKey: "ethics:create" } },
|
||||
{ prefixes: ["/startup/feasibility/new"], permission: { operationKey: "startup_initiation:create" } },
|
||||
{ prefixes: ["/startup/ethics/new"], permission: { operationKey: "startup_ethics:create" } },
|
||||
{ prefixes: ["/startup/kickoff/new", "/startup/training/new"], permission: { operationKey: "startup_auth:create" } },
|
||||
{ prefixes: ["/subjects/new"], permission: { operationKey: "subjects:create" } },
|
||||
{ prefixes: ["/knowledge/notes/new"], permission: { operationKey: "knowledge_notes:create" } },
|
||||
{ prefixes: ["/knowledge/precautions/new"], permission: { operationKey: "precautions:create" } },
|
||||
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
|
||||
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
|
||||
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
|
||||
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
|
||||
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
|
||||
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
|
||||
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
|
||||
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
|
||||
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility"], permission: { operationKey: "startup_initiation:read" } },
|
||||
{ prefixes: ["/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
|
||||
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
|
||||
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
|
||||
{ prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
|
||||
{ prefixes: ["/monitoring-audit", "/monitoring", "/audit"], permission: { operationKey: "monitoring_audit:read" } },
|
||||
{ prefixes: ["/risk-issues/monitoring-visits"], permission: { operationKey: "monitoring_issues:list" } },
|
||||
{ prefixes: ["/risk-issues/pd"], permission: { operationKey: "subject_pds:list" } },
|
||||
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:list" } },
|
||||
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
|
||||
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
|
||||
{ prefixes: ["/knowledge/notes", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "knowledge_notes:read" } },
|
||||
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
|
||||
];
|
||||
|
||||
const editRoutePermissions: Array<{ pattern: RegExp; permission: ProjectRoutePermission }> = [
|
||||
{ pattern: /^\/fees\/contracts\/[^/]+\/edit$/, permission: { operationKey: "fees_contracts:update" } },
|
||||
{ pattern: /^\/finance\/contracts\/[^/]+\/edit$/, permission: { operationKey: "finance_contracts:update" } },
|
||||
{ pattern: /^\/drug\/shipments\/[^/]+\/edit$/, permission: { operationKey: "drug_shipments:update" } },
|
||||
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } },
|
||||
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "ethics:update" } },
|
||||
{ pattern: /^\/startup\/feasibility\/[^/]+\/edit$/, permission: { operationKey: "startup_initiation:update" } },
|
||||
{ pattern: /^\/startup\/ethics\/[^/]+\/edit$/, permission: { operationKey: "startup_ethics:update" } },
|
||||
{ pattern: /^\/startup\/kickoff\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
|
||||
{ pattern: /^\/startup\/training\/[^/]+\/edit$/, permission: { operationKey: "startup_auth:update" } },
|
||||
{ pattern: /^\/subjects\/[^/]+\/edit$/, permission: { operationKey: "subjects:update" } },
|
||||
{ pattern: /^\/knowledge\/notes\/[^/]+\/edit$/, permission: { operationKey: "knowledge_notes:update" } },
|
||||
{ pattern: /^\/knowledge\/precautions\/[^/]+\/edit$/, permission: { operationKey: "precautions:update" } },
|
||||
];
|
||||
|
||||
export const projectRouteLandingPaths = [
|
||||
@@ -53,10 +53,9 @@ export const projectRouteLandingPaths = [
|
||||
"/startup/meeting-auth",
|
||||
"/subjects",
|
||||
"/risk-issues/sae",
|
||||
"/monitoring-audit",
|
||||
"/etmf",
|
||||
"/knowledge/medical-consult",
|
||||
"/knowledge/notes",
|
||||
"/knowledge/precautions",
|
||||
"/knowledge/support-files",
|
||||
"/knowledge/instruction-files",
|
||||
];
|
||||
|
||||
@@ -18,4 +18,18 @@ describe("audit logs access", () => {
|
||||
expect(source).not.toContain('const role = auth.user?.role');
|
||||
expect(source).not.toContain('role !== "ADMIN"');
|
||||
});
|
||||
|
||||
it("does not expose legacy startup ethics business labels", () => {
|
||||
const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8");
|
||||
const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8");
|
||||
|
||||
const combined = `${auditSource}\n${overviewSource}`;
|
||||
expect(combined).not.toContain("可行性评估");
|
||||
expect(combined).not.toContain("伦理审批");
|
||||
expect(combined).not.toContain("立项可行性");
|
||||
expect(combined).not.toContain("立项伦理");
|
||||
expect(auditSource).toContain('startup_feasibility: "立项记录"');
|
||||
expect(auditSource).toContain('startup_ethics: "伦理记录"');
|
||||
expect(overviewSource).toContain('label: "伦理记录"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
|
||||
const readProjectPermissionModulesSource = () =>
|
||||
readFileSync(resolve(__dirname, "../../utils/projectPermissionModules.ts"), "utf8");
|
||||
|
||||
describe("permission management custom roles", () => {
|
||||
it("uses role management wording in the drawer", () => {
|
||||
@@ -38,7 +40,7 @@ describe("permission management custom roles", () => {
|
||||
expect(source).not.toContain("输入自定义角色标识");
|
||||
expect(source).toContain("roleOptions");
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("await loadRoleTemplates();");
|
||||
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
@@ -59,7 +61,7 @@ describe("permission management custom roles", () => {
|
||||
it("uses database role names when listing and editing preset roles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
|
||||
expect(source).toContain("name: row.name, description: row.description ?? \"\",");
|
||||
expect(source).not.toContain("templateRoleName(row)");
|
||||
@@ -106,12 +108,10 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
|
||||
});
|
||||
|
||||
it("does not offer QA as a preset project permission role", () => {
|
||||
it("uses QA and CTA as preset project permission role keys", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain('key: "QA"');
|
||||
expect(source).not.toContain('QA: "QA"');
|
||||
expect(source).not.toContain("QA: 60");
|
||||
expect(source).toContain("compareRolesByTemplateOrder(a.key, b.key)");
|
||||
});
|
||||
|
||||
it("submits only the selected role from role editor saves", () => {
|
||||
@@ -143,7 +143,77 @@ describe("permission management custom roles", () => {
|
||||
it("renders setup_config as a Chinese module name in the role editor", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('setup_config: "立项配置"');
|
||||
expect(source).toContain("projectPermissionModuleLabel");
|
||||
});
|
||||
|
||||
it("sorts role editor modules with the shared project permission module order", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("compareProjectPermissionModules");
|
||||
expect(source).toContain("const sorted = [...filtered].sort");
|
||||
expect(source).toContain("const moduleDiff = compareProjectPermissionModules(a.module, b.module);");
|
||||
expect(source).toContain("return allOperations.value.indexOf(a) - allOperations.value.indexOf(b);");
|
||||
expect(source).not.toContain("const ROLE_EDITOR_MODULE_LABELS");
|
||||
});
|
||||
|
||||
it("groups role editor permissions by module sections", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("roleEditorSectionGroups");
|
||||
expect(source).toContain("roleEditorSectionLabel(op)");
|
||||
expect(source).toContain("role-editor-section");
|
||||
expect(source).toContain("getPermissionSectionLabel");
|
||||
expect(source).toContain("comparePermissionSectionLabels");
|
||||
expect(source).toContain('const SUBJECT_PERMISSION_SECTION_ORDER = ["基础信息", "病史", "访视", "AE", "PD"];');
|
||||
expect(source).toContain('subjects: "基础信息"');
|
||||
expect(source).toContain('visits: "访视"');
|
||||
expect(source).toContain('subject_aes: "AE"');
|
||||
expect(source).toContain('subject_pds: "PD"');
|
||||
expect(source).toContain('subject_histories: "病史"');
|
||||
expect(source).not.toContain('subjects: "参与者基础信息"');
|
||||
});
|
||||
|
||||
it("shows startup ethics permissions in role editor with business section names", () => {
|
||||
const source = readSource();
|
||||
const moduleSource = readProjectPermissionModulesSource();
|
||||
|
||||
expect(moduleSource).toContain('startup_ethics: "立项与伦理"');
|
||||
expect(source).toContain('startup_initiation: "立项记录"');
|
||||
expect(source).toContain('startup_ethics: "伦理记录"');
|
||||
});
|
||||
|
||||
it("does not expose the legacy dashboard module after merging into project overview", () => {
|
||||
const moduleSource = readProjectPermissionModulesSource();
|
||||
|
||||
expect(moduleSource).not.toContain('dashboard: "仪表板"');
|
||||
});
|
||||
|
||||
it("does not expose generic attachment management after module attachment migration", () => {
|
||||
const moduleSource = readProjectPermissionModulesSource();
|
||||
const source = readSource();
|
||||
|
||||
expect(moduleSource).not.toContain('attachments: "附件管理"');
|
||||
expect(source).not.toContain('attachments: "附件管理"');
|
||||
});
|
||||
|
||||
it("shows precautions under the shared library parent module", () => {
|
||||
const moduleSource = readProjectPermissionModulesSource();
|
||||
const source = readSource();
|
||||
|
||||
expect(moduleSource).toContain('shared_library: "共享库"');
|
||||
expect(moduleSource).not.toContain('precautions: "注意事项"');
|
||||
expect(source).toContain('precautions: "注意事项"');
|
||||
expect(source).toContain('faq: "医学咨询/FAQ"');
|
||||
});
|
||||
|
||||
it("labels contract fees without the removed finance contract prefix in role editor", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).not.toContain("FEE_PERMISSION_SECTION_LABELS");
|
||||
expect(source).not.toContain('fees_payments: "费用分析"');
|
||||
expect(source).not.toContain('fees_attachments: "合同附件"');
|
||||
expect(source).not.toContain("finance_contracts");
|
||||
expect(source).not.toContain('contract_fees: "合同费用"');
|
||||
});
|
||||
|
||||
it("does not render the duplicated monitoring header", () => {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<div class="perm-header">
|
||||
<div class="perm-header-left">
|
||||
<h2 class="perm-title">项目权限配置</h2>
|
||||
<span class="perm-subtitle">为每个项目的角色配置接口级权限</span>
|
||||
</div>
|
||||
<div class="perm-header-actions">
|
||||
<div class="project-selector">
|
||||
@@ -53,10 +52,6 @@
|
||||
<el-icon><Files /></el-icon>
|
||||
角色管理
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存变更
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<PermissionTemplateSelector
|
||||
@@ -69,7 +64,6 @@
|
||||
<ApiEndpointPermissions
|
||||
:project="selectedStudy"
|
||||
:matrix="apiMatrix"
|
||||
@update="onApiMatrixUpdate"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="成员管理" name="members">
|
||||
@@ -238,137 +232,151 @@
|
||||
角色管理抽屉
|
||||
══════════════════════════════════════ -->
|
||||
<el-drawer v-model="templateDrawerVisible" title="角色管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
|
||||
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
|
||||
<div class="drawer-tabs-shell">
|
||||
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
|
||||
|
||||
<!-- 标签页:角色列表 -->
|
||||
<el-tab-pane label="角色列表" name="list">
|
||||
<div class="drawer-toolbar">
|
||||
<div class="filter-spacer" />
|
||||
<el-button type="primary" @click="openCreateTemplate">新增角色</el-button>
|
||||
</div>
|
||||
<el-table :data="templates" v-loading="templatesLoading" stripe style="margin-top: 12px">
|
||||
<el-table-column prop="name" label="角色名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="template-name">{{ roleDisplayName(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="template_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限数" width="80">
|
||||
<template #default="{ row }">{{ countPermissions(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- 标签页:角色列表 -->
|
||||
<el-tab-pane label="角色列表" name="list">
|
||||
<el-table :data="templates" v-loading="templatesLoading" stripe class="template-table">
|
||||
<el-table-column prop="name" label="角色名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="template-name">{{ roleDisplayName(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="template_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限数" width="80">
|
||||
<template #default="{ row }">{{ countPermissions(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 标签页:生效管理 -->
|
||||
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
|
||||
<div class="active-roles-desc">
|
||||
<el-alert
|
||||
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
</div>
|
||||
<div class="active-roles-list" v-loading="activeRolesLoading">
|
||||
<div v-for="role in roleOptions" :key="role.key" class="active-role-row">
|
||||
<div class="active-role-info">
|
||||
<span class="active-role-icon">{{ role.icon }}</span>
|
||||
<div>
|
||||
<div class="active-role-name">{{ role.label }}</div>
|
||||
<div class="active-role-desc">{{ role.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="activeRolesDraft.includes(role.key)"
|
||||
@change="(v: boolean) => toggleActiveRole(role.key, v)"
|
||||
<!-- 标签页:生效管理 -->
|
||||
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
|
||||
<div class="active-roles-desc">
|
||||
<el-alert
|
||||
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-footer">
|
||||
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 标签页:编辑权限 -->
|
||||
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
|
||||
<div class="role-editor-layout" v-loading="roleEditorLoading">
|
||||
<!-- 左侧角色列表 -->
|
||||
<div class="role-editor-sidebar">
|
||||
<div
|
||||
v-for="role in roleOptions"
|
||||
:key="role.key"
|
||||
class="role-sidebar-item"
|
||||
:class="{ active: editingRole === role.key }"
|
||||
@click="selectRoleForEdit(role.key)"
|
||||
>
|
||||
<span class="role-sidebar-icon">{{ role.icon }}</span>
|
||||
<div class="role-sidebar-info">
|
||||
<div class="role-sidebar-name">{{ role.label }}</div>
|
||||
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
|
||||
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
|
||||
<div class="active-roles-list" v-loading="activeRolesLoading">
|
||||
<div v-for="role in roleOptions" :key="role.key" class="active-role-row">
|
||||
<div class="active-role-info">
|
||||
<span class="active-role-icon">{{ role.icon }}</span>
|
||||
<div>
|
||||
<div class="active-role-name">{{ role.label }}</div>
|
||||
<div class="active-role-desc">{{ role.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
|
||||
<el-switch
|
||||
:model-value="activeRolesDraft.includes(role.key)"
|
||||
@change="(v: boolean) => toggleActiveRole(role.key, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 右侧权限编辑区 -->
|
||||
<div class="role-editor-content">
|
||||
<template v-if="editingRole">
|
||||
<div v-if="!canEditSelectedRole" class="role-editor-notice">
|
||||
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
|
||||
</div>
|
||||
<div class="role-editor-toolbar">
|
||||
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable style="width: 220px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<div class="filter-spacer" />
|
||||
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(true)">全选</el-button>
|
||||
<el-button size="small" :disabled="!canEditSelectedRole" @click="roleEditorSelectAll(false)">全不选</el-button>
|
||||
</div>
|
||||
<div class="role-editor-modules">
|
||||
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
|
||||
<div class="role-editor-module-header">
|
||||
<span>{{ roleEditorModuleLabel(mod) }}</span>
|
||||
<el-tag size="small" type="info">{{ ops.length }}</el-tag>
|
||||
</div>
|
||||
<div class="role-editor-ops">
|
||||
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
|
||||
<el-checkbox
|
||||
:model-value="roleEditorDraft[op.operation_key] ?? false"
|
||||
:disabled="!canEditSelectedRole"
|
||||
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
|
||||
/>
|
||||
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
|
||||
<span class="op-desc">{{ op.description || op.operation_key }}</span>
|
||||
</div>
|
||||
<!-- 标签页:编辑权限 -->
|
||||
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
|
||||
<div class="role-editor-layout" v-loading="roleEditorLoading">
|
||||
<!-- 左侧角色列表 -->
|
||||
<div class="role-editor-sidebar">
|
||||
<div
|
||||
v-for="role in roleOptions"
|
||||
:key="role.key"
|
||||
class="role-sidebar-item"
|
||||
:class="{ active: editingRole === role.key }"
|
||||
@click="selectRoleForEdit(role.key)"
|
||||
>
|
||||
<span class="role-sidebar-icon">{{ role.icon }}</span>
|
||||
<div class="role-sidebar-info">
|
||||
<div class="role-sidebar-name">{{ role.label }}</div>
|
||||
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
|
||||
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
|
||||
</div>
|
||||
</div>
|
||||
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div class="drawer-footer">
|
||||
<el-button type="primary" :loading="roleEditorSaving" :disabled="!canEditSelectedRole" @click="saveRoleEditor">
|
||||
保存 {{ roleLabel(editingRole) }} 权限
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</div>
|
||||
|
||||
</el-tabs>
|
||||
<!-- 右侧权限编辑区 -->
|
||||
<div class="role-editor-content">
|
||||
<template v-if="editingRole">
|
||||
<div v-if="!canEditSelectedRole" class="role-editor-notice">
|
||||
<el-alert :title="editingRole === 'PM' ? '仅系统管理员可修改项目负责人权限' : '该角色权限不可修改'" type="info" :closable="false" show-icon />
|
||||
</div>
|
||||
<div class="role-editor-toolbar">
|
||||
<el-input v-model="roleEditorSearch" placeholder="搜索权限..." clearable class="role-editor-search">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="role-editor-modules">
|
||||
<div v-for="(sections, mod) in roleEditorSectionGroups" :key="mod" class="role-editor-module">
|
||||
<div class="role-editor-module-header">
|
||||
<span>{{ roleEditorModuleLabel(mod) }}</span>
|
||||
</div>
|
||||
<el-collapse class="role-editor-collapse">
|
||||
<el-collapse-item
|
||||
v-for="section in sections"
|
||||
:key="`${mod}:${section.label}`"
|
||||
:name="`${mod}:${section.label}`"
|
||||
class="role-editor-section"
|
||||
>
|
||||
<template #title>
|
||||
<span class="role-editor-section-title">{{ section.label }}</span>
|
||||
<span class="role-editor-section-summary">{{ roleEditorEnabledCount(section.ops) }} / {{ section.ops.length }} 已启用</span>
|
||||
</template>
|
||||
<div class="role-editor-ops">
|
||||
<div v-for="op in section.ops" :key="op.operation_key" class="role-editor-op-row">
|
||||
<el-checkbox
|
||||
:model-value="roleEditorDraft[op.operation_key] ?? false"
|
||||
:disabled="!canEditSelectedRole"
|
||||
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
|
||||
/>
|
||||
<el-tag :type="opTagType(op)" size="small" class="op-action-tag" effect="light">{{ opActionLabel(op) }}</el-tag>
|
||||
<span class="op-desc">{{ op.description || op.operation_key }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
<div class="drawer-tab-actions">
|
||||
<el-button v-if="templateDrawerTab === 'list'" type="primary" @click="openCreateTemplate">新增角色</el-button>
|
||||
<el-button v-else-if="templateDrawerTab === 'active'" type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="templateDrawerTab === 'edit-role' && editingRole"
|
||||
type="primary"
|
||||
:loading="roleEditorSaving"
|
||||
:disabled="!canEditSelectedRole"
|
||||
@click="saveRoleEditor"
|
||||
>
|
||||
保存 {{ roleLabel(editingRole) }} 权限
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 新增/编辑角色对话框 -->
|
||||
@@ -427,7 +435,7 @@
|
||||
import { ref, computed, onMounted, watch, reactive } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Check, Files, Search, ArrowRight } from "@element-plus/icons-vue";
|
||||
import { Files, Search, ArrowRight } from "@element-plus/icons-vue";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import type {
|
||||
ApiEndpointPermissionsResponse,
|
||||
@@ -456,6 +464,10 @@ import { useAuthStore } from "@/store/auth";
|
||||
import { displayDateTime } from "@/utils/display";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
import {
|
||||
compareProjectPermissionModules,
|
||||
projectPermissionModuleLabel,
|
||||
} from "@/utils/projectPermissionModules";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
@@ -469,9 +481,9 @@ const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study ||
|
||||
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
|
||||
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
|
||||
|
||||
const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const ROLE_RANK: Record<string, number> = {
|
||||
ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
|
||||
ADMIN: 100, PM: 80, QA: 60, PV: 50, CRA: 40, CTA: 40,
|
||||
};
|
||||
type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };
|
||||
const canAssignProjectRole = (role: string) => isAdmin.value || (ROLE_RANK[role] ?? 0) < ROLE_RANK.PM;
|
||||
@@ -516,8 +528,6 @@ const projectSubTab = ref<"api" | "members">("api");
|
||||
|
||||
// 接口级权限
|
||||
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const currentPermissionsForTemplate = computed(() => {
|
||||
if (!apiMatrix.value) return undefined;
|
||||
@@ -545,7 +555,6 @@ const loadPermissionData = async () => {
|
||||
try {
|
||||
const res = await fetchApiEndpointPermissions(selectedStudyId.value);
|
||||
apiMatrix.value = res.data;
|
||||
dirty.value = false;
|
||||
} catch {
|
||||
ElMessage.error("加载权限数据失败");
|
||||
} finally {
|
||||
@@ -561,7 +570,6 @@ const onStudyChange = async (id: string) => {
|
||||
return;
|
||||
}
|
||||
apiMatrix.value = null;
|
||||
dirty.value = false;
|
||||
members.value = [];
|
||||
candidates.value = [];
|
||||
activeRolesDraft.value = [];
|
||||
@@ -570,8 +578,6 @@ const onStudyChange = async (id: string) => {
|
||||
loadCandidates();
|
||||
};
|
||||
|
||||
const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.value = m; dirty.value = true; };
|
||||
|
||||
const activeRolesInStudy = computed(() => activeRolesDraft.value);
|
||||
|
||||
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
|
||||
@@ -598,24 +604,6 @@ const flattenRolePermissions = (
|
||||
[role]: { ...permissions },
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await updateApiEndpointPermissions(
|
||||
selectedStudyId.value,
|
||||
flattenMatrix(apiMatrix.value, { includePm: isAdmin.value })
|
||||
);
|
||||
apiMatrix.value = res.data;
|
||||
dirty.value = false;
|
||||
ElMessage.success("权限已保存");
|
||||
} catch {
|
||||
ElMessage.error("保存权限失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 成员管理
|
||||
const members = ref<StudyMember[]>([]);
|
||||
const candidates = ref<UserInfo[]>([]);
|
||||
@@ -766,7 +754,7 @@ const roleOptions = computed(() => {
|
||||
known.add(role);
|
||||
}
|
||||
}
|
||||
options.sort((a, b) => (ROLE_RANK[b.key] ?? 0) - (ROLE_RANK[a.key] ?? 0) || a.key.localeCompare(b.key));
|
||||
options.sort((a, b) => compareRolesByTemplateOrder(a.key, b.key));
|
||||
return options;
|
||||
});
|
||||
|
||||
@@ -961,6 +949,11 @@ interface RoleEditorOp {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RoleEditorSection {
|
||||
label: string;
|
||||
ops: RoleEditorOp[];
|
||||
}
|
||||
|
||||
const editingRole = ref("");
|
||||
const roleEditorSearch = ref("");
|
||||
const roleEditorDraft = ref<Record<string, boolean>>({});
|
||||
@@ -968,17 +961,43 @@ const roleEditorSaving = ref(false);
|
||||
const roleEditorLoading = ref(false);
|
||||
const allOperations = ref<RoleEditorOp[]>([]);
|
||||
|
||||
const ROLE_EDITOR_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 roleEditorModuleLabel = projectPermissionModuleLabel;
|
||||
|
||||
const SUBJECT_PERMISSION_SECTION_ORDER = ["基础信息", "病史", "访视", "AE", "PD"];
|
||||
|
||||
const PERMISSION_SECTION_LABELS: Record<string, string> = {
|
||||
subjects: "基础信息",
|
||||
visits: "访视",
|
||||
subject_aes: "AE",
|
||||
subject_pds: "PD",
|
||||
subject_histories: "病史",
|
||||
monitoring_issues: "监查访视问题",
|
||||
project_milestones: "项目里程碑",
|
||||
startup_initiation: "立项记录",
|
||||
startup_ethics: "伦理记录",
|
||||
precautions: "注意事项",
|
||||
precautions_attachments: "注意事项",
|
||||
faq: "医学咨询/FAQ",
|
||||
faq_category: "医学咨询/FAQ",
|
||||
faq_reply: "医学咨询/FAQ",
|
||||
faq_attachments: "医学咨询/FAQ",
|
||||
};
|
||||
|
||||
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
|
||||
const getPermissionSectionLabel = (op: RoleEditorOp): string => {
|
||||
const prefix = op.operation_key.split(":")[0] || op.module;
|
||||
return PERMISSION_SECTION_LABELS[prefix] || roleEditorModuleLabel(op.module);
|
||||
};
|
||||
|
||||
const roleEditorSectionLabel = (op: RoleEditorOp): string => getPermissionSectionLabel(op);
|
||||
|
||||
const comparePermissionSectionLabels = (a: string, b: string): number => {
|
||||
const aIndex = SUBJECT_PERMISSION_SECTION_ORDER.indexOf(a);
|
||||
const bIndex = SUBJECT_PERMISSION_SECTION_ORDER.indexOf(b);
|
||||
if (aIndex !== -1 || bIndex !== -1) {
|
||||
return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex);
|
||||
}
|
||||
return a.localeCompare(b, "zh-CN");
|
||||
};
|
||||
|
||||
const getOpVerb = (op: { operation_key: string; action: string }): string => {
|
||||
const suffix = op.operation_key.split(":").pop() || "";
|
||||
@@ -1008,14 +1027,44 @@ const roleEditorGrouped = computed(() => {
|
||||
op.operation_key.toLowerCase().includes(search) ||
|
||||
roleEditorModuleLabel(op.module).includes(search)
|
||||
);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const moduleDiff = compareProjectPermissionModules(a.module, b.module);
|
||||
if (moduleDiff !== 0) return moduleDiff;
|
||||
return allOperations.value.indexOf(a) - allOperations.value.indexOf(b);
|
||||
});
|
||||
const map: Record<string, RoleEditorOp[]> = {};
|
||||
for (const op of filtered) {
|
||||
for (const op of sorted) {
|
||||
if (!map[op.module]) map[op.module] = [];
|
||||
map[op.module].push(op);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const roleEditorSectionGroups = computed(() => {
|
||||
const result: Record<string, RoleEditorSection[]> = {};
|
||||
for (const [mod, ops] of Object.entries(roleEditorGrouped.value)) {
|
||||
const sections = new Map<string, RoleEditorOp[]>();
|
||||
for (const op of ops) {
|
||||
const label = roleEditorSectionLabel(op);
|
||||
if (!sections.has(label)) sections.set(label, []);
|
||||
sections.get(label)!.push(op);
|
||||
}
|
||||
result[mod] = [...sections.entries()]
|
||||
.sort(([a], [b]) => comparePermissionSectionLabels(a, b))
|
||||
.map(([label, sectionOps]) => ({
|
||||
label,
|
||||
ops: sectionOps,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const roleEditorModuleCount = (sections: RoleEditorSection[]): number =>
|
||||
sections.reduce((total, section) => total + section.ops.length, 0);
|
||||
|
||||
const roleEditorEnabledCount = (ops: RoleEditorOp[]): number =>
|
||||
ops.filter((op) => roleEditorDraft.value[op.operation_key]).length;
|
||||
|
||||
const canEditSelectedRole = computed(() => {
|
||||
if (editingRole.value === "ADMIN") return false;
|
||||
if (isAdmin.value) return true;
|
||||
@@ -1060,11 +1109,6 @@ const initRoleEditor = () => {
|
||||
roleEditorDraft.value = draft;
|
||||
};
|
||||
|
||||
const roleEditorSelectAll = (val: boolean) => {
|
||||
const ops = Object.values(roleEditorGrouped.value).flat();
|
||||
for (const op of ops) roleEditorDraft.value[op.operation_key] = val;
|
||||
};
|
||||
|
||||
const saveRoleEditor = async () => {
|
||||
if (!selectedStudyId.value || !apiMatrix.value) return;
|
||||
if (!canEditSelectedRole.value) {
|
||||
@@ -1076,7 +1120,6 @@ const saveRoleEditor = async () => {
|
||||
const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);
|
||||
const res = await updateApiEndpointPermissions(selectedStudyId.value, payload);
|
||||
apiMatrix.value = res.data;
|
||||
dirty.value = false;
|
||||
const savedRole = editingRole.value;
|
||||
editingRole.value = "";
|
||||
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
|
||||
@@ -1150,11 +1193,6 @@ onMounted(async () => {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.perm-subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.perm-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1515,28 +1553,27 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* ── 抽屉内部 ── */
|
||||
.filter-spacer { flex: 1; }
|
||||
|
||||
.drawer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f1f5f9;
|
||||
.drawer-tabs-shell {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 110px);
|
||||
}
|
||||
|
||||
.drawer-tabs { padding-top: 4px; }
|
||||
|
||||
.drawer-footer {
|
||||
.drawer-tab-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
min-width: 160px;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-table {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── 生效角色列表 ── */
|
||||
@@ -1670,60 +1707,195 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.role-editor-modules { flex: 1; }
|
||||
.role-editor-modules {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f1f5f9;
|
||||
margin-bottom: 18px;
|
||||
padding: 4px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.role-editor-module { margin-bottom: 20px; }
|
||||
.role-editor-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.role-editor-search .el-input__wrapper) {
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
padding: 4px 12px;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.role-editor-search .el-input__wrapper:hover) {
|
||||
background: #f8fafc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.role-editor-search .el-input__wrapper.is-focus) {
|
||||
background: #eff6ff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.role-editor-module {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.role-editor-module:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.role-editor-module-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a2332;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
padding: 14px 18px 14px 22px;
|
||||
background: linear-gradient(135deg, #f1f5ff 0%, #f8fafc 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.role-editor-module-header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: linear-gradient(180deg, #3b82f6 0%, #60a5fa 100%);
|
||||
}
|
||||
|
||||
.role-editor-ops {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
padding: 8px 6px 6px;
|
||||
}
|
||||
|
||||
.role-editor-collapse {
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.role-editor-section {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.role-editor-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-editor-section-title::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-editor-section-summary {
|
||||
margin-left: auto;
|
||||
margin-right: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__header) {
|
||||
height: 46px;
|
||||
padding: 0 18px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section:last-child .el-collapse-item__header) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__header:hover) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__header.is-active) {
|
||||
background: #f8fafc;
|
||||
border-bottom-color: #e2e8f0;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__header.is-active .role-editor-section-title::before) {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__arrow) {
|
||||
margin-right: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__content) {
|
||||
padding: 4px 8px 10px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
:deep(.role-editor-section .el-collapse-item__wrap) {
|
||||
border-bottom: 0;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.role-editor-op-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s ease;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.role-editor-op-row:hover {
|
||||
background: #f8fafc;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.op-action-tag {
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.op-desc {
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,11 +47,12 @@ describe("ProjectMembers user directory access", () => {
|
||||
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
|
||||
});
|
||||
|
||||
it("does not offer QA as a project member role preset", () => {
|
||||
it("offers QA and CTA as project member role presets", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).not.toContain('value="QA"');
|
||||
expect(source).not.toContain("canAssignRole('QA')");
|
||||
expect(source).not.toContain("QA: 60");
|
||||
expect(source).toContain('"QA"');
|
||||
expect(source).toContain('"CTA"');
|
||||
expect(source).toContain("QA: 60");
|
||||
expect(source).toContain("CTA: 40");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,15 +132,15 @@ const newMember = reactive({
|
||||
});
|
||||
const canManageMembers = computed(() => permission.can("project.members.manage"));
|
||||
const projectRole = computed(() => project.value?.role_in_study || "");
|
||||
const ROLE_KEYS = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "ADMIN"];
|
||||
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
|
||||
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
|
||||
const roleRank: Record<string, number> = {
|
||||
ADMIN: 100,
|
||||
PM: 80,
|
||||
PV: 50,
|
||||
MEDICAL_REVIEW: 50,
|
||||
QA: 60,
|
||||
CRA: 40,
|
||||
IMP: 40,
|
||||
CTA: 40,
|
||||
};
|
||||
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
|
||||
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
|
||||
|
||||
Reference in New Issue
Block a user