优化文档管理与导航体验

1、为文档增加编辑接口和前端编辑抽屉,文档列表与详情页统一按创建、更新、删除权限展示操作。

2、将文档版本上传和分发改为抽屉交互,并在详情页补充版本删除权限检查。

3、增强全局面包屑上下文和详情页标题同步,避免详情页重复或缺失导航信息。

4、按项目权限过滤快捷入口、首页费用指标和 eTMF 新建入口,并补充对应前端测试。
This commit is contained in:
Cheng Zhou
2026-06-04 11:11:28 +08:00
parent 6e8494abd5
commit 90ca061db8
21 changed files with 1016 additions and 189 deletions
+22 -3
View File
@@ -120,7 +120,7 @@
<!-- 移入 Header 的面包屑 -->
<div v-if="breadcrumbs.length" class="header-breadcrumb">
<template v-for="(item, index) in breadcrumbs" :key="index">
<template v-for="(item, index) in breadcrumbs" :key="breadcrumbKey(item, index)">
<!-- 带下拉的选择器类型 -->
<el-dropdown v-if="item.hasDropdown" trigger="click" @command="handleBreadcrumbCommand">
<div class="breadcrumb-item-wrapper is-link">
@@ -195,7 +195,7 @@
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<div class="ctms-route-shell">
<component :is="Component" />
<component v-if="Component" :is="Component" />
</div>
</router-view>
</div>
@@ -310,6 +310,12 @@ const breadcrumbs = computed(() => {
label: TEXT.menu.admin,
path: "/admin/users"
});
if (route.path.startsWith("/admin/projects/")) {
items.push({
label: TEXT.menu.projectManagement,
path: "/admin/projects"
});
}
} else if (study.currentStudy) {
// 1. 项目名
items.push({
@@ -343,6 +349,7 @@ const breadcrumbs = computed(() => {
"/startup/training": { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth" },
"/knowledge/medical-consult": { label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult" },
"/knowledge/precautions": { label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
"/documents": { label: TEXT.menu.fileVersionManagement, path: study.currentStudy?.id ? `/trial/${study.currentStudy.id}/documents` : "/file-versions" },
};
let matchedModule = null;
@@ -365,8 +372,9 @@ const breadcrumbs = computed(() => {
}
// 4. 当前页面标题
const hasContextPageTitle = Boolean(study.viewContext?.pageTitle);
const title = route.meta?.title as string | undefined;
if (title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview && title !== (study.viewContext?.siteName || study.currentSite?.name)) {
if (!hasContextPageTitle && title && title !== study.currentStudy?.name && title !== TEXT.menu.projectOverview && title !== (study.viewContext?.siteName || study.currentSite?.name)) {
// 检查 title 是否已经作为模块名添加过了
const isModuleTitle = items.some(item => item.label === title);
if (!isModuleTitle) {
@@ -376,10 +384,21 @@ const breadcrumbs = computed(() => {
});
}
}
if (study.viewContext?.pageTitle) {
items.push({ label: study.viewContext.pageTitle, path: route.path });
}
return items;
});
const breadcrumbKey = (item: any, index: number) => {
const type = item.type || "item";
const path = item.path || "";
const label = item.label || "";
return `${index}:${type}:${path}:${label}`;
};
const handleBreadcrumbCommand = async (cmd: any) => {
if (cmd.type === 'study') {
study.setCurrentStudy(cmd.value);
@@ -0,0 +1,44 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readLayout = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
describe("Layout breadcrumbs", () => {
it("shows the file version module before document detail titles", () => {
const source = readLayout();
expect(source).toContain('"/documents": { label: TEXT.menu.fileVersionManagement');
expect(source).toContain("study.viewContext?.pageTitle");
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
});
it("uses context page titles instead of generic route detail titles", () => {
const source = readLayout();
expect(source).toContain("const hasContextPageTitle = Boolean(study.viewContext?.pageTitle)");
expect(source).toContain("if (!hasContextPageTitle && title");
});
it("keeps project management as the parent for admin project detail breadcrumbs", () => {
const source = readLayout();
expect(source).toContain('if (route.path.startsWith("/admin/projects/"))');
expect(source).toContain("label: TEXT.menu.projectManagement");
expect(source).toContain('path: "/admin/projects"');
});
it("uses stable breadcrumb keys when context titles change", () => {
const source = readLayout();
expect(source).toContain(':key="breadcrumbKey(item, index)"');
expect(source).not.toContain(':key="index"');
expect(source).toContain("const breadcrumbKey = (item: any, index: number)");
});
it("guards route component rendering while router resolves async views", () => {
const source = readLayout();
expect(source).toContain('<component v-if="Component" :is="Component" />');
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readQuickActionsSource = () => readFileSync(resolve(__dirname, "./QuickActions.vue"), "utf8");
describe("QuickActions project permissions", () => {
it("filters quick action entries with the same route permission contract as sidebar and router", () => {
const source = readQuickActionsSource();
expect(source).toContain("useAuthStore");
expect(source).toContain("useStudyStore");
expect(source).toContain("getProjectRoutePermission");
expect(source).toContain("hasProjectPermission");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("visibleActions");
expect(source).toContain("v-for=\"item in visibleActions\"");
expect(source).not.toContain("v-for=\"item in actions\"");
});
});
+22 -3
View File
@@ -5,9 +5,9 @@
<span class="header-title">{{ TEXT.common.labels.quickActions }}</span>
</div>
<div class="actions-grid">
<div
v-for="item in actions"
:key="item.path"
<div
v-for="item in visibleActions"
:key="item.path"
class="action-item"
@click="go(item.path)"
>
@@ -22,14 +22,21 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection
} from "@element-plus/icons-vue";
import { TEXT } from "../locales";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const actions = [
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
@@ -40,6 +47,18 @@ const actions = [
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection },
];
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const visibleActions = computed(() =>
actions.filter((item) =>
hasProjectPermission(
study.currentPermissions,
projectRole.value,
getProjectRoutePermission(item.path),
isSystemAdmin(auth.user),
)
)
);
const go = (path: string) => router.push(path);
</script>