feat(perm/frontend): PM 视角的权限管理界面与个人有效权限接入
- store/study 与 router 改为读取 /api-permissions/me 拉取当前用户在 当前项目的有效权限,菜单与路由守卫据此判定,并在 PM 项目缺省时 主动 ensureDefaultPmStudy。 - api/projectPermissions 暴露 fetchMyApiEndpointPermissions,并允许 调用方关闭统一错误提示,便于在批量项目权限拉取场景下静默失败。 - 项目管理:Projects 页根据每个项目的有效权限决定项目入口的可点击 状态,缺权限时禁用项目名跳转;ProjectDetail 据 setup_config 写权限 渲染保存/回填/清空/发布按钮,并在无读取权限时提示并退出页面。 - 权限管理:PermissionManagement 切换为按角色提交,Admin 视角才能 编辑 PM 行;按系统模块美化系统级权限展示并标注 PM 可访问项;项目 成员表的角色下拉受 PM 等级约束,自定义角色入口下线。 - 监控:PermissionMonitoring/PermissionAccessLogs 增加 isAdmin 与 showSecurityLog 入参,仅 ADMIN 才能看到底层安全日志与统计。 - ApiEndpointPermissions 表格根据当前用户角色禁用不可写行,避免 误操作 PM 行;MODULE_LABELS 增加 setup_config 的中文展示。 - 测试同步覆盖以上行为:新版 router/Layout/Projects/PermissionManagement /PermissionAccessLogs/PermissionMonitoring/PermissionIpLocations 等 组件均补足检测,并按需为相关组件提供 pinia + localStorage stub。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,19 @@ vi.mock("@/api/projectPermissions", () => ({
|
||||
|
||||
describe("ApiPermissions.vue", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: window.localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<!-- 权限监控 -->
|
||||
<el-tab-pane label="权限监控" name="monitoring">
|
||||
<PermissionMonitoring
|
||||
:is-admin="isAdmin"
|
||||
:metrics="metrics"
|
||||
:health="health"
|
||||
:alerts="alerts"
|
||||
@@ -83,12 +84,16 @@ import {
|
||||
resetPermissionMetrics,
|
||||
} from "@/api/projectPermissions";
|
||||
import { useStudyStore } from "@/store/study";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const studyStore = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
|
||||
const activeTab = ref<"api" | "monitoring">("api");
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -19,23 +19,66 @@ describe("permission management custom roles", () => {
|
||||
expect(source).not.toContain("新增模板");
|
||||
});
|
||||
|
||||
it("keeps custom active roles configurable for permissions and members", () => {
|
||||
it("keeps active roles configurable for permissions and members without inline role creation", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("customRoleInput");
|
||||
expect(source).toContain("addCustomRole");
|
||||
expect(source).not.toContain("customRoleInput");
|
||||
expect(source).not.toContain("addCustomRole");
|
||||
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("ROLE_LABELS[row.category] || row.name");
|
||||
expect(source).toContain("row.category ? roleLabel(row.category) : row.name");
|
||||
expect(source).toContain("await loadPermissionData();");
|
||||
expect(source).toContain('activeRolesInStudy.value[0] || ""');
|
||||
expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""');
|
||||
expect(source).toContain('role === "ADMIN"');
|
||||
});
|
||||
|
||||
it("counts role list permissions from current project permissions when available", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const countEnabledPermissions");
|
||||
expect(source).toContain("const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined;");
|
||||
expect(source).toContain("if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);");
|
||||
});
|
||||
|
||||
it("uses canonical role labels when opening preset roles for editing", () => {
|
||||
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("name: templateRoleName(row), description: row.description ?? \"\",");
|
||||
expect(source).toContain("roleDisplayName(row)");
|
||||
});
|
||||
|
||||
it("keeps member role selects bound to role keys used by the permission matrix", () => {
|
||||
const source = readSource();
|
||||
|
||||
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("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
|
||||
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
|
||||
});
|
||||
|
||||
it("submits only the selected role from role editor saves", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const payload = flattenRolePermissions(editingRole.value, roleEditorDraft.value);");
|
||||
expect(source).toContain("const flattenRolePermissions");
|
||||
expect(source).not.toContain("const payload = flattenMatrix(apiMatrix.value, { includePm: isAdmin.value });");
|
||||
});
|
||||
|
||||
it("renders setup_config as a Chinese module name in the role editor", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('setup_config: "立项配置"');
|
||||
});
|
||||
|
||||
it("does not render the duplicated monitoring header", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("<PermissionMonitoring />");
|
||||
expect(source).toContain('<PermissionMonitoring :is-admin="isAdmin" />');
|
||||
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
|
||||
expect(source).not.toContain("重置指标");
|
||||
expect(source).not.toContain("refreshMonitoring");
|
||||
@@ -56,4 +99,16 @@ describe("permission management custom roles", () => {
|
||||
expect(source).toContain("padding: 0 0 20px;");
|
||||
expect(source).not.toContain("padding: 20px 0 20px;");
|
||||
});
|
||||
|
||||
it("limits project PM member management to subordinate project roles", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const assignableRoleLabels = computed");
|
||||
expect(source).toContain("type MemberRoleRow = { user_id: string; role_in_study: string; user?: any };");
|
||||
expect(source).toContain("const memberRoleLabels = (row: MemberRoleRow)");
|
||||
expect(source).toContain("(ROLE_RANK[role] ?? 0) < ROLE_RANK.PM");
|
||||
expect(source).toContain("(ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM");
|
||||
expect(source).toContain('addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";');
|
||||
expect(source).not.toContain('addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,12 @@
|
||||
@change="(val: string) => updateMemberRole(row.id, val)"
|
||||
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
>
|
||||
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
|
||||
<el-option
|
||||
v-for="(label, val) in memberRoleLabels(row)"
|
||||
:key="val"
|
||||
:label="label"
|
||||
:value="val"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -166,19 +171,31 @@
|
||||
<span class="system-stat-item">
|
||||
<strong>{{ systemPermissions.length }}</strong> 项权限
|
||||
</span>
|
||||
<span class="system-stat-divider" />
|
||||
<span class="system-stat-item">
|
||||
<strong>{{ systemPermissions.filter(p => p.roles.includes('PM')).length }}</strong> 项 PM 可访问
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
|
||||
<div class="system-module-header">
|
||||
<div class="system-module-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
<div class="system-module-icon" :class="`module-icon--${moduleKey}`">
|
||||
<svg v-if="moduleKey === 'system_users'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
||||
<svg v-else-if="moduleKey === 'system_projects'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<svg v-else-if="moduleKey === 'system_permissions'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<svg v-else-if="moduleKey === 'system_monitoring'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<svg v-else-if="moduleKey === 'system_audit'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</div>
|
||||
<div class="system-module-info">
|
||||
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
|
||||
<span class="system-module-count">{{ items.length }} 项操作</span>
|
||||
</div>
|
||||
<div class="system-module-roles-summary">
|
||||
<el-tag v-if="items.some(i => i.roles.includes('PM'))" size="small" type="warning" effect="plain" round>含 PM 权限</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-perm-list">
|
||||
<div v-for="row in items" :key="row.permission_key" class="system-perm-row">
|
||||
<div v-for="(row, idx) in items" :key="row.permission_key" class="system-perm-row" :class="{ 'system-perm-row--alt': idx % 2 === 1 }">
|
||||
<div class="system-perm-main">
|
||||
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small" class="system-perm-action">
|
||||
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
|
||||
@@ -187,7 +204,15 @@
|
||||
</div>
|
||||
<code class="perm-key">{{ row.permission_key }}</code>
|
||||
<div class="system-perm-roles">
|
||||
<el-tag v-for="r in row.roles" :key="r" size="small" effect="dark" round class="role-chip">{{ ROLE_LABELS[r] || r }}</el-tag>
|
||||
<el-tag
|
||||
v-for="r in row.roles"
|
||||
:key="r"
|
||||
size="small"
|
||||
:effect="r === 'ADMIN' ? 'dark' : 'plain'"
|
||||
:type="r === 'ADMIN' ? '' : 'warning'"
|
||||
round
|
||||
class="role-chip"
|
||||
>{{ roleLabel(r) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +227,7 @@
|
||||
<template v-else-if="activeTab === 'monitoring'">
|
||||
<div class="perm-body">
|
||||
<div class="perm-body-padded">
|
||||
<PermissionMonitoring />
|
||||
<PermissionMonitoring :is-admin="isAdmin" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -275,10 +300,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-role-entry">
|
||||
<el-input v-model="customRoleInput" placeholder="输入自定义角色标识,如 DATA_MANAGER" maxlength="20" clearable />
|
||||
<el-button @click="addCustomRole">新增角色</el-button>
|
||||
</div>
|
||||
<div class="drawer-footer">
|
||||
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
|
||||
</div>
|
||||
@@ -310,16 +331,16 @@
|
||||
<!-- 右侧权限编辑区 -->
|
||||
<div class="role-editor-content">
|
||||
<template v-if="editingRole">
|
||||
<div v-if="editingRole === 'PM'" class="role-editor-notice">
|
||||
<el-alert title="项目负责人拥有全部权限,不可修改" type="info" :closable="false" show-icon />
|
||||
<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="editingRole === 'PM'" @click="roleEditorSelectAll(true)">全选</el-button>
|
||||
<el-button size="small" :disabled="editingRole === 'PM'" @click="roleEditorSelectAll(false)">全不选</el-button>
|
||||
<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">
|
||||
@@ -331,7 +352,7 @@
|
||||
<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="editingRole === 'PM'"
|
||||
: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>
|
||||
@@ -341,8 +362,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-footer">
|
||||
<el-button type="primary" :loading="roleEditorSaving" :disabled="editingRole === 'PM'" @click="saveRoleEditor">
|
||||
保存 {{ ROLE_LABELS[editingRole] }} 权限
|
||||
<el-button type="primary" :loading="roleEditorSaving" :disabled="!canEditSelectedRole" @click="saveRoleEditor">
|
||||
保存 {{ roleLabel(editingRole) }} 权限
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -402,7 +423,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="项目角色" prop="role_in_study">
|
||||
<el-select v-model="addMemberForm.role_in_study" style="width: 100%">
|
||||
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
|
||||
<el-option v-for="(label, val) in assignableRoleLabels" :key="val" :label="label" :value="val" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -447,6 +468,7 @@ import { fetchStudies } from "@/api/studies";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { displayDateTime } from "@/utils/display";
|
||||
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "@/utils/roles";
|
||||
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
|
||||
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
||||
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
|
||||
@@ -454,22 +476,43 @@ import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
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: "药品管理员", QA: "QA",
|
||||
};
|
||||
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
|
||||
|
||||
const activeRoleLabels = computed(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const role of activeRolesInStudy.value) {
|
||||
result[role] = ROLE_LABELS[role] || role;
|
||||
result[role] = roleLabel(role);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const ROLE_RANK: Record<string, number> = {
|
||||
ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 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;
|
||||
const assignableRoleLabels = computed(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const role of activeRolesInStudy.value) {
|
||||
if (canAssignProjectRole(role)) result[role] = roleLabel(role);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const memberRoleLabels = (row: MemberRoleRow) => {
|
||||
if (canEditMember(row)) return assignableRoleLabels.value;
|
||||
return {
|
||||
...assignableRoleLabels.value,
|
||||
[row.role_in_study]: roleLabel(row.role_in_study),
|
||||
};
|
||||
};
|
||||
|
||||
// ── 顶层标签 ──
|
||||
const tabFromPath = (): "project" | "system" | "monitoring" => {
|
||||
@@ -513,7 +556,8 @@ const currentPermissionsForTemplate = computed(() => {
|
||||
const loadStudies = async () => {
|
||||
try {
|
||||
const res = await fetchStudies();
|
||||
studies.value = res.data.items ?? [];
|
||||
const items = res.data.items ?? [];
|
||||
studies.value = isAdmin.value ? items : items.filter((item: Study) => item.role_in_study === "PM");
|
||||
} catch {
|
||||
ElMessage.error("加载项目列表失败");
|
||||
}
|
||||
@@ -534,6 +578,12 @@ const loadPermissionData = async () => {
|
||||
};
|
||||
|
||||
const onStudyChange = async (id: string) => {
|
||||
const study = studies.value.find((s) => s.id === id);
|
||||
if (!isAdmin.value && study?.role_in_study !== "PM") {
|
||||
ElMessage.error("仅项目 PM 可配置该项目");
|
||||
selectedStudyId.value = "";
|
||||
return;
|
||||
}
|
||||
apiMatrix.value = null;
|
||||
dirty.value = false;
|
||||
members.value = [];
|
||||
@@ -549,9 +599,14 @@ const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.val
|
||||
const activeRolesInStudy = computed(() => activeRolesDraft.value);
|
||||
|
||||
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
|
||||
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
|
||||
const flattenMatrix = (
|
||||
matrix: ApiEndpointPermissionsResponse,
|
||||
{ includePm = true }: { includePm?: boolean } = {}
|
||||
): Record<string, Record<string, boolean>> => {
|
||||
const result: Record<string, Record<string, boolean>> = {};
|
||||
for (const [role, endpoints] of Object.entries(matrix as Record<string, Record<string, any>>)) {
|
||||
if (role === "ADMIN") continue;
|
||||
if (role === "PM" && !includePm) continue;
|
||||
result[role] = {};
|
||||
for (const [key, val] of Object.entries(endpoints)) {
|
||||
result[role][key] = typeof val === "boolean" ? val : val.allowed;
|
||||
@@ -560,11 +615,21 @@ const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, R
|
||||
return result;
|
||||
};
|
||||
|
||||
const flattenRolePermissions = (
|
||||
role: string,
|
||||
permissions: Record<string, boolean>
|
||||
): Record<string, Record<string, boolean>> => ({
|
||||
[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));
|
||||
const res = await updateApiEndpointPermissions(
|
||||
selectedStudyId.value,
|
||||
flattenMatrix(apiMatrix.value, { includePm: isAdmin.value })
|
||||
);
|
||||
apiMatrix.value = res.data;
|
||||
dirty.value = false;
|
||||
ElMessage.success("权限已保存");
|
||||
@@ -588,10 +653,11 @@ const addMemberRules: FormRules = {
|
||||
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
|
||||
};
|
||||
|
||||
const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => {
|
||||
if (!auth.user?.is_admin) return false;
|
||||
const canEditMember = (row: MemberRoleRow) => {
|
||||
if (!canManageSelectedProject.value) return false;
|
||||
if (row.user_id === auth.user?.id) return false;
|
||||
if (row.user?.is_admin) return false;
|
||||
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -633,7 +699,7 @@ const loadCandidates = async () => {
|
||||
|
||||
const openAddMember = () => {
|
||||
addMemberForm.user_id = "";
|
||||
addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM" : activeRolesInStudy.value[0] || "";
|
||||
addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
|
||||
addMemberVisible.value = true;
|
||||
};
|
||||
|
||||
@@ -718,7 +784,6 @@ const ALL_ROLES = [
|
||||
const activeRolesDraft = ref<string[]>([]);
|
||||
const activeRolesLoading = ref(false);
|
||||
const activeRolesSaving = ref(false);
|
||||
const customRoleInput = ref("");
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const options = [...ALL_ROLES];
|
||||
@@ -759,22 +824,8 @@ const toggleActiveRole = (role: string, active: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomRole = () => {
|
||||
const role = customRoleInput.value.trim();
|
||||
if (!role) return;
|
||||
if (role === "ADMIN") {
|
||||
ElMessage.warning("ADMIN 不能作为项目角色");
|
||||
return;
|
||||
}
|
||||
if (role.length > 20) {
|
||||
ElMessage.warning("项目角色长度不能超过20个字符");
|
||||
return;
|
||||
}
|
||||
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
|
||||
customRoleInput.value = "";
|
||||
};
|
||||
|
||||
const saveActiveRoles = async () => {
|
||||
if (!canManageSelectedProject.value) return;
|
||||
activeRolesSaving.value = true;
|
||||
try {
|
||||
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
|
||||
@@ -799,11 +850,18 @@ 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 roleDisplayName = (row: PermissionTemplate) => (row.category ? (ROLE_LABELS[row.category] || row.name) : row.name);
|
||||
const templateRoleName = (row: PermissionTemplate) => row.category ? roleLabel(row.category) : row.name;
|
||||
const roleDisplayName = (row: PermissionTemplate) => templateRoleName(row);
|
||||
const countEnabledPermissions = (permissions: Record<string, boolean>) =>
|
||||
Object.values(permissions).filter(Boolean).length;
|
||||
const countPermissions = (row: PermissionTemplate) => {
|
||||
const role = row.category;
|
||||
const currentRolePermissions = role ? currentPermissionsForTemplate.value?.[role] : undefined;
|
||||
if (currentRolePermissions) return countEnabledPermissions(currentRolePermissions);
|
||||
|
||||
let n = 0;
|
||||
for (const perms of Object.values(row.permissions) as Record<string, boolean>[])
|
||||
n += Object.values(perms).filter(Boolean).length;
|
||||
n += countEnabledPermissions(perms);
|
||||
return n;
|
||||
};
|
||||
|
||||
@@ -828,7 +886,7 @@ const openCreateTemplate = () => {
|
||||
const openEditTemplate = (row: PermissionTemplate) => {
|
||||
editingTemplateId.value = row.id;
|
||||
templateForm.value = {
|
||||
name: row.name, description: row.description ?? "",
|
||||
name: templateRoleName(row), description: row.description ?? "",
|
||||
template_type: row.template_type, category: row.category ?? "",
|
||||
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
|
||||
};
|
||||
@@ -934,7 +992,7 @@ const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
|
||||
subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题",
|
||||
monitoring_audit: "监查稽查", project_members: "项目成员",
|
||||
project_milestones: "项目里程碑", project_overview: "项目总览",
|
||||
fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
|
||||
setup_config: "立项配置", fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
|
||||
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
|
||||
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
|
||||
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
|
||||
@@ -978,6 +1036,13 @@ const roleEditorGrouped = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const canEditSelectedRole = computed(() => {
|
||||
if (editingRole.value === "ADMIN") return false;
|
||||
if (isAdmin.value) return true;
|
||||
if (editingRole.value === "PM") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const selectRoleForEdit = (role: string) => {
|
||||
editingRole.value = role;
|
||||
roleEditorSearch.value = "";
|
||||
@@ -1022,19 +1087,19 @@ const roleEditorSelectAll = (val: boolean) => {
|
||||
|
||||
const saveRoleEditor = async () => {
|
||||
if (!selectedStudyId.value || !apiMatrix.value) return;
|
||||
if (!canEditSelectedRole.value) {
|
||||
ElMessage.warning("当前角色权限不可修改");
|
||||
return;
|
||||
}
|
||||
roleEditorSaving.value = true;
|
||||
try {
|
||||
const payload = flattenMatrix(apiMatrix.value);
|
||||
if (!payload[editingRole.value]) payload[editingRole.value] = {};
|
||||
for (const [key, val] of Object.entries(roleEditorDraft.value)) {
|
||||
payload[editingRole.value][key] = val;
|
||||
}
|
||||
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(`${ROLE_LABELS[savedRole] || savedRole} 权限已保存`);
|
||||
ElMessage.success(`${roleLabel(savedRole)} 权限已保存`);
|
||||
} catch {
|
||||
ElMessage.error("保存失败");
|
||||
} finally {
|
||||
@@ -1055,8 +1120,9 @@ onMounted(async () => {
|
||||
|
||||
const qProjectId = route.query.projectId as string | undefined;
|
||||
const qSub = route.query.sub as string | undefined;
|
||||
if (qProjectId) {
|
||||
selectedStudyId.value = qProjectId;
|
||||
const initialStudyId = qProjectId || studies.value[0]?.id;
|
||||
if (initialStudyId) {
|
||||
selectedStudyId.value = initialStudyId;
|
||||
if (qSub === "members") projectSubTab.value = "members";
|
||||
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
||||
loadCandidates();
|
||||
@@ -1278,15 +1344,15 @@ onMounted(async () => {
|
||||
padding: 20px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.system-module-block {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -1298,8 +1364,8 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
@@ -1307,67 +1373,103 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #eff6ff, #e0f2fe);
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.system-module-icon.module-icon--system_users {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.system-module-icon.module-icon--system_projects {
|
||||
background: linear-gradient(135deg, #ede9fe, #ddd6fe);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.system-module-icon.module-icon--system_permissions {
|
||||
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.system-module-icon.module-icon--system_monitoring {
|
||||
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.system-module-icon.module-icon--system_audit {
|
||||
background: linear-gradient(135deg, #fef2f2, #fecaca);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.system-module-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.system-module-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.system-module-title {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a2332;
|
||||
}
|
||||
|
||||
.system-module-count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.system-module-roles-summary {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.system-perm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.system-perm-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) minmax(180px, auto) auto;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.system-perm-row--alt {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.system-perm-row:hover {
|
||||
background: #f8fafc;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.system-perm-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.system-perm-action {
|
||||
flex-shrink: 0;
|
||||
min-width: 44px;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.system-perm-desc {
|
||||
@@ -1382,7 +1484,8 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.role-chip {
|
||||
@@ -1393,10 +1496,11 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: #f8fafc;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.system-stat-item {
|
||||
@@ -1419,11 +1523,12 @@ onMounted(async () => {
|
||||
.system-perm-table { width: 100%; }
|
||||
|
||||
.perm-key {
|
||||
justify-self: start;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11.5px;
|
||||
background: linear-gradient(135deg, #f0f4f8, #e8edf5);
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
@@ -1506,16 +1611,6 @@ onMounted(async () => {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.custom-role-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.template-name { font-weight: 600; color: #1a2332; }
|
||||
|
||||
/* ── 角色编辑器 ── */
|
||||
|
||||
@@ -56,10 +56,10 @@
|
||||
</div>
|
||||
|
||||
<div class="setup-toolbar-actions">
|
||||
<el-button type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
|
||||
<el-button type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
|
||||
<el-button type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
|
||||
<el-button type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
|
||||
<el-button v-if="canManageSetup" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
|
||||
<el-button v-if="canManageSetup" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
|
||||
<el-button v-if="canManageSetup" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
|
||||
<el-button v-if="canManageSetup" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
plain
|
||||
@@ -1912,9 +1912,9 @@ import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFi
|
||||
import {
|
||||
fetchStudyDetail,
|
||||
} from "../../api/studies";
|
||||
import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import type { Site, Study } from "../../types/api";
|
||||
import type {
|
||||
CenterConfirmDraft,
|
||||
@@ -1933,6 +1933,7 @@ import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { TEXT, requiredMessage } from "../../locales";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator";
|
||||
import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows";
|
||||
import {
|
||||
@@ -2403,7 +2404,21 @@ const setupDraft = reactive<SetupConfigDraft>({
|
||||
|
||||
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
|
||||
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
|
||||
const canManageSetup = computed(() => isSystemAdmin(authStore.user));
|
||||
const projectPermissions = ref<Record<string, Record<string, any>> | null>(null);
|
||||
const hasSetupReadPermission = computed(() => {
|
||||
if (isSystemAdmin(authStore.user)) return true;
|
||||
if (!projectPermissions.value) return false;
|
||||
const rolePerms = Object.values(projectPermissions.value)[0];
|
||||
if (!rolePerms) return false;
|
||||
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
|
||||
});
|
||||
const canManageSetup = computed(() => {
|
||||
if (isSystemAdmin(authStore.user)) return true;
|
||||
if (!projectPermissions.value) return false;
|
||||
const rolePerms = Object.values(projectPermissions.value)[0];
|
||||
if (!rolePerms) return false;
|
||||
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
|
||||
});
|
||||
const isPreviewView = computed(() => setupViewMode.value === "preview");
|
||||
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
|
||||
const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value);
|
||||
@@ -4361,18 +4376,7 @@ const loadMemberDisplayMap = async (studyId: string) => {
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore member API errors and fallback to users API below.
|
||||
}
|
||||
try {
|
||||
const { data } = await fetchUsers({ limit: 1000 });
|
||||
const users = (data as any)?.items || [];
|
||||
users.forEach((user: any) => {
|
||||
const userId = String(user?.id || "").trim();
|
||||
if (!userId || nextMap[userId]) return;
|
||||
nextMap[userId] = user?.full_name || user?.username || user?.email || userId;
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
// Ignore member API errors.
|
||||
}
|
||||
memberDisplayMap.value = nextMap;
|
||||
projectMemberOptions.value = Array.from(memberOptionMap.entries())
|
||||
@@ -4384,8 +4388,21 @@ const loadProject = async () => {
|
||||
try {
|
||||
const projectId = route.params.projectId as string;
|
||||
projectPublishCompareFallback.value = null;
|
||||
const { data } = await fetchStudyDetail(projectId);
|
||||
|
||||
const [studyRes, permRes] = await Promise.all([
|
||||
fetchStudyDetail(projectId),
|
||||
fetchMyApiEndpointPermissions(projectId, { suppressErrorMessage: true }).catch(() => ({ data: null })),
|
||||
]);
|
||||
const { data } = studyRes;
|
||||
project.value = data as Study;
|
||||
projectPermissions.value = permRes.data as any;
|
||||
|
||||
if (!hasSetupReadPermission.value) {
|
||||
ElMessage.warning("当前角色无权访问立项配置");
|
||||
router.replace("/admin/projects");
|
||||
return;
|
||||
}
|
||||
|
||||
syncFormFromProjectWithoutSetupLink(project.value);
|
||||
projectSnapshotAtLoad.value = buildProjectPublishSnapshot();
|
||||
clearLocalProjectDraft();
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("ProjectMembers user directory access", () => {
|
||||
|
||||
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("if (row.user?.is_admin) return false;");
|
||||
expect(source).toContain("const canAssignRole = (role: string)");
|
||||
expect(source).toContain(':disabled="!canAssignRole(\'ADMIN\')"');
|
||||
expect(source).toContain(':disabled="!canEditMember(scope.row)');
|
||||
|
||||
@@ -42,12 +42,22 @@ describe("project management access", () => {
|
||||
it("uses each project permission matrix to show project management actions", () => {
|
||||
const source = readProjects();
|
||||
|
||||
expect(source).toContain("fetchApiEndpointPermissions(item.id)");
|
||||
expect(source).toContain("fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true })");
|
||||
expect(source).toContain("permissionsByProject");
|
||||
expect(source).toContain("suppressErrorMessage: true");
|
||||
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')");
|
||||
});
|
||||
|
||||
it("opens project names in management detail when the user has setup config write permission", () => {
|
||||
const source = readProjects();
|
||||
|
||||
expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'write')\"");
|
||||
expect(source).toContain('@click="goProject(scope.row)" class="project-name"');
|
||||
expect(source).toContain('@click="enterStudy(scope.row)"');
|
||||
expect(source).toContain('if (!canProject(row, "setup_config", "write"))');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="project-info">
|
||||
<el-link type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
|
||||
<el-link v-if="canProject(scope.row, 'setup_config', 'write')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
|
||||
<span v-else class="project-name project-name--disabled">{{ scope.row.name }}</span>
|
||||
<span class="project-code">{{ scope.row.code || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +152,7 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { User, OfficeBuilding, ArrowRight, Delete, Lock, Key, Document, Plus } from "@element-plus/icons-vue";
|
||||
import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
|
||||
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
|
||||
import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions";
|
||||
import type { ApiEndpointPermissionsResponse, Study } from "../../types/api";
|
||||
import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -184,7 +185,7 @@ const loadProjects = async () => {
|
||||
const entries = await Promise.all(
|
||||
items.map(async (item: Study) => {
|
||||
try {
|
||||
const { data: permissions } = await fetchApiEndpointPermissions(item.id);
|
||||
const { data: permissions } = await fetchMyApiEndpointPermissions(item.id, { suppressErrorMessage: true });
|
||||
return [item.id, permissions] as const;
|
||||
} catch {
|
||||
return [item.id, null] as const;
|
||||
@@ -210,14 +211,18 @@ const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
||||
project_members: { read: "project_members:list", write: "project_members:update" },
|
||||
sites: { read: "sites:read", write: "sites:update" },
|
||||
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
|
||||
setup_config: { read: "setup_config:read", write: "setup_config:update" },
|
||||
};
|
||||
|
||||
const canProject = (row: Study, module: string, action: "read" | "write") => {
|
||||
if (isAdmin.value) return true;
|
||||
const role = row.role_in_study || "";
|
||||
const operationKey = MODULE_TO_OPERATION[module]?.[action];
|
||||
if (!operationKey) return false;
|
||||
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]);
|
||||
const perms = permissionsByProject.value[row.id];
|
||||
if (!perms) return false;
|
||||
const rolePerms = Object.values(perms)[0];
|
||||
if (!rolePerms) return false;
|
||||
return isApiPermissionAllowed(rolePerms[operationKey]);
|
||||
};
|
||||
|
||||
const goMembers = (row: Study) => {
|
||||
@@ -330,11 +335,11 @@ const goDetail = (row: Study) => {
|
||||
};
|
||||
|
||||
const goProject = (row: Study) => {
|
||||
if (isAdmin.value) {
|
||||
goDetail(row);
|
||||
if (!canProject(row, "setup_config", "write")) {
|
||||
ElMessage.warning("当前角色无权访问该项目的立项配置");
|
||||
return;
|
||||
}
|
||||
enterStudy(row);
|
||||
goDetail(row);
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) =>
|
||||
@@ -449,6 +454,13 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-name--disabled {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.project-code {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
|
||||
Reference in New Issue
Block a user