统一项目角色展示与预设文案
This commit is contained in:
@@ -7,16 +7,26 @@ const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement
|
||||
describe("permission management custom roles", () => {
|
||||
it("uses role management wording in the drawer", () => {
|
||||
const source = readSource();
|
||||
const listTabStart = source.indexOf("<!-- 标签页:角色列表 -->");
|
||||
const listTabEnd = source.indexOf("<!-- 标签页:生效管理 -->", listTabStart);
|
||||
const listTabSource = source.slice(listTabStart, listTabEnd);
|
||||
|
||||
expect(source).toContain('title="角色管理"');
|
||||
expect(source).toContain('label="角色列表"');
|
||||
expect(source).toContain('placeholder="角色类型"');
|
||||
expect(listTabSource).not.toContain('placeholder="角色类型"');
|
||||
expect(listTabSource).not.toContain("typeFilter");
|
||||
expect(listTabSource).not.toContain("loadTemplates");
|
||||
expect(listTabSource).not.toContain('<el-option label="预设角色" value="ROLE" />');
|
||||
expect(listTabSource).not.toContain('<el-option label="自定义角色" value="CUSTOM" />');
|
||||
expect(listTabSource).not.toContain('<el-option label="场景角色" value="SCENARIO" />');
|
||||
expect(source).toContain("新增角色");
|
||||
expect(source).toContain('label="角色名称"');
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
expect(source).not.toContain('title="权限模板管理"');
|
||||
expect(source).not.toContain('label="模板列表"');
|
||||
expect(source).not.toContain("新增模板");
|
||||
expect(source).not.toContain("const typeFilter");
|
||||
expect(source).toContain("const res = await fetchPermissionTemplates();");
|
||||
});
|
||||
|
||||
it("keeps active roles configurable for permissions and members without inline role creation", () => {
|
||||
@@ -27,11 +37,15 @@ describe("permission management custom roles", () => {
|
||||
expect(source).not.toContain("custom-role-entry");
|
||||
expect(source).not.toContain("输入自定义角色标识");
|
||||
expect(source).toContain("roleOptions");
|
||||
expect(source).toContain("ROLE_LABELS[role] || role");
|
||||
expect(source).toContain("row.category ? roleLabel(row.category) : row.name");
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("await loadRoleTemplates();");
|
||||
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""');
|
||||
expect(source).toContain('role === "ADMIN"');
|
||||
expect(source).not.toContain("const ALL_ROLES = [");
|
||||
expect(source).not.toContain("const ROLE_LABELS");
|
||||
});
|
||||
|
||||
it("counts role list permissions from current project permissions when available", () => {
|
||||
@@ -42,11 +56,13 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain("if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);");
|
||||
});
|
||||
|
||||
it("uses canonical role labels when opening preset roles for editing", () => {
|
||||
it("uses database role names when listing and editing preset roles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const roleLabel = (role: string) => ROLE_LABELS[role] || role;");
|
||||
expect(source).toContain("const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;");
|
||||
expect(source).toContain("const { roleLabel, roleDescription, roleIcon, 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)");
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
});
|
||||
|
||||
@@ -55,8 +71,14 @@ describe("permission management custom roles", () => {
|
||||
const dialogStart = source.indexOf("<!-- 新增/编辑角色对话框 -->");
|
||||
const dialogEnd = source.indexOf("<!-- 新增成员对话框 -->", dialogStart);
|
||||
const dialogSource = source.slice(dialogStart, dialogEnd);
|
||||
const typeIndex = dialogSource.indexOf('label="角色类型"');
|
||||
const nameIndex = dialogSource.indexOf('label="角色名称"');
|
||||
const descriptionIndex = dialogSource.indexOf('label="描述"');
|
||||
|
||||
expect(dialogSource).toContain(':disabled="Boolean(editingTemplateId)"');
|
||||
expect(typeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(nameIndex).toBeGreaterThan(typeIndex);
|
||||
expect(descriptionIndex).toBeGreaterThan(nameIndex);
|
||||
expect(source).toContain("const openCreateTemplate = () =>");
|
||||
expect(source).toContain("const openEditTemplate = (row: PermissionTemplate) =>");
|
||||
expect(source).toContain('template_type: "CUSTOM"');
|
||||
@@ -77,6 +99,9 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain('v-for="(label, val) in memberRoleLabels(row)"');
|
||||
expect(source).toContain("for (const role of activeRolesInStudy.value)");
|
||||
expect(source).toContain("result[role] = roleLabel(role);");
|
||||
expect(source).toContain("{{ role.label }}");
|
||||
expect(source).toContain("{{ role.desc }}");
|
||||
expect(source).toContain("保存 {{ roleLabel(editingRole) }} 权限");
|
||||
expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
|
||||
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
|
||||
});
|
||||
@@ -84,11 +109,7 @@ describe("permission management custom roles", () => {
|
||||
it("does not offer QA as a preset project permission role", () => {
|
||||
const source = readSource();
|
||||
|
||||
const allRolesStart = source.indexOf("const ALL_ROLES = [");
|
||||
const allRolesEnd = source.indexOf("];", allRolesStart);
|
||||
const allRolesSource = source.slice(allRolesStart, allRolesEnd);
|
||||
|
||||
expect(allRolesSource).not.toContain('key: "QA"');
|
||||
expect(source).not.toContain('key: "QA"');
|
||||
expect(source).not.toContain('QA: "QA"');
|
||||
expect(source).not.toContain("QA: 60");
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
:study-id="selectedStudyId"
|
||||
:current-permissions="currentPermissionsForTemplate"
|
||||
:active-roles="activeRolesInStudy"
|
||||
:refresh-key="templateRefreshKey"
|
||||
@edit-role="openRoleEditor"
|
||||
/>
|
||||
<ApiEndpointPermissions
|
||||
@@ -242,11 +243,6 @@
|
||||
<!-- 标签页:角色列表 -->
|
||||
<el-tab-pane label="角色列表" name="list">
|
||||
<div class="drawer-toolbar">
|
||||
<el-select v-model="typeFilter" placeholder="角色类型" clearable style="width: 140px" @change="loadTemplates">
|
||||
<el-option label="预设角色" value="ROLE" />
|
||||
<el-option label="场景角色" value="SCENARIO" />
|
||||
<el-option label="自定义角色" value="CUSTOM" />
|
||||
</el-select>
|
||||
<div class="filter-spacer" />
|
||||
<el-button type="primary" @click="openCreateTemplate">新增角色</el-button>
|
||||
</div>
|
||||
@@ -378,18 +374,18 @@
|
||||
<!-- 新增/编辑角色对话框 -->
|
||||
<el-dialog v-model="templateDialogVisible" :title="editingTemplateId ? '编辑角色' : '新增角色'" width="520px" @close="resetTemplateForm">
|
||||
<el-form :model="templateForm" :rules="templateRules" ref="templateFormRef" label-width="90px">
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input v-model="templateForm.name" placeholder="请输入角色名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="可选描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色类型" prop="template_type">
|
||||
<el-select v-model="templateForm.template_type" style="width: 100%" :disabled="Boolean(editingTemplateId)">
|
||||
<el-option label="预设角色" value="ROLE" :disabled="!editingTemplateId" />
|
||||
<el-option label="自定义角色" value="CUSTOM" :disabled="Boolean(editingTemplateId)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input v-model="templateForm.name" placeholder="请输入角色名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="可选描述" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||||
@@ -460,6 +456,7 @@ import { useAuthStore } from "@/store/auth";
|
||||
import { displayDateTime } from "@/utils/display";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||||
@@ -472,19 +469,7 @@ const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study ||
|
||||
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
|
||||
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
PM: "项目负责人", CRA: "CRA", PV: "PV",
|
||||
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员",
|
||||
};
|
||||
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
|
||||
|
||||
const activeRoleLabels = computed(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const role of activeRolesInStudy.value) {
|
||||
result[role] = roleLabel(role);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const { roleLabel, roleDescription, roleIcon, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const ROLE_RANK: Record<string, number> = {
|
||||
ADMIN: 100, PM: 80, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
|
||||
};
|
||||
@@ -757,33 +742,31 @@ const templateDrawerVisible = ref(false);
|
||||
const templateDrawerTab = ref<"list" | "active" | "edit-role">("list");
|
||||
const templates = ref<PermissionTemplate[]>([]);
|
||||
const templatesLoading = ref(false);
|
||||
const typeFilter = ref<string | undefined>(undefined);
|
||||
const templateRefreshKey = ref(0);
|
||||
const templateDialogVisible = ref(false);
|
||||
const editingTemplateId = ref<string | null>(null);
|
||||
const templateFormRef = ref<FormInstance>();
|
||||
const templateSaving = ref(false);
|
||||
|
||||
const ALL_ROLES = [
|
||||
{ key: "PM", label: "项目负责人", icon: "👑", desc: "项目管理员,拥有所有权限" },
|
||||
{ key: "CRA", label: "CRA", icon: "📋", desc: "数据输入和日常管理人员" },
|
||||
{ key: "PV", label: "PV", icon: "🔍", desc: "访视和参与者管理人员" },
|
||||
{ key: "MEDICAL_REVIEW", label: "医学审核", icon: "🏥", desc: "医学审核人员" },
|
||||
{ key: "IMP", label: "药品管理员", icon: "📦", desc: "物资和设备管理人员" },
|
||||
];
|
||||
|
||||
const activeRolesDraft = ref<string[]>([]);
|
||||
const activeRolesLoading = ref(false);
|
||||
const activeRolesSaving = ref(false);
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const options = [...ALL_ROLES];
|
||||
const options = templates.value.filter((template) => template.category).map((template) => ({
|
||||
key: template.category!,
|
||||
label: template.name,
|
||||
icon: roleIcon(template.category),
|
||||
desc: template.description || roleDescription(template.category),
|
||||
}));
|
||||
const known = new Set(options.map((role) => role.key));
|
||||
for (const role of activeRolesDraft.value) {
|
||||
if (!known.has(role)) {
|
||||
options.push({ key: role, label: role, icon: "🔧", desc: "自定义项目角色" });
|
||||
options.push({ key: role, label: roleLabel(role), icon: roleIcon(role), desc: roleDescription(role) });
|
||||
known.add(role);
|
||||
}
|
||||
}
|
||||
options.sort((a, b) => (ROLE_RANK[b.key] ?? 0) - (ROLE_RANK[a.key] ?? 0) || a.key.localeCompare(b.key));
|
||||
return options;
|
||||
});
|
||||
|
||||
@@ -840,8 +823,7 @@ const templateRules: FormRules = {
|
||||
const typeLabel = (t: string) => ({ ROLE: "预设角色", SCENARIO: "场景角色", CUSTOM: "自定义角色" }[t] ?? t);
|
||||
const typeTagType = (t: string) =>
|
||||
({ ROLE: "primary", SCENARIO: "warning", CUSTOM: "success" } as Record<string, any>)[t] ?? "info";
|
||||
const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;
|
||||
const roleDisplayName = (row: PermissionTemplate) => templateRoleName(row);
|
||||
const roleDisplayName = (row: PermissionTemplate) => row.name;
|
||||
const countEnabledPermissions = (permissions: Record<string, boolean>) =>
|
||||
Object.values(permissions).filter(Boolean).length;
|
||||
const countPermissions = (row: PermissionTemplate) => {
|
||||
@@ -858,8 +840,10 @@ const countPermissions = (row: PermissionTemplate) => {
|
||||
const loadTemplates = async () => {
|
||||
templatesLoading.value = true;
|
||||
try {
|
||||
const res = await fetchPermissionTemplates(typeFilter.value ? { template_type: typeFilter.value } : undefined);
|
||||
const res = await fetchPermissionTemplates();
|
||||
templates.value = res.data;
|
||||
await loadRoleTemplates();
|
||||
templateRefreshKey.value += 1;
|
||||
} catch {
|
||||
ElMessage.error("加载角色失败");
|
||||
} finally {
|
||||
@@ -876,7 +860,7 @@ const openCreateTemplate = () => {
|
||||
const openEditTemplate = (row: PermissionTemplate) => {
|
||||
editingTemplateId.value = row.id;
|
||||
templateForm.value = {
|
||||
name: templateRoleName(row), description: row.description ?? "",
|
||||
name: row.name, description: row.description ?? "",
|
||||
template_type: row.template_type, category: row.category ?? "",
|
||||
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
|
||||
};
|
||||
|
||||
@@ -32,10 +32,21 @@ describe("ProjectMembers user directory access", () => {
|
||||
expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
|
||||
expect(source).toContain("if (row.user?.is_admin) return false;");
|
||||
expect(source).toContain("const canAssignRole = (role: string)");
|
||||
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"');
|
||||
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
|
||||
expect(source).toContain(':disabled="!canEditMember(scope.row)');
|
||||
});
|
||||
|
||||
it("uses permission template names for project role display options", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
expect(source).toContain("useRoleTemplateMeta");
|
||||
expect(source).toContain("const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();");
|
||||
expect(source).toContain("const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));");
|
||||
expect(source).toContain("{{ roleLabel(scope.row.role_in_study) }}");
|
||||
expect(source).not.toContain("displayEnum(TEXT.enums.userRole, scope.row.role_in_study)");
|
||||
expect(source).not.toContain(':label="TEXT.enums.userRole.PM"');
|
||||
});
|
||||
|
||||
it("does not offer QA as a project member role preset", () => {
|
||||
const source = readProjectMembers();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
|
||||
<el-tag>{{ roleLabel(scope.row.role_in_study) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
@@ -41,12 +41,13 @@
|
||||
@change="(val: string) => updateRole(scope.row.id, val)"
|
||||
:disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
>
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" />
|
||||
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
:disabled="!canAssignRole(role.value)"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
|
||||
<el-button link type="danger" size="small" :disabled="!canEditMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
@@ -80,12 +81,13 @@
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
|
||||
<el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
|
||||
<el-option :label="TEXT.enums.userRole.PM" value="PM" :disabled="!canAssignRole('PM')" />
|
||||
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" :disabled="!canAssignRole('CRA')" />
|
||||
<el-option :label="TEXT.enums.userRole.PV" value="PV" :disabled="!canAssignRole('PV')" />
|
||||
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" :disabled="!canAssignRole('MEDICAL_REVIEW')" />
|
||||
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" :disabled="!canAssignRole('IMP')" />
|
||||
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
:disabled="!canAssignRole(role.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -107,13 +109,15 @@ import type { Study, StudyMember, UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { evaluateAction } from "../../guards/actionGuard";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import { displayDateTime, displayEnum } from "../../utils/display";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
||||
|
||||
const route = useRoute();
|
||||
const projectId = computed(() => route.params.projectId as string);
|
||||
const auth = useAuthStore();
|
||||
const permission = usePermission();
|
||||
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
|
||||
const project = ref<Study | null>(null);
|
||||
const members = ref<StudyMember[]>([]);
|
||||
@@ -128,6 +132,8 @@ 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 roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
|
||||
const roleRank: Record<string, number> = {
|
||||
ADMIN: 100,
|
||||
PM: 80,
|
||||
@@ -343,7 +349,7 @@ const availableUsers = computed(() => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadProject(), loadMembers()]);
|
||||
await Promise.all([loadRoleTemplates(), loadProject(), loadMembers()]);
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user