Files
ctms/frontend/src/router/index.ts
T
2026-06-29 16:25:04 +08:00

623 lines
23 KiB
TypeScript

import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
import { fetchStudies, fetchStudyDetail } from "../api/studies";
import { fetchApiEndpointPermissions } from "../api/projectPermissions";
import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue";
import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue";
import AdminProjects from "../views/admin/Projects.vue";
import AdminSites from "../views/admin/Sites.vue";
import PermissionManagement from "../views/admin/PermissionManagement.vue";
import SystemMonitoringPage from "../views/admin/SystemMonitoringPage.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue";
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
import FeeContracts from "../views/fees/ContractFees.vue";
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
import DrugShipments from "../views/ia/DrugShipments.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import DocumentList from "../views/documents/DocumentList.vue";
import DocumentDetail from "../views/documents/DocumentDetail.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
import SubjectManagement from "../views/ia/SubjectManagement.vue";
import RiskIssueSae from "../views/ia/RiskIssueSae.vue";
import RiskIssuePd from "../views/ia/RiskIssuePd.vue";
import RiskIssueMonitoringVisits from "../views/ia/RiskIssueMonitoringVisits.vue";
import EtmfPlaceholder from "../views/ia/EtmfPlaceholder.vue";
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import Precautions from "../views/ia/Precautions.vue";
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
import EthicsForm from "../views/startup/EthicsForm.vue";
import EthicsDetail from "../views/startup/EthicsDetail.vue";
import KickoffForm from "../views/startup/KickoffForm.vue";
import KickoffDetail from "../views/startup/KickoffDetail.vue";
import TrainingDetail from "../views/startup/TrainingDetail.vue";
import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales";
const SYSTEM_PERMISSION_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
const routes: RouteRecordRaw[] = [
{
path: "/login",
name: "Login",
component: Login,
meta: { public: true, title: TEXT.common.actions.login },
},
{
path: "/register",
name: "Register",
component: Register,
meta: { public: true, title: TEXT.common.actions.register },
},
{
path: "/forgot-password",
name: "ForgotPassword",
component: ForgotPassword,
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
},
{
path: "/",
component: Layout,
redirect: "/project/overview",
children: [
{
path: "profile",
name: "ProfileSettings",
component: ProfileSettings,
meta: { title: TEXT.menu.profile },
},
{
path: "project/overview",
name: "ProjectOverview",
component: ProjectOverview,
meta: { title: TEXT.menu.projectOverview, requiresStudy: true },
},
{
path: "project/milestones",
name: "ProjectMilestones",
component: ProjectMilestones,
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
},
{
path: "fees/contracts",
name: "FeeContracts",
component: FeeContracts,
meta: { title: TEXT.menu.feeContracts, requiresStudy: true },
},
{
path: "fees/contracts/:contractId",
name: "FeeContractDetail",
component: FeeContractDetail,
meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true },
},
{
path: "drug/shipments",
name: "DrugShipments",
component: DrugShipments,
meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
},
{
path: "drug/shipments/:shipmentId",
name: "DrugShipmentDetail",
component: ShipmentDetail,
meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
},
{
path: "materials/equipment",
name: "MaterialEquipment",
component: MaterialEquipment,
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
},
{
path: "materials/equipment/:equipmentId",
name: "MaterialEquipmentDetail",
component: MaterialEquipmentDetail,
meta: { title: TEXT.modules.materialEquipment.detailTitle, requiresStudy: true },
},
{
path: "file-versions",
name: "FileVersionManagement",
component: FileVersionManagement,
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
},
{
path: "trial/:trialId/documents",
name: "DocumentList",
component: DocumentList,
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
},
{
path: "documents/:id",
name: "DocumentDetail",
component: DocumentDetail,
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
},
{
path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics",
component: StartupFeasibilityEthics,
meta: { title: TEXT.menu.startupFeasibilityEthics, requiresStudy: true },
},
{
path: "startup/feasibility/new",
name: "StartupFeasibilityNew",
component: FeasibilityForm,
meta: { title: TEXT.common.actions.add + TEXT.modules.startupFeasibilityEthics.feasibilityTab, requiresStudy: true },
},
{
path: "startup/feasibility/:recordId",
name: "StartupFeasibilityDetail",
component: FeasibilityDetail,
meta: { title: TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle, requiresStudy: true },
},
{
path: "startup/feasibility/:recordId/edit",
name: "StartupFeasibilityEdit",
component: FeasibilityForm,
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupFeasibilityEthics.feasibilityTab, requiresStudy: true },
},
{
path: "startup/ethics/new",
name: "StartupEthicsNew",
component: EthicsForm,
meta: { title: TEXT.common.actions.add + TEXT.modules.startupFeasibilityEthics.ethicsTab, requiresStudy: true },
},
{
path: "startup/ethics/:recordId",
name: "StartupEthicsDetail",
component: EthicsDetail,
meta: { title: TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle, requiresStudy: true },
},
{
path: "startup/ethics/:recordId/edit",
name: "StartupEthicsEdit",
component: EthicsForm,
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupFeasibilityEthics.ethicsTab, requiresStudy: true },
},
{
path: "startup/meeting-auth",
name: "StartupMeetingAuth",
component: StartupMeetingAuth,
meta: { title: TEXT.menu.startupMeetingAuth, requiresStudy: true },
},
{
path: "startup/kickoff/new",
name: "StartupKickoffNew",
component: KickoffForm,
meta: { title: TEXT.common.actions.add + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true },
},
{
path: "startup/kickoff/:meetingId",
name: "StartupKickoffDetail",
component: KickoffDetail,
meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true },
},
{
path: "startup/training/:recordId",
name: "StartupTrainingDetail",
component: TrainingDetail,
meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true },
},
{
path: "subjects",
name: "SubjectManagement",
component: SubjectManagement,
meta: { title: TEXT.menu.subjects, requiresStudy: true },
},
{
path: "subjects/new",
name: "SubjectNew",
component: SubjectForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.subjects, requiresStudy: true },
},
{
path: "subjects/:subjectId",
name: "SubjectDetail",
component: SubjectDetail,
meta: { title: TEXT.modules.subjectDetail.title, requiresStudy: true },
},
{
path: "subjects/:subjectId/edit",
name: "SubjectEdit",
component: SubjectForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.subjects, requiresStudy: true },
},
{
path: "risk-issues",
redirect: "/risk-issues/sae",
},
{
path: "risk-issues/sae",
name: "RiskIssueSae",
component: RiskIssueSae,
meta: { title: TEXT.menu.riskIssueSae, requiresStudy: true },
},
{
path: "risk-issues/pd",
name: "RiskIssuePd",
component: RiskIssuePd,
meta: { title: TEXT.menu.riskIssuePd, requiresStudy: true },
},
{
path: "risk-issues/monitoring-visits",
name: "RiskIssueMonitoringVisits",
component: RiskIssueMonitoringVisits,
meta: { title: TEXT.menu.riskIssueMonitoringVisits, requiresStudy: true },
},
{
path: "etmf",
name: "EtmfPlaceholder",
component: EtmfPlaceholder,
meta: { title: TEXT.menu.etmf, requiresStudy: true },
},
{
path: "knowledge/medical-consult",
name: "KnowledgeMedicalConsult",
component: KnowledgeMedicalConsult,
meta: { title: TEXT.menu.knowledgeMedicalConsult, requiresStudy: true },
},
{
path: "knowledge/medical-consult/:itemId",
name: "KnowledgeMedicalConsultDetail",
component: FaqDetail,
meta: { title: TEXT.modules.knowledgeMedicalConsult.detailTitle, requiresStudy: true },
},
{
path: "knowledge/precautions",
name: "Precautions",
component: Precautions,
meta: { title: TEXT.menu.knowledgeNotes, requiresStudy: true },
},
{
path: "knowledge/precautions/new",
name: "PrecautionNew",
component: PrecautionForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.knowledgeNotes, requiresStudy: true },
},
{
path: "knowledge/precautions/:id",
name: "PrecautionDetail",
component: PrecautionDetail,
meta: { title: TEXT.modules.knowledgeNotes.detailTitle, requiresStudy: true },
},
{
path: "knowledge/precautions/:id/edit",
name: "PrecautionEdit",
component: PrecautionForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true },
},
{
path: "knowledge/support-files",
name: "KnowledgeSupportFiles",
component: KnowledgeSupportFiles,
meta: { title: TEXT.menu.knowledgeSupportFiles, requiresStudy: true },
},
{
path: "knowledge/instruction-files",
name: "KnowledgeInstructionFiles",
component: KnowledgeInstructionFiles,
meta: { title: TEXT.menu.knowledgeInstructionFiles, requiresStudy: true },
},
],
},
{
path: "/admin",
component: Layout,
redirect: "/admin/users",
children: [
{
path: "users",
name: "AdminUsers",
component: AdminUsers,
meta: { title: TEXT.menu.accountManagement, requiresAdmin: true },
},
{
path: "projects",
name: "AdminProjects",
component: AdminProjects,
meta: { title: TEXT.menu.projectManagement },
},
{
path: "projects/:projectId",
name: "AdminProjectDetail",
component: ProjectDetail,
meta: { title: TEXT.modules.adminProjects.detailTitle, requiresProjectMember: true, projectPermission: "setup_config:read" },
},
{
path: "projects/:projectId/members",
redirect: (to) => ({ path: "/admin/permissions/project", query: { projectId: to.params.projectId as string, sub: "members" } }),
},
{
path: "projects/:id/api-permissions",
redirect: (to) => ({ path: "/admin/permissions/project", query: { projectId: to.params.id as string } }),
},
{
path: "projects/:projectId/sites",
name: "AdminSites",
component: AdminSites,
meta: { title: TEXT.modules.adminSites.title, adminProjectPermission: { module: "sites", action: "read" } },
},
{
path: "audit-logs",
name: "AdminAuditLogs",
component: AuditLogs,
meta: { title: TEXT.menu.auditLogs, adminProjectPermission: { module: "audit_export", action: "read" } },
},
{
path: "system-monitoring",
name: "AdminSystemMonitoring",
component: SystemMonitoringPage,
meta: { title: TEXT.menu.systemMonitoring, requiresAdmin: true },
},
{
path: "permission-monitoring",
redirect: "/admin/system-monitoring",
},
{
path: "permissions/monitoring",
redirect: "/admin/system-monitoring",
},
{
path: "permissions",
redirect: "/admin/permissions/system",
},
{
path: "permissions/system",
name: "AdminPermissionsSystem",
component: PermissionManagement,
meta: { title: "系统级权限", systemPermission: SYSTEM_PERMISSION_READ },
},
{
path: "permissions/project",
name: "AdminPermissionsProject",
component: PermissionManagement,
meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG },
},
],
},
{
path: "/projects/:projectId",
redirect: (to) => `/admin/projects/${to.params.projectId}`,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
project_members: { read: "project_members:read", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
};
const SYSTEM_PERMISSION_ROLES: Record<string, string[]> = {
[SYSTEM_PERMISSION_READ]: ["ADMIN", "PM"],
[SYSTEM_PERMISSION_PROJECT_CONFIG]: ["ADMIN", "PM"],
};
const getTargetProjectId = (to: any) => {
const queryProjectId = typeof to.query?.projectId === "string" ? to.query.projectId : "";
const paramProjectId = typeof to.params?.projectId === "string" ? to.params.projectId : "";
const paramId = typeof to.params?.id === "string" ? to.params.id : "";
return queryProjectId || paramProjectId || paramId;
};
const hasSystemPermissionAccess = async (permissionKey: string, isAdmin: boolean, to: any) => {
const roles = SYSTEM_PERMISSION_ROLES[permissionKey] || [];
if (isAdmin) return roles.includes("ADMIN");
if (!roles.includes("PM")) return false;
const studyStore = useStudyStore();
const targetProjectId = getTargetProjectId(to);
if (targetProjectId) {
if (studyStore.currentStudy?.id === targetProjectId) {
return studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
}
const { data } = await fetchStudies();
const items = (data as any).items || [];
const matchedProject = items.find((study: any) => study.id === targetProjectId);
return matchedProject?.role_in_study === "PM";
}
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasPmProject) await studyStore.ensureDefaultPmStudy().catch(() => {});
return studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
};
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (isAdmin) return true;
const studyStore = useStudyStore();
const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined;
if (!permission) return true;
const targetProjectId = getTargetProjectId(to);
if (targetProjectId && studyStore.currentStudy?.id !== targetProjectId) {
const [{ data: project }, { data: permissions }] = await Promise.all([
fetchStudyDetail(targetProjectId),
fetchApiEndpointPermissions(targetProjectId),
]);
studyStore.setCurrentStudy(project);
studyStore.currentPermissions = permissions;
} else if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions();
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return false;
const operationKey = ADMIN_PROJECT_OPERATION_KEYS[permission.module]?.[permission.action];
if (!operationKey) return false;
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
};
const ensureRouteProjectMember = async (to: any, studyStore: ReturnType<typeof useStudyStore>) => {
const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : "";
if (!routeProjectId) return false;
const { data: project } = await fetchStudyDetail(routeProjectId);
studyStore.setCurrentStudy(project);
return true;
};
const ensureProjectPermissionAccess = async (
operationKey: string,
isAdmin: boolean,
studyStore: ReturnType<typeof useStudyStore>,
) => {
if (isAdmin) return true;
if (!operationKey || !studyStore.currentStudy) return false;
if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions();
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
};
router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore();
const studyStore = useStudyStore();
const getToken = () => auth.token;
let token = getToken();
if (!auth.user && getToken()) {
try {
await auth.fetchMe();
} catch {
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
auth.logout();
next({ path: "/login" });
return;
}
}
token = getToken();
const isAdmin = isSystemAdmin(auth.user);
if (token && !studyStore.currentStudy) {
if (isAdmin) {
await studyStore.ensureDefaultActiveStudy();
} else {
await studyStore.ensureDefaultStudy();
}
}
if (token && studyStore.currentStudy && auth.user?.email) {
studyStore.rememberCurrentStudyForUser(auth.user.email);
}
if (!to.meta.public && !token) {
next({ path: "/login" });
return;
}
if (isAdmin && to.path === "/") {
next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" });
return;
}
if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) {
next({
path: isAdmin
? studyStore.currentStudy
? "/project/overview"
: "/admin/users"
: studyStore.currentStudy
? "/project/overview"
: "/admin/projects",
});
return;
}
}
if (to.meta.adminProjectPermission) {
const allowed = await ensureAdminProjectAccess(to, isAdmin).catch(() => false);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
}
if (to.meta.requiresAdmin && !isAdmin) {
next({ path: "/admin/projects" });
return;
}
if (to.meta.requiresProjectMember && !isAdmin) {
const allowed = await ensureRouteProjectMember(to, studyStore).catch(() => false);
if (!allowed) {
next({ path: "/admin/projects" });
return;
}
}
if (to.meta.projectPermission) {
const allowed = await ensureProjectPermissionAccess(to.meta.projectPermission as string, isAdmin, studyStore).catch(() => false);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
}
if (to.meta.systemPermission) {
const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin, to);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
}
if (to.meta.requiresProjectPm && !isAdmin) {
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasPmProject) {
await studyStore.ensureDefaultPmStudy().catch(() => {});
}
const hasProjectPmAccess = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasProjectPmAccess) {
next({ path: "/admin/projects" });
return;
}
}
if (to.name === "AdminAuditLogs") {
if (studyStore.currentStudy && !studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const canReadAudit = isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.["audit_logs:read"]);
if (!(isAdmin || (role === "PM" && canReadAudit))) {
next({ path: "/project/overview" });
return;
}
}
if (to.meta.requiresStudy && !studyStore.currentStudy) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
if (to.meta.requiresStudy && studyStore.currentStudy) {
if (!studyStore.currentPermissions) {
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
const projectRole = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const permission = getProjectRoutePermission(to.path);
const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions, projectRole, permission, isAdmin);
if (!canAccessProjectRoute) {
const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions, projectRole, isAdmin);
next({ path: fallback || "/admin/projects" });
return;
}
}
next();
});
export default router;