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

- 新增项目成员候选用户接口,避免 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
+2 -1
View File
@@ -13,7 +13,8 @@ describe("audit logs access", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain('projectRole.value === "PM"');
expect(source).toContain("roles?.PM?.audit_export?.read");
expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
expect(source).not.toContain("roles?.PM?.audit_export?.read");
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
});
+2 -2
View File
@@ -216,11 +216,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.PM?.audit_export?.read;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.PM?.audit_export?.read;
return projectRole.value === "PM" && !!permissionMatrix.value?.roles?.[projectRole.value]?.audit_export?.read;
});
const ensureAccess = () => {
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readProjectMembers = () => readFileSync(resolve(__dirname, "./ProjectMembers.vue"), "utf8");
describe("ProjectMembers user directory access", () => {
it("does not load the admin-only global user directory", () => {
const source = readProjectMembers();
expect(source).not.toContain("fetchUsers");
expect(source).not.toContain("../../api/users");
expect(source).toContain('const canManageMembers = computed(() => permission.can("project.members.manage"));');
expect(source).toContain("if (!canManageMembers.value");
expect(source).toContain("return;");
expect(source).toContain('v-if="canManageMembers"');
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
});
it("uses the member API embedded user data when the global directory is unavailable", () => {
const source = readProjectMembers();
expect(source).toContain("users.value = members.value");
expect(source).toContain(".map((member) => member.user)");
expect(source).toContain("users.value.find((u) => u.id === m.user_id) || m.user");
});
it("prevents project managers from editing themselves or assigning higher roles", () => {
const source = readProjectMembers();
expect(source).toContain("const roleRank: Record<string, number>");
expect(source).toContain("if (row.user_id === auth.user?.id) return false;");
expect(source).toContain('if (row.user?.role === "ADMIN") return false;');
expect(source).toContain("const canAssignRole = (role: string)");
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"');
expect(source).toContain(':disabled="!canEditMember(scope.row)');
});
});
+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>
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8");
const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8");
const readLayout = () => readFileSync(resolve(__dirname, "../../components/Layout.vue"), "utf8");
describe("project management access", () => {
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
const projects = readProjects();
const layout = readLayout();
const router = readRouter();
expect(layout).toContain('v-if="auth.user"');
expect(layout).toContain('index="/admin/projects"');
expect(router).toContain('name: "AdminProjects"');
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
expect(projects).toContain('v-if="isAdmin" type="primary"');
expect(projects).toContain('v-if="isAdmin && !scope.row.is_locked"');
expect(projects).toContain('v-if="isAdmin" :content="TEXT.common.actions.delete"');
});
it("keeps management backend modules under the project management page instead of sidebar shortcuts", () => {
const layout = readLayout();
expect(layout).not.toContain("canAccessPmAdminBackend");
expect(layout).not.toContain("canAccessPmAdminModule");
expect(layout).not.toContain("TEXT.modules.adminProjectMembers.memberLabel");
expect(layout).toContain('index="/admin/projects"');
});
it("uses each project permission matrix to show project management actions", () => {
const source = readProjects();
expect(source).toContain("fetchProjectRolePermissions(item.id)");
expect(source).toContain("permissionsByProject");
expect(source).toContain("const canProject = (row: Study, module: string, action: \"read\" | \"write\")");
expect(source).toContain("canProject(scope.row, 'project_members', 'read')");
expect(source).toContain("canProject(scope.row, 'project_members', 'write')");
expect(source).toContain("canProject(scope.row, 'sites', 'read')");
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
});
});
+55 -12
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="openCreate">
<el-button v-if="isAdmin" type="primary" @click="openCreate">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
</el-button>
</div>
@@ -11,7 +11,7 @@
<el-table :data="projects" v-loading="loading" stripe class="project-table" table-layout="fixed">
<el-table-column prop="name" :label="TEXT.common.fields.projectName" show-overflow-tooltip>
<template #default="scope">
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
<el-link type="primary" @click="goProject(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" show-overflow-tooltip>
@@ -33,19 +33,22 @@
<el-table-column :label="TEXT.common.labels.actions" width="176" align="center">
<template #default="scope">
<div class="action-row">
<el-tooltip :content="TEXT.modules.adminProjects.members" placement="top">
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
<el-button link type="primary" :icon="User" class="action-btn" @click="goMembers(scope.row)" />
</el-tooltip>
<el-tooltip content="权限管理" placement="top">
<el-tooltip v-if="canProject(scope.row, 'project_members', 'write')" content="权限管理" placement="top">
<el-button link type="primary" :icon="Key" class="action-btn" @click="goPermissions(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.sites" placement="top">
<el-tooltip v-if="canProject(scope.row, 'sites', 'read')" :content="TEXT.modules.adminProjects.sites" placement="top">
<el-button link type="primary" :icon="OfficeBuilding" class="action-btn" @click="goSites(scope.row)" />
</el-tooltip>
<el-tooltip v-if="canProject(scope.row, 'audit_export', 'read')" :content="TEXT.menu.auditLogs" placement="top">
<el-button link type="primary" :icon="Document" class="action-btn" @click="goAuditLogs(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.modules.adminProjects.enter" placement="top">
<el-button link type="success" :icon="ArrowRight" class="action-btn enter-btn" @click="enterStudy(scope.row)" />
</el-tooltip>
<el-tooltip v-if="!scope.row.is_locked" content="锁定项目" placement="top">
<el-tooltip v-if="isAdmin && !scope.row.is_locked" content="锁定项目" placement="top">
<el-button
link
type="info"
@@ -54,7 +57,7 @@
@click="handleLockToggle(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.delete" placement="top">
<el-tooltip v-if="isAdmin" :content="TEXT.common.actions.delete" placement="top">
<el-button link type="danger" :icon="Delete" class="action-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</div>
@@ -68,14 +71,17 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key } from "@element-plus/icons-vue";
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document } from "@element-plus/icons-vue";
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
import type { Study } from "../../types/api";
import { fetchProjectRolePermissions } from "../../api/projectPermissions";
import type { ProjectRolePermissionsResponse, Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
const projects = ref<Study[]>([]);
@@ -84,6 +90,9 @@ const formVisible = ref(false);
const editingProject = ref<Study | null>(null);
const router = useRouter();
const studyStore = useStudyStore();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const permissionsByProject = ref<Record<string, ProjectRolePermissionsResponse>>({});
const loadProjects = async () => {
loading.value = true;
@@ -91,6 +100,19 @@ const loadProjects = async () => {
const { data } = await fetchStudies();
const items = (data as any).items || [];
projects.value = items;
if (!isAdmin.value) {
const entries = await Promise.all(
items.map(async (item: Study) => {
try {
const { data: permissions } = await fetchProjectRolePermissions(item.id);
return [item.id, permissions] as const;
} catch {
return [item.id, null] as const;
}
})
);
permissionsByProject.value = Object.fromEntries(entries.filter((entry) => entry[1]));
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjects.loadFailed);
} finally {
@@ -99,10 +121,17 @@ const loadProjects = async () => {
};
const openCreate = () => {
if (!isAdmin.value) return;
editingProject.value = null;
formVisible.value = true;
};
const canProject = (row: Study, module: string, action: "read" | "write") => {
if (isAdmin.value) return true;
const role = row.role_in_study || "";
return !!permissionsByProject.value[row.id]?.roles?.[role as keyof ProjectRolePermissionsResponse["roles"]]?.[module]?.[action];
};
const goMembers = (row: Study) => {
router.push(`/admin/projects/${row.id}/members`);
};
@@ -115,7 +144,12 @@ const goPermissions = (row: Study) => {
router.push(`/admin/projects/${row.id}/permissions`);
};
const goAuditLogs = async (row: Study) => {
await enterStudy(row, "/admin/audit-logs");
};
const handleDelete = async (study: Study) => {
if (!isAdmin.value) return;
try {
// 第一次确认
await ElMessageBox.confirm(
@@ -165,6 +199,7 @@ const handleDelete = async (study: Study) => {
};
const handleLockToggle = async (study: Study) => {
if (!isAdmin.value) return;
// 锁定后不可解锁
if (study.is_locked) {
ElMessage.warning("项目已锁定,不可解锁");
@@ -204,16 +239,24 @@ const handleLockToggle = async (study: Study) => {
}
};
const enterStudy = async (row: Study) => {
const enterStudy = async (row: Study, path = "/project/overview") => {
studyStore.setCurrentStudy(row);
await studyStore.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview");
router.push(path);
};
const goDetail = (row: Study) => {
router.push(`/admin/projects/${row.id}`);
};
const goProject = (row: Study) => {
if (isAdmin.value) {
goDetail(row);
return;
}
enterStudy(row);
};
const statusLabel = (status: string) =>
TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status;
+12
View File
@@ -17,3 +17,15 @@ describe("Admin site form phone field", () => {
expect(typeSource).toContain("contact_phone?: string | null;");
});
});
describe("Admin site PM user loading", () => {
it("uses project member embedded user data instead of the global user directory", () => {
const sitesSource = readFileSync(resolve(__dirname, "./Sites.vue"), "utf8");
const formSource = readSiteForm();
expect(sitesSource).not.toContain("../../api/users");
expect(sitesSource).not.toContain("fetchUsers");
expect(sitesSource).toContain(".map((member) => member.user)");
expect(formSource).toContain("m.user?.full_name");
});
});
+1 -1
View File
@@ -86,7 +86,7 @@ const craOptions = computed(() => {
return acc;
}, {});
return (props.members || []).map((m: any) => ({
label: userMap[m.user_id] || m.full_name || m.username || m.user_id,
label: userMap[m.user_id] || m.user?.full_name || m.user?.username || m.full_name || m.username || m.user_id,
value: m.user_id,
}));
});
+6 -10
View File
@@ -80,7 +80,6 @@ import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import { deleteSite, fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import SiteForm from "./SiteForm.vue";
import type { Site, Study, UserInfo } from "../../types/api";
@@ -125,12 +124,9 @@ const loadSites = async () => {
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || [];
} catch {
users.value = [];
}
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
};
const loadMembers = async () => {
@@ -138,8 +134,10 @@ const loadMembers = async () => {
try {
const { data } = await listMembers(projectId.value, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
loadUsers();
} catch {
members.value = [];
users.value = [];
}
};
@@ -149,7 +147,7 @@ const memberNameMap = computed(() => {
map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m) => {
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.user?.full_name || m.user?.username || m.full_name || m.username || m.user_id;
});
return map;
});
@@ -194,14 +192,12 @@ const siteRowClass = ({ row }: { row: Site }) => {
};
const openCreate = () => {
loadUsers();
loadMembers();
editingSite.value = null;
formVisible.value = true;
};
const openEdit = (row: Site) => {
loadUsers();
loadMembers();
editingSite.value = row;
formVisible.value = true;