1d26646a96
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
749 lines
28 KiB
TypeScript
749 lines
28 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 DesktopServerSettings from "../views/DesktopServerSettings.vue";
|
|
import DesktopProjectEntry from "../views/DesktopProjectEntry.vue";
|
|
import DesktopSessionRestore from "../views/DesktopSessionRestore.vue";
|
|
import WebWorkbenchEntry from "../views/WebWorkbenchEntry.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 EmailSettings from "../views/admin/EmailSettings.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 DocumentList from "../views/documents/DocumentList.vue";
|
|
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
|
import OfficePreviewWorkspace from "../views/OfficePreviewWorkspace.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 CollaborationLibrary from "../views/knowledge/CollaborationLibrary.vue";
|
|
import CollaborationWorkspace from "../views/knowledge/CollaborationWorkspace.vue";
|
|
import CollaborationShareWorkspace from "../views/knowledge/CollaborationShareWorkspace.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";
|
|
import { resolveDesktopRouteRedirect } from "./desktopGuard";
|
|
import { isTauriRuntime } from "../runtime";
|
|
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
|
|
|
|
const SYSTEM_PERMISSION_READ = "system:permissions:read";
|
|
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
|
|
const WEB_WORKBENCH_ENTRY_PATH = "/workbench";
|
|
const DESKTOP_PROJECT_ENTRY_PATH = "/desktop/project-entry";
|
|
|
|
const resolveWorkbenchEntryPath = (isDesktopRuntime: boolean) =>
|
|
isDesktopRuntime ? DESKTOP_PROJECT_ENTRY_PATH : WEB_WORKBENCH_ENTRY_PATH;
|
|
|
|
const isWorkbenchEntryPath = (path: string) =>
|
|
path === WEB_WORKBENCH_ENTRY_PATH || path === DESKTOP_PROJECT_ENTRY_PATH;
|
|
|
|
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: "/collaboration/share",
|
|
name: "CollaborationPublicShare",
|
|
component: CollaborationShareWorkspace,
|
|
meta: { public: true, title: "共享协作文件" },
|
|
},
|
|
{
|
|
path: "/desktop/server-settings",
|
|
name: "DesktopServerSettings",
|
|
component: DesktopServerSettings,
|
|
meta: { public: true, title: "服务器设置" },
|
|
},
|
|
{
|
|
path: DESKTOP_SESSION_RESTORE_PATH,
|
|
name: "DesktopSessionRestore",
|
|
component: DesktopSessionRestore,
|
|
meta: { public: true, title: "恢复登录状态" },
|
|
},
|
|
{
|
|
path: WEB_WORKBENCH_ENTRY_PATH,
|
|
name: "WorkbenchEntry",
|
|
component: WebWorkbenchEntry,
|
|
meta: { title: "工作台入口" },
|
|
},
|
|
{
|
|
path: DESKTOP_PROJECT_ENTRY_PATH,
|
|
name: "DesktopProjectEntry",
|
|
component: DesktopProjectEntry,
|
|
meta: { title: "选择工作入口" },
|
|
},
|
|
{
|
|
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: DocumentList,
|
|
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: "office-preview/attachment/:id",
|
|
name: "OfficeAttachmentPreview",
|
|
component: OfficePreviewWorkspace,
|
|
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
|
|
},
|
|
{
|
|
path: "office-preview/version/:id",
|
|
name: "OfficeVersionPreview",
|
|
component: OfficePreviewWorkspace,
|
|
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
|
|
},
|
|
{
|
|
path: "office-preview/collaboration/:fileId/revision/:id",
|
|
name: "OfficeCollaborationRevisionPreview",
|
|
component: OfficePreviewWorkspace,
|
|
meta: { title: "协作历史版本预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: 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: "knowledge/collaboration",
|
|
name: "KnowledgeCollaboration",
|
|
component: CollaborationLibrary,
|
|
meta: { title: TEXT.menu.knowledgeCollaboration, requiresStudy: true, fullBleed: true },
|
|
},
|
|
{
|
|
path: "knowledge/collaboration/:fileId",
|
|
name: "KnowledgeCollaborationWorkspace",
|
|
component: CollaborationWorkspace,
|
|
meta: {
|
|
title: TEXT.menu.knowledgeCollaboration,
|
|
requiresStudy: true,
|
|
fullBleed: true,
|
|
immersiveWorkspace: true,
|
|
transientWorkspaceTask: true,
|
|
workspaceTaskGroup: TEXT.menu.knowledgeCollaboration,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
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: "email-settings",
|
|
name: "AdminEmailSettings",
|
|
component: EmailSettings,
|
|
meta: { title: "邮件服务", 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 isDesktopRuntime = isTauriRuntime();
|
|
const desktopRedirect = resolveDesktopRouteRedirect(to);
|
|
if (desktopRedirect) {
|
|
next({ path: desktopRedirect });
|
|
return;
|
|
}
|
|
if (isDesktopRuntime && to.path === WEB_WORKBENCH_ENTRY_PATH) {
|
|
next({ path: DESKTOP_PROJECT_ENTRY_PATH });
|
|
return;
|
|
}
|
|
if (!isDesktopRuntime && to.path === DESKTOP_PROJECT_ENTRY_PATH) {
|
|
next({ path: WEB_WORKBENCH_ENTRY_PATH });
|
|
return;
|
|
}
|
|
|
|
const auth = useAuthStore();
|
|
const studyStore = useStudyStore();
|
|
const getToken = () => auth.token;
|
|
let token = getToken();
|
|
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
|
|
if (!to.meta.public && !auth.user && getToken() && !isDesktopSessionRestoreRoute) {
|
|
try {
|
|
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
|
|
} catch (error) {
|
|
if (isDesktopRuntime && shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
|
|
next({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: to.fullPath } });
|
|
return;
|
|
}
|
|
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
|
await auth.logout();
|
|
next({ path: "/login" });
|
|
return;
|
|
}
|
|
}
|
|
token = getToken();
|
|
const isAdmin = isSystemAdmin(auth.user);
|
|
if (!to.meta.public && !token) {
|
|
next({ path: "/login" });
|
|
return;
|
|
}
|
|
const workbenchEntryPath = resolveWorkbenchEntryPath(isDesktopRuntime);
|
|
if (token && studyStore.currentStudy && auth.user?.email) {
|
|
studyStore.rememberCurrentStudyForUser(auth.user.email);
|
|
}
|
|
|
|
if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy) {
|
|
studyStore.clearCurrentStudy();
|
|
}
|
|
|
|
if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin")) {
|
|
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
|
|
if (!hasPmProject) await studyStore.ensureDefaultPmStudy().catch(() => {});
|
|
const canAccessProjectManagement = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
|
|
if (!canAccessProjectManagement) {
|
|
next({ path: DESKTOP_PROJECT_ENTRY_PATH });
|
|
return;
|
|
}
|
|
if (!studyStore.currentPermissions) await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
|
}
|
|
|
|
if (isDesktopRuntime && token && isAdmin && to.path.startsWith("/admin") && studyStore.currentStudy) {
|
|
studyStore.clearCurrentStudy();
|
|
}
|
|
|
|
if (isDesktopRuntime && token && to.path === "/") {
|
|
next({ path: studyStore.currentStudy ? "/project/overview" : DESKTOP_PROJECT_ENTRY_PATH });
|
|
return;
|
|
}
|
|
if (token && to.path === "/") {
|
|
next({ path: studyStore.currentStudy ? "/project/overview" : workbenchEntryPath });
|
|
return;
|
|
}
|
|
if ((to.path === "/login" || to.path === "/register") && token) {
|
|
if (!auth.forceLogin) {
|
|
next({ path: workbenchEntryPath });
|
|
return;
|
|
}
|
|
}
|
|
if (to.name === "FileVersionManagement" && studyStore.currentStudy?.id) {
|
|
next({ name: "DocumentList", params: { trialId: studyStore.currentStudy.id }, replace: true });
|
|
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: workbenchEntryPath });
|
|
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;
|