Files
ctms/frontend/src/views/admin/PermissionManagement.vue
T
2026-05-19 16:03:27 +08:00

1360 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="perm-page" v-loading="pageLoading">
<!-- ══════════════════════════════════════
项目权限配置
══════════════════════════════════════ -->
<template v-if="activeTab === 'project'">
<div class="perm-header">
<div class="perm-header-left">
<h2 class="perm-title">项目权限配置</h2>
<span class="perm-subtitle">为每个项目的角色配置接口级权限</span>
</div>
<div class="perm-header-actions">
<el-select
v-model="selectedStudyId"
placeholder="选择项目"
filterable
style="width: 280px"
@change="onStudyChange"
>
<el-option
v-for="s in studies"
:key="s.id"
:label="`${s.code} · ${s.name}`"
:value="s.id"
/>
</el-select>
</div>
</div>
<div class="perm-body">
<div v-if="!selectedStudyId" class="perm-empty">
<el-empty description="请先选择一个项目以配置权限" />
</div>
<template v-else>
<el-tabs v-model="projectSubTab" class="project-sub-tabs">
<el-tab-pane label="角色权限" name="api">
<div class="sub-tab-toolbar">
<div class="sub-tab-spacer" />
<el-button @click="templateDrawerVisible = true">
<el-icon><Files /></el-icon>
模板管理
</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
<PermissionTemplateSelector
:study-id="selectedStudyId"
:current-permissions="currentPermissionsForTemplate"
:active-roles="activeRolesInStudy"
@edit-role="openRoleEditor"
/>
<el-divider />
<ApiEndpointPermissions
:project="selectedStudy"
:matrix="apiMatrix"
@update="onApiMatrixUpdate"
/>
</el-tab-pane>
<el-tab-pane label="成员管理" name="members">
<div class="sub-tab-toolbar">
<div class="sub-tab-spacer" />
<el-button type="primary" @click="openAddMember">添加成员</el-button>
</div>
<el-table :data="memberRows" v-loading="membersLoading" stripe class="full-table">
<el-table-column prop="full_name" label="姓名" min-width="140" />
<el-table-column label="项目角色" min-width="140">
<template #default="{ row }">
<el-select
v-model="row.role_in_study"
size="small"
style="width: 130px"
@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-select>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tooltip v-if="row.effectiveStatus === 'DISABLED_GLOBAL'" content="请前往账号管理启用该账号" placement="top">
<el-tag type="danger">全局已禁用</el-tag>
</el-tooltip>
<el-tag v-else :type="row.is_active ? 'success' : 'danger'">
{{ row.is_active ? "启用" : "已禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="added_at" label="加入时间" min-width="160">
<template #default="{ row }">{{ displayDateTime(row.added_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button
link
:type="row.is_active ? 'warning' : 'primary'"
size="small"
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleMemberActive(row)"
>
{{ row.is_active ? "停用" : "启用" }}
</el-button>
<el-button link type="danger" size="small" :disabled="!canEditMember(row)" @click="deleteMember(row)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<!-- ══════════════════════════════════════
系统级权限
══════════════════════════════════════ -->
<template v-else-if="activeTab === 'system'">
<div class="perm-header">
<div class="perm-header-left">
<h2 class="perm-title">系统级权限</h2>
<span class="perm-subtitle">只读展示所有系统管理操作仅限 ADMIN 角色执行</span>
</div>
</div>
<div class="perm-body">
<div v-if="systemLoading" class="perm-empty">
<el-skeleton :rows="8" animated />
</div>
<div v-else-if="!systemPermissions.length" class="perm-empty">
<el-empty description="暂无系统级权限数据" />
</div>
<template v-else>
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
<div class="system-module-header">
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
<el-tag size="small" type="info" round>{{ items.length }} 项操作</el-tag>
</div>
<el-table :data="items" border size="small" class="system-perm-table">
<el-table-column prop="description" label="操作描述" min-width="180" />
<el-table-column prop="permission_key" label="权限标识" min-width="220">
<template #default="{ row }">
<code class="perm-key">{{ row.permission_key }}</code>
</template>
</el-table-column>
<el-table-column prop="action" label="操作类型" width="100">
<template #default="{ row }">
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small">
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="授权角色" width="120">
<template #default="{ row }">
<el-tag v-for="r in row.roles" :key="r" size="small" type="danger" style="margin-right: 4px">{{ r }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</template>
<!-- ══════════════════════════════════════
权限监控
══════════════════════════════════════ -->
<template v-else-if="activeTab === 'monitoring'">
<div class="perm-header">
<div class="perm-header-left">
<h2 class="perm-title">权限监控</h2>
<span class="perm-subtitle">实时监控权限使用情况与异常告警</span>
</div>
<div class="perm-header-actions">
<el-button @click="loadMonitoringData" :loading="monitoringLoading">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
<el-button @click="doResetMetrics" :loading="resetting">重置指标</el-button>
</div>
</div>
<div class="perm-body">
<div class="perm-body-padded">
<PermissionMonitoring
:metrics="metrics"
:health="health"
:alerts="alerts"
@refresh="loadMonitoringData"
/>
</div>
</div>
</template>
<!-- ══════════════════════════════════════
模板管理抽屉
══════════════════════════════════════ -->
<el-drawer v-model="templateDrawerVisible" title="权限模板管理" size="720px" direction="rtl" @open="onTemplateDrawerOpen">
<el-tabs v-model="templateDrawerTab" class="drawer-tabs">
<!-- 标签页模板列表 -->
<el-tab-pane label="模板列表" name="list">
<div class="drawer-toolbar">
<el-select v-model="typeFilter" placeholder="模板类型" clearable style="width: 140px" @change="loadTemplates">
<el-option label="角色模板" value="ROLE" />
<el-option label="场景模板" value="SCENARIO" />
<el-option label="自定义模板" value="CUSTOM" />
</el-select>
<div class="filter-spacer" />
<el-button type="primary" @click="openCreateTemplate">新增模板</el-button>
</div>
<el-table :data="templates" v-loading="templatesLoading" stripe style="margin-top: 12px">
<el-table-column prop="name" label="模板名称" min-width="140">
<template #default="{ row }">
<span class="template-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="template_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTagType(row.template_type)" size="small">{{ typeLabel(row.template_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限数" width="80">
<template #default="{ row }">{{ countPermissions(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditTemplate(row)">编辑</el-button>
<el-button link type="danger" :disabled="row.is_system" @click="confirmDeleteTemplate(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 标签页生效管理 -->
<el-tab-pane label="生效管理" name="active" :disabled="!selectedStudyId">
<div class="active-roles-desc">
<el-alert
title="生效的角色将映射到成员管理的项目角色中,未生效的角色不可在成员管理中分配。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
</div>
<div class="active-roles-list" v-loading="activeRolesLoading">
<div v-for="role in ALL_ROLES" :key="role.key" class="active-role-row">
<div class="active-role-info">
<span class="active-role-icon">{{ role.icon }}</span>
<div>
<div class="active-role-name">{{ role.label }}</div>
<div class="active-role-desc">{{ role.desc }}</div>
</div>
</div>
<el-switch
:model-value="activeRolesDraft.includes(role.key)"
@change="(v: boolean) => toggleActiveRole(role.key, v)"
/>
</div>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="activeRolesSaving" @click="saveActiveRoles">保存</el-button>
</div>
</el-tab-pane>
<!-- 标签页编辑权限 -->
<el-tab-pane label="编辑权限" name="edit-role" :disabled="!selectedStudyId">
<div class="role-editor-layout" v-loading="roleEditorLoading">
<!-- 左侧角色列表 -->
<div class="role-editor-sidebar">
<div
v-for="role in ALL_ROLES"
:key="role.key"
class="role-sidebar-item"
:class="{ active: editingRole === role.key }"
@click="selectRoleForEdit(role.key)"
>
<span class="role-sidebar-icon">{{ role.icon }}</span>
<div class="role-sidebar-info">
<div class="role-sidebar-name">{{ role.label }}</div>
<div class="role-sidebar-count" v-if="editingRole === role.key && Object.keys(roleEditorDraft).length">
{{ Object.values(roleEditorDraft).filter(Boolean).length }} 项已启用
</div>
</div>
<el-icon v-if="editingRole === role.key" class="role-sidebar-arrow"><ArrowRight /></el-icon>
</div>
</div>
<!-- 右侧权限编辑区 -->
<div class="role-editor-content">
<template v-if="editingRole">
<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" @click="roleEditorSelectAll(true)">全选</el-button>
<el-button size="small" @click="roleEditorSelectAll(false)">全不选</el-button>
</div>
<div class="role-editor-modules">
<div v-for="(ops, mod) in roleEditorGrouped" :key="mod" class="role-editor-module">
<div class="role-editor-module-header">
<span>{{ roleEditorModuleLabel(mod) }}</span>
<el-tag size="small" type="info">{{ ops.length }}</el-tag>
</div>
<div class="role-editor-ops">
<div v-for="op in ops" :key="op.operation_key" class="role-editor-op-row">
<el-checkbox
:model-value="roleEditorDraft[op.operation_key] ?? false"
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
/>
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
<span class="op-desc">{{ op.description || op.operation_key }}</span>
</div>
</div>
</div>
</div>
<div class="drawer-footer">
<el-button type="primary" :loading="roleEditorSaving" @click="saveRoleEditor">
保存 {{ ROLE_LABELS[editingRole] }} 权限
</el-button>
</div>
</template>
<el-empty v-else description="请从左侧选择角色" style="padding: 80px 0" />
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-drawer>
<!-- 新增/编辑模板对话框 -->
<el-dialog v-model="templateDialogVisible" :title="editingTemplateId ? '编辑权限模板' : '新增权限模板'" width="520px" @close="resetTemplateForm">
<el-form :model="templateForm" :rules="templateRules" ref="templateFormRef" label-width="90px">
<el-form-item label="模板名称" prop="name">
<el-input v-model="templateForm.name" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="templateForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
<el-form-item label="模板类型" prop="template_type">
<el-select v-model="templateForm.template_type" style="width: 100%">
<el-option label="角色模板" value="ROLE" />
<el-option label="场景模板" value="SCENARIO" />
<el-option label="自定义模板" value="CUSTOM" />
</el-select>
</el-form-item>
<el-form-item label="分类">
<el-input v-model="templateForm.category" placeholder="如 PM / CRA / PV" />
</el-form-item>
<el-form-item label="推荐角色">
<el-input v-model="templateForm.recommended_roles" placeholder="如 PM,CRA" />
</el-form-item>
<el-form-item label="标签">
<el-input v-model="templateForm.tags" placeholder="可选标签" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="templateSaving" @click="submitTemplateForm">保存</el-button>
</template>
</el-dialog>
<!-- 新增成员对话框 -->
<el-dialog v-model="addMemberVisible" title="新增成员" width="480px" @close="resetAddMemberForm">
<el-form :model="addMemberForm" :rules="addMemberRules" ref="addMemberFormRef" label-width="90px">
<el-form-item label="用户" prop="user_id">
<el-select v-model="addMemberForm.user_id" filterable placeholder="请选择用户" style="width: 100%">
<el-option
v-for="u in availableCandidates"
:key="u.id"
:label="u.full_name"
:value="u.id"
:disabled="!u.is_active"
/>
</el-select>
</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-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addMemberVisible = false">取消</el-button>
<el-button type="primary" :loading="addMemberSaving" @click="submitAddMember">添加</el-button>
</template>
</el-dialog>
<!-- 角色权限编辑抽屉已合并至模板管理抽屉 -->
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, reactive } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { RefreshRight, Check, Files, Search, ArrowRight } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import type {
ApiEndpointPermissionsResponse,
PermissionMetricsResponse,
AlertsResponse,
HealthResponse,
Study,
StudyMember,
UserInfo,
} from "@/types/api";
import {
fetchApiEndpointPermissions,
updateApiEndpointPermissions,
fetchApiOperations,
fetchPermissionMetrics,
fetchPermissionAlerts,
fetchPermissionHealth,
resetPermissionMetrics,
fetchPermissionTemplates,
createPermissionTemplate,
updatePermissionTemplate,
deletePermissionTemplate,
fetchSystemPermissions,
fetchActiveRoles,
updateActiveRoles,
type PermissionTemplate,
type SystemPermissionItem,
} from "@/api/projectPermissions";
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "@/api/members";
import { fetchStudies } from "@/api/studies";
import { useAuthStore } from "@/store/auth";
import { displayDateTime } from "@/utils/display";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const ROLE_LABELS: Record<string, string> = {
PM: "项目负责人", CRA: "CRA", PV: "PV",
MEDICAL_REVIEW: "医学审核", IMP: "药品管理员", QA: "QA",
};
const activeRoleLabels = computed(() => {
const result: Record<string, string> = {};
for (const role of activeRolesInStudy.value) {
if (ROLE_LABELS[role]) result[role] = ROLE_LABELS[role];
}
return result;
});
const ROLE_RANK: Record<string, number> = {
ADMIN: 100, PM: 80, QA: 60, PV: 50, MEDICAL_REVIEW: 50, CRA: 40, IMP: 40,
};
// ── 顶层标签 ──
const tabFromPath = (): "project" | "system" | "monitoring" => {
const p = route.path;
if (p.endsWith("/system")) return "system";
if (p.endsWith("/monitoring")) return "monitoring";
return "project";
};
const activeTab = computed({
get: () => tabFromPath(),
set: (tab: "project" | "system" | "monitoring") => {
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project", monitoring: "/admin/permissions/monitoring" };
router.push({ path: pathMap[tab], query: route.query });
},
});
const pageLoading = ref(false);
// ═══════════════════════════════════════════
// 项目权限配置
// ═══════════════════════════════════════════
const studies = ref<Study[]>([]);
const selectedStudyId = ref<string>("");
const selectedStudy = computed(() => studies.value.find((s) => s.id === selectedStudyId.value) ?? null);
const projectSubTab = ref<"api" | "members">("api");
// 接口级权限
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
const dirty = ref(false);
const saving = ref(false);
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) result[role][key] = val.allowed;
}
return result;
});
const loadStudies = async () => {
try {
const res = await fetchStudies();
studies.value = res.data.items ?? [];
} catch {
ElMessage.error("加载项目列表失败");
}
};
const loadPermissionData = async () => {
if (!selectedStudyId.value) return;
pageLoading.value = true;
try {
const res = await fetchApiEndpointPermissions(selectedStudyId.value);
apiMatrix.value = res.data;
dirty.value = false;
} catch {
ElMessage.error("加载权限数据失败");
} finally {
pageLoading.value = false;
}
};
const onStudyChange = async (id: string) => {
apiMatrix.value = null;
dirty.value = false;
members.value = [];
candidates.value = [];
activeRolesDraft.value = [];
router.replace({ query: { ...route.query, projectId: id } });
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
loadCandidates();
};
const onApiMatrixUpdate = (m: ApiEndpointPermissionsResponse) => { apiMatrix.value = m; dirty.value = true; };
const activeRolesInStudy = computed(() => activeRolesDraft.value);
// 把 apiMatrix(可能混有 { allowed: boolean } 或 boolean)展平为 boolean 格式
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): 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>>)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
};
const save = async () => {
if (!selectedStudyId.value || !dirty.value || !apiMatrix.value) return;
saving.value = true;
try {
const res = await updateApiEndpointPermissions(selectedStudyId.value, flattenMatrix(apiMatrix.value));
apiMatrix.value = res.data;
dirty.value = false;
ElMessage.success("权限已保存");
} catch {
ElMessage.error("保存权限失败");
} finally {
saving.value = false;
}
};
// 成员管理
const members = ref<StudyMember[]>([]);
const candidates = ref<UserInfo[]>([]);
const membersLoading = ref(false);
const addMemberVisible = ref(false);
const addMemberSaving = ref(false);
const addMemberFormRef = ref<FormInstance>();
const addMemberForm = reactive({ user_id: "", role_in_study: "PM" });
const addMemberRules: FormRules = {
user_id: [{ required: true, message: "请选择用户", trigger: "change" }],
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
};
const canEditMember = (row: { user_id: string; role_in_study: string; user?: any }) => {
if (auth.user?.role !== "ADMIN") return false;
if (row.user_id === auth.user?.id) return false;
if (row.user?.role === "ADMIN") return false;
return true;
};
const memberRows = computed(() =>
members.value.map((m) => {
const user = candidates.value.find((u) => u.id === m.user_id) || m.user;
const effectiveStatus = user && (user as any).is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
return { ...m, full_name: (user as any)?.full_name || m.user_id, effectiveStatus };
})
);
const availableCandidates = computed(() => {
const ids = new Set(members.value.map((m) => m.user_id));
return candidates.value.filter((u) => !ids.has(u.id));
});
const loadMembers = async () => {
if (!selectedStudyId.value) return;
membersLoading.value = true;
try {
const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true });
members.value = Array.isArray(data) ? data : (data as any).items ?? [];
} catch {
ElMessage.error("加载成员失败");
} finally {
membersLoading.value = false;
}
};
const loadCandidates = async () => {
if (!selectedStudyId.value) return;
try {
const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 });
candidates.value = data ?? [];
} catch {
candidates.value = [];
}
};
const openAddMember = () => {
addMemberForm.user_id = "";
addMemberForm.role_in_study = "PM";
addMemberVisible.value = true;
};
const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); };
const submitAddMember = async () => {
const valid = await addMemberFormRef.value?.validate().catch(() => false);
if (!valid) return;
addMemberSaving.value = true;
try {
await addMember(selectedStudyId.value, addMemberForm);
ElMessage.success("成员已添加");
addMemberVisible.value = false;
await Promise.all([loadMembers(), loadCandidates()]);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "添加失败");
} finally {
addMemberSaving.value = false;
}
};
const updateMemberRole = async (memberId: string, role: string) => {
try {
await updateMember(selectedStudyId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
loadMembers();
}
};
const toggleMemberActive = async (row: any) => {
if (row.is_active) {
const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null);
if (!ok) return;
}
try {
await updateMember(selectedStudyId.value, row.id, { is_active: !row.is_active });
ElMessage.success(row.is_active ? "已停用" : "已启用");
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
const deleteMember = async (row: any) => {
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", {
type: "warning",
}).catch(() => null);
if (!ok) return;
try {
await removeMember(selectedStudyId.value, row.id);
ElMessage.success("已移除");
await Promise.all([loadMembers(), loadCandidates()]);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "移除失败");
}
};
// ═══════════════════════════════════════════
// 模板管理(抽屉)
// ═══════════════════════════════════════════
const templateDrawerVisible = ref(false);
const templateDrawerTab = ref<"list" | "active" | "edit-role">("list");
const templates = ref<PermissionTemplate[]>([]);
const templatesLoading = ref(false);
const typeFilter = ref<string | undefined>(undefined);
const templateDialogVisible = ref(false);
const editingTemplateId = ref<string | null>(null);
const templateFormRef = ref<FormInstance>();
const templateSaving = ref(false);
const ALL_ROLES = [
{ key: "PM", label: "项目负责人", icon: "👑", desc: "项目管理员,拥有所有权限" },
{ key: "CRA", label: "CRA", icon: "📋", desc: "数据输入和日常管理人员" },
{ key: "PV", label: "PV", icon: "🔍", desc: "访视和参与者管理人员" },
{ key: "MEDICAL_REVIEW", label: "医学审核", icon: "🏥", desc: "医学审核人员" },
{ key: "IMP", label: "药品管理员", icon: "📦", desc: "物资和设备管理人员" },
{ key: "QA", label: "QA", icon: "✅", desc: "质量检查和审计人员" },
];
const activeRolesDraft = ref<string[]>([]);
const activeRolesLoading = ref(false);
const activeRolesSaving = ref(false);
const onTemplateDrawerOpen = async () => {
await loadTemplates();
if (selectedStudyId.value) {
await Promise.all([loadActiveRoles(), ensureOperationsLoaded()]);
}
};
const loadActiveRoles = async () => {
activeRolesLoading.value = true;
try {
const res = await fetchActiveRoles(selectedStudyId.value);
activeRolesDraft.value = res.data.active_roles;
} catch {
ElMessage.error("加载生效角色失败");
} finally {
activeRolesLoading.value = false;
}
};
const toggleActiveRole = (role: string, active: boolean) => {
if (active) {
if (!activeRolesDraft.value.includes(role)) activeRolesDraft.value.push(role);
} else {
activeRolesDraft.value = activeRolesDraft.value.filter((r) => r !== role);
}
};
const saveActiveRoles = async () => {
activeRolesSaving.value = true;
try {
await updateActiveRoles(selectedStudyId.value, activeRolesDraft.value);
ElMessage.success("生效角色已保存");
} catch {
ElMessage.error("保存失败");
} finally {
activeRolesSaving.value = false;
}
};
const templateForm = ref({
name: "", description: "", template_type: "CUSTOM" as "ROLE" | "SCENARIO" | "CUSTOM",
category: "", recommended_roles: "", tags: "",
});
const templateRules: FormRules = {
name: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
template_type: [{ required: true, message: "请选择模板类型", trigger: "change" }],
};
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 countPermissions = (row: PermissionTemplate) => {
let n = 0;
for (const perms of Object.values(row.permissions) as Record<string, boolean>[])
n += Object.values(perms).filter(Boolean).length;
return n;
};
const loadTemplates = async () => {
templatesLoading.value = true;
try {
const res = await fetchPermissionTemplates(typeFilter.value ? { template_type: typeFilter.value } : undefined);
templates.value = res.data;
} catch {
ElMessage.error("加载模板失败");
} finally {
templatesLoading.value = false;
}
};
const openCreateTemplate = () => {
editingTemplateId.value = null;
resetTemplateForm();
templateDialogVisible.value = true;
};
const openEditTemplate = (row: PermissionTemplate) => {
editingTemplateId.value = row.id;
templateForm.value = {
name: row.name, description: row.description ?? "",
template_type: row.template_type, category: row.category ?? "",
recommended_roles: row.recommended_roles ?? "", tags: row.tags ?? "",
};
templateDialogVisible.value = true;
};
const resetTemplateForm = () => {
templateForm.value = { name: "", description: "", template_type: "CUSTOM", category: "", recommended_roles: "", tags: "" };
templateFormRef.value?.clearValidate();
};
const submitTemplateForm = async () => {
const valid = await templateFormRef.value?.validate().catch(() => false);
if (!valid) return;
templateSaving.value = true;
try {
const payload = {
...templateForm.value,
description: templateForm.value.description || undefined,
category: templateForm.value.category || undefined,
recommended_roles: templateForm.value.recommended_roles || undefined,
tags: templateForm.value.tags || undefined,
permissions: editingTemplateId.value
? (templates.value.find((t) => t.id === editingTemplateId.value)?.permissions ?? {})
: {},
};
if (editingTemplateId.value) {
await updatePermissionTemplate(editingTemplateId.value, payload);
ElMessage.success("模板已更新");
} else {
await createPermissionTemplate(payload);
ElMessage.success("模板已创建");
}
templateDialogVisible.value = false;
await loadTemplates();
} catch {
ElMessage.error("保存失败");
} finally {
templateSaving.value = false;
}
};
const confirmDeleteTemplate = async (row: PermissionTemplate) => {
await ElMessageBox.confirm(`确认删除模板「${row.name}」?此操作不可撤销。`, "删除确认", {
type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger",
});
try {
await deletePermissionTemplate(row.id);
ElMessage.success("已删除");
await loadTemplates();
} catch {
ElMessage.error("删除失败");
}
};
// ═══════════════════════════════════════════
// 系统级权限
// ═══════════════════════════════════════════
const systemPermissions = ref<SystemPermissionItem[]>([]);
const systemModuleLabels = ref<Record<string, string>>({});
const systemLoading = ref(false);
const systemPermissionsByModule = computed(() => {
const map: Record<string, SystemPermissionItem[]> = {};
for (const item of systemPermissions.value) {
if (!map[item.module]) map[item.module] = [];
map[item.module].push(item);
}
return map;
});
const loadSystemPermissions = async () => {
systemLoading.value = true;
try {
const res = await fetchSystemPermissions();
systemPermissions.value = res.data.permissions;
systemModuleLabels.value = res.data.module_labels;
} catch {
ElMessage.error("加载系统级权限失败");
} finally {
systemLoading.value = false;
}
};
// ═══════════════════════════════════════════
// 权限监控
// ═══════════════════════════════════════════
const metrics = ref<PermissionMetricsResponse | null>(null);
const health = ref<HealthResponse | null>(null);
const alerts = ref<AlertsResponse | null>(null);
const monitoringLoading = ref(false);
const resetting = ref(false);
const loadMonitoringData = async () => {
monitoringLoading.value = true;
try {
const [metricsRes, healthRes, alertsRes] = await Promise.all([
fetchPermissionMetrics(),
fetchPermissionHealth(),
fetchPermissionAlerts(undefined, 20),
]);
metrics.value = metricsRes.data;
health.value = healthRes.data;
alerts.value = alertsRes.data;
} catch {
ElMessage.error("加载监控数据失败");
} finally {
monitoringLoading.value = false;
}
};
const doResetMetrics = async () => {
resetting.value = true;
try {
await resetPermissionMetrics();
await loadMonitoringData();
ElMessage.success("指标已重置");
} catch {
ElMessage.error("重置失败");
} finally {
resetting.value = false;
}
};
// ═══════════════════════════════════════════
// 角色权限编辑器(抽屉)
// ═══════════════════════════════════════════
interface RoleEditorOp {
operation_key: string;
module: string;
action: string;
description: string;
}
const editingRole = ref("");
const roleEditorSearch = ref("");
const roleEditorDraft = ref<Record<string, boolean>>({});
const roleEditorSaving = ref(false);
const roleEditorLoading = ref(false);
const allOperations = ref<RoleEditorOp[]>([]);
const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
subjects: "参与者管理", sites: "中心管理", risk_issues: "风险问题",
monitoring_audit: "监查稽查", project_members: "项目成员",
project_milestones: "项目里程碑", project_overview: "项目总览",
fees: "合同费用", materials: "物资管理", material_equipments: "物资设备",
documents: "文件版本", startup_ethics: "立项与伦理", startup_auth: "启动与授权",
audit_export: "审计日志", faq: "FAQ", shared_library: "共享库",
attachments: "附件管理", dashboard: "仪表板", subject_histories: "参与者历史",
};
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
const getOpVerb = (op: { operation_key: string; action: string }): string => {
const suffix = op.operation_key.split(":").pop() || "";
if (suffix === "create") return "create";
if (suffix === "read" || suffix === "list") return "read";
if (suffix === "update") return "update";
if (suffix === "delete") return "delete";
if (suffix === "export") return "export";
return op.action;
};
const opActionLabel = (op: { operation_key: string; action: string }) => {
const verb = getOpVerb(op);
return ({ create: "创建", read: "读取", update: "更新", delete: "删除", export: "导出", write: "写入" }[verb] ?? verb);
};
const opTagType = (op: { operation_key: string; action: string }) => {
const verb = getOpVerb(op);
return (({ create: "success", read: "info", update: "warning", delete: "danger", export: "", write: "success" } as Record<string, string>)[verb] ?? "info");
};
const roleEditorGrouped = computed(() => {
const search = roleEditorSearch.value.toLowerCase();
const filtered = allOperations.value.filter((op) =>
!search ||
op.description.toLowerCase().includes(search) ||
op.operation_key.toLowerCase().includes(search) ||
roleEditorModuleLabel(op.module).includes(search)
);
const map: Record<string, RoleEditorOp[]> = {};
for (const op of filtered) {
if (!map[op.module]) map[op.module] = [];
map[op.module].push(op);
}
return map;
});
const selectRoleForEdit = (role: string) => {
editingRole.value = role;
roleEditorSearch.value = "";
initRoleEditor();
};
const openRoleEditor = async (role: string) => {
templateDrawerTab.value = "edit-role";
templateDrawerVisible.value = true;
await ensureOperationsLoaded();
selectRoleForEdit(role);
};
const ensureOperationsLoaded = async () => {
if (allOperations.value.length) return;
roleEditorLoading.value = true;
try {
const res = await fetchApiOperations();
allOperations.value = res.data.operations;
} catch {
ElMessage.error("加载权限操作失败");
} finally {
roleEditorLoading.value = false;
}
};
const initRoleEditor = () => {
const role = editingRole.value;
const draft: Record<string, boolean> = {};
if (apiMatrix.value && apiMatrix.value[role]) {
for (const [key, val] of Object.entries(apiMatrix.value[role] as Record<string, any>)) {
draft[key] = typeof val === "boolean" ? val : val.allowed;
}
}
roleEditorDraft.value = draft;
};
const roleEditorSelectAll = (val: boolean) => {
const ops = Object.values(roleEditorGrouped.value).flat();
for (const op of ops) roleEditorDraft.value[op.operation_key] = val;
};
const saveRoleEditor = async () => {
if (!selectedStudyId.value || !apiMatrix.value) 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 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} 权限已保存`);
} catch {
ElMessage.error("保存失败");
} finally {
roleEditorSaving.value = false;
}
};
// ═══════════════════════════════════════════
// 懒加载 & 初始化
// ═══════════════════════════════════════════
watch(() => route.path, (path) => {
if (path.endsWith("/system") && !systemPermissions.value.length) loadSystemPermissions();
if (path.endsWith("/monitoring") && !metrics.value) loadMonitoringData();
});
onMounted(async () => {
await loadStudies();
await loadTemplates();
const qProjectId = route.query.projectId as string | undefined;
const qSub = route.query.sub as string | undefined;
if (qProjectId) {
selectedStudyId.value = qProjectId;
if (qSub === "members") projectSubTab.value = "members";
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
loadCandidates();
}
const tab = tabFromPath();
if (tab === "system" && !systemPermissions.value.length) loadSystemPermissions();
if (tab === "monitoring" && !metrics.value) loadMonitoringData();
});
</script>
<style scoped>
/* ── 页面容器:铺满,无 padding ── */
.perm-page {
display: flex;
flex-direction: column;
margin: -6px -8px;
min-height: calc(100vh - 52px);
min-height: calc(100dvh - 52px);
background: #f5f7fa;
}
/* ── 顶部标题栏 ── */
.perm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px 16px;
background: #fff;
border-bottom: 1px solid #e8edf3;
flex-shrink: 0;
gap: 16px;
}
.perm-header-left {
display: flex;
flex-direction: column;
gap: 3px;
}
.perm-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1f2e;
line-height: 1.3;
}
.perm-subtitle {
font-size: 13px;
color: #8c96a8;
}
.perm-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
/* ── 内容区:可滚动 ── */
.perm-body {
flex: 1;
overflow-y: auto;
padding: 20px 0 20px;
}
.perm-empty {
padding: 80px 28px;
display: flex;
justify-content: center;
}
.perm-body-padded {
padding: 0 28px;
}
/* ── 项目权限:子 tabs ── */
.project-sub-tabs {
background: #fff;
border-radius: 0;
padding: 0 28px 24px;
}
:deep(.project-sub-tabs .el-tabs__header) {
margin-bottom: 16px;
}
.sub-tab-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.sub-tab-spacer { flex: 1; }
.full-table { width: 100%; }
/* ── 系统级权限:模块块 ── */
.system-module-block {
background: #fff;
border-radius: 0;
padding: 20px 28px;
margin-bottom: 12px;
border-bottom: 1px solid #eef0f4;
}
.system-module-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.system-module-title {
font-size: 15px;
font-weight: 600;
color: #1a1f2e;
}
.system-perm-table { width: 100%; }
.perm-key {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
background: #f0f4f8;
padding: 2px 8px;
border-radius: 4px;
color: #4a5568;
}
/* ── 抽屉内部 ── */
.filter-spacer { flex: 1; }
.drawer-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.drawer-tabs { padding-top: 4px; }
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid #edf2f8;
}
/* ── 生效角色列表 ── */
.active-roles-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.active-role-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid #edf2f8;
background: #fff;
}
.active-role-row:hover { background: #f5f7fa; }
.active-role-info {
display: flex;
align-items: center;
gap: 12px;
}
.active-role-icon { font-size: 20px; }
.active-role-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.active-role-desc {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.template-name { font-weight: 500; }
/* ── 角色编辑器 ── */
.role-editor-layout {
display: flex;
height: calc(100vh - 200px);
gap: 0;
}
.role-editor-sidebar {
width: 160px;
flex-shrink: 0;
border-right: 1px solid #edf2f8;
overflow-y: auto;
padding: 4px 0;
}
.role-sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer;
border-radius: 6px;
margin: 2px 4px;
transition: background 0.15s;
}
.role-sidebar-item:hover { background: #f5f7fa; }
.role-sidebar-item.active {
background: #ecf5ff;
color: #409eff;
}
.role-sidebar-icon { font-size: 18px; flex-shrink: 0; }
.role-sidebar-info { flex: 1; min-width: 0; }
.role-sidebar-name {
font-size: 13px;
font-weight: 500;
color: #303133;
}
.role-sidebar-item.active .role-sidebar-name { color: #409eff; }
.role-sidebar-count {
font-size: 11px;
color: #67c23a;
margin-top: 2px;
}
.role-sidebar-arrow {
font-size: 12px;
color: #409eff;
flex-shrink: 0;
}
.role-editor-content {
flex: 1;
overflow-y: auto;
padding: 0 16px;
display: flex;
flex-direction: column;
}
.role-editor-modules { flex: 1; }
.role-editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.role-editor-module { margin-bottom: 20px; }
.role-editor-module-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #edf2f8;
}
.role-editor-ops {
display: flex;
flex-direction: column;
gap: 6px;
}
.role-editor-op-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.op-action-tag {
flex-shrink: 0;
width: 42px;
text-align: center;
}
.op-desc {
font-size: 13px;
color: #303133;
}
</style>