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:
@@ -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; }
|
||||
|
||||
/* ── 角色编辑器 ── */
|
||||
|
||||
Reference in New Issue
Block a user