完善项目级权限与项目管理入口

- 新增项目成员候选用户接口,避免 PM 读取全局用户列表
- 按项目角色和权限矩阵控制项目管理、成员、中心与审计入口
- 合并侧边栏管理后台入口,统一从项目管理进入成员等模块
- 加强成员管理安全约束,禁止 PM 修改自己、系统管理员或更高权限角色
- 修复多项目角色缓存串用问题,并补充前后端回归测试
This commit is contained in:
Cheng Zhou
2026-05-13 14:16:15 +08:00
parent ab1975d3c6
commit a17f4cc522
21 changed files with 452 additions and 134 deletions
+77 -27
View File
@@ -3,7 +3,7 @@
<div class="main-content-flat unified-shell">
<div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div>
<el-button type="primary" @click="openAdd">
<el-button v-if="canManageMembers" type="primary" @click="openAdd">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button>
</div>
@@ -39,23 +39,23 @@
size="small"
style="width: 120px"
@change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
:disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option :label="TEXT.enums.userRole.PM" value="PM" />
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" />
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
<el-option :label="TEXT.enums.userRole.QA" value="QA" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
<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.QA" value="QA" :disabled="!canAssignRole('QA')" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
</el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="danger" size="small" :disabled="!canEditMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
:disabled="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
:disabled="!canEditMember(scope.row) || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleActive(scope.row)"
>
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
@@ -81,13 +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" />
<el-option :label="TEXT.enums.userRole.CRA" value="CRA" />
<el-option :label="TEXT.enums.userRole.PV" value="PV" />
<el-option :label="TEXT.enums.userRole.MEDICAL_REVIEW" value="MEDICAL_REVIEW" />
<el-option :label="TEXT.enums.userRole.IMP" value="IMP" />
<el-option :label="TEXT.enums.userRole.QA" value="QA" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" />
<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.QA" value="QA" :disabled="!canAssignRole('QA')" />
<el-option :label="TEXT.enums.userRole.ADMIN" value="ADMIN" :disabled="!canAssignRole('ADMIN')" />
</el-select>
</el-form-item>
</el-form>
@@ -103,18 +103,19 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { fetchUsers } from "../../api/users";
import { addMember, listMembers, removeMember, updateMember } from "../../api/members";
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "../../api/members";
import { fetchStudyDetail } from "../../api/studies";
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 { TEXT, requiredMessage } from "../../locales";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const permission = usePermission();
const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]);
@@ -127,6 +128,26 @@ const newMember = reactive({
user_id: "",
role_in_study: "PM",
});
const canManageMembers = computed(() => permission.can("project.members.manage"));
const projectRole = computed(() => project.value?.role_in_study || "");
const roleRank: Record<string, number> = {
ADMIN: 100,
PM: 80,
QA: 60,
PV: 50,
MEDICAL_REVIEW: 50,
CRA: 40,
IMP: 40,
};
const currentRoleRank = computed(() => auth.user?.role === "ADMIN" ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.role === "ADMIN" ? true : (roleRank[role] || 0) <= currentRoleRank.value);
const canEditMember = (row: StudyMember) => {
if (!canManageMembers.value) return false;
if (auth.user?.role === "ADMIN") return true;
if (row.user_id === auth.user?.id) return false;
if (row.user?.role === "ADMIN") return false;
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
};
const addRules = reactive<FormRules>({
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
@@ -149,6 +170,11 @@ const loadMembers = async () => {
try {
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
members.value = Array.isArray(data) ? data : data.items || [];
if (!canManageMembers.value) {
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
} finally {
@@ -157,9 +183,13 @@ const loadMembers = async () => {
};
const loadUsers = async () => {
if (!canManageMembers.value || !projectId.value) {
users.value = [];
return;
}
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || [];
const { data } = await listMemberCandidates(projectId.value, { limit: 500 });
users.value = data || [];
} catch {
users.value = [];
}
@@ -167,7 +197,7 @@ const loadUsers = async () => {
const memberRows = computed(() =>
members.value.map((m) => {
const user = users.value.find((u) => u.id === m.user_id);
const user = users.value.find((u) => u.id === m.user_id) || m.user;
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
return {
...m,
@@ -178,8 +208,9 @@ const memberRows = computed(() =>
);
const openAdd = () => {
if (!canManageMembers.value) return;
newMember.user_id = "";
newMember.role_in_study = "PM";
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
addVisible.value = true;
};
@@ -194,13 +225,17 @@ const submitAdd = async () => {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return;
}
if (!canAssignRole(newMember.role_in_study)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
await addFormRef.value?.validate();
adding.value = true;
try {
await addMember(projectId.value, newMember);
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
addVisible.value = false;
loadMembers();
await Promise.all([loadMembers(), loadUsers()]);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
} finally {
@@ -210,6 +245,12 @@ const submitAdd = async () => {
const updateRole = async (memberId: string, role: string) => {
if (!projectId.value) return;
const row = members.value.find((member) => member.id === memberId);
if (!row || !canEditMember(row) || !canAssignRole(role)) {
ElMessage.warning(TEXT.common.messages.noPermission);
loadMembers();
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
@@ -231,6 +272,10 @@ const updateRole = async (memberId: string, role: string) => {
const toggleActive = async (row: StudyMember) => {
if (!projectId.value) return;
if (!canEditMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
@@ -266,6 +311,10 @@ const toggleActive = async (row: StudyMember) => {
const onDelete = async (row: StudyMember) => {
if (!projectId.value) return;
if (!canEditMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
@@ -284,6 +333,7 @@ const onDelete = async (row: StudyMember) => {
try {
await removeMember(projectId.value, row.id);
members.value = members.value.filter((m) => m.id !== row.id);
await loadUsers();
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
@@ -296,8 +346,8 @@ const availableUsers = computed(() => {
});
onMounted(async () => {
await Promise.all([loadProject(), loadUsers()]);
loadMembers();
await Promise.all([loadProject(), loadMembers()]);
loadUsers();
});
</script>