优化文档管理与导航体验
1、为文档增加编辑接口和前端编辑抽屉,文档列表与详情页统一按创建、更新、删除权限展示操作。 2、将文档版本上传和分发改为抽屉交互,并在详情页补充版本删除权限检查。 3、增强全局面包屑上下文和详情页标题同步,避免详情页重复或缺失导航信息。 4、按项目权限过滤快捷入口、首页费用指标和 eTMF 新建入口,并补充对应前端测试。
This commit is contained in:
@@ -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\"");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user