release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { buildAdminNavigationItems, buildProjectNavigationItems } from "./navigation";
|
||||
|
||||
const labels = (items: ReturnType<typeof buildAdminNavigationItems>) => items.map((item) => item.label);
|
||||
const webLayoutSource = readFileSync(resolve(__dirname, "../WebLayout.vue"), "utf8");
|
||||
|
||||
describe("system management navigation", () => {
|
||||
it("exposes project management, audit logs, and permission management to PMs", () => {
|
||||
const items = buildAdminNavigationItems({
|
||||
hasUser: true,
|
||||
isAdmin: false,
|
||||
canAccessAdminPermissions: true,
|
||||
canAccessAuditLogs: true,
|
||||
});
|
||||
|
||||
expect(labels(items)).toEqual(["项目管理", "审计日志", "权限管理"]);
|
||||
});
|
||||
|
||||
it("does not expose PM system modules to a non-PM account", () => {
|
||||
const items = buildAdminNavigationItems({
|
||||
hasUser: true,
|
||||
isAdmin: false,
|
||||
canAccessAdminPermissions: false,
|
||||
canAccessAuditLogs: false,
|
||||
});
|
||||
|
||||
expect(labels(items)).toEqual(["项目管理"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project shared-library navigation", () => {
|
||||
it("registers online collaboration as an independent shared-library entry", () => {
|
||||
const groups = buildProjectNavigationItems({
|
||||
hasCurrentStudy: true,
|
||||
hasAnyProjectModuleAccess: true,
|
||||
canAccessProjectPath: () => true,
|
||||
});
|
||||
const entries = groups.flatMap((group) => group.children || []);
|
||||
|
||||
expect(entries.some((item) => item.path === "/knowledge/collaboration" && item.label === "在线协作")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the web sidebar, command navigation, and breadcrumb tree in sync", () => {
|
||||
expect(webLayoutSource).toContain('index="/knowledge/collaboration"');
|
||||
expect(webLayoutSource.match(/path: "\/knowledge\/collaboration"/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(webLayoutSource.match(/TEXT\.menu\.knowledgeCollaboration/g)?.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ export const getActiveLayoutPath = (path: string) => {
|
||||
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
|
||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||||
if (/^\/knowledge\/collaboration\/[^/]+$/.test(path)) return path;
|
||||
if (path.startsWith("/knowledge/collaboration")) return "/knowledge/collaboration";
|
||||
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||||
if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring";
|
||||
@@ -58,6 +60,7 @@ export const buildAdminNavigationItems = (options: {
|
||||
hasUser: boolean;
|
||||
isAdmin: boolean;
|
||||
canAccessAdminPermissions: boolean;
|
||||
canAccessAuditLogs: boolean;
|
||||
}): LayoutNavigationItem[] => {
|
||||
if (!options.hasUser) return [];
|
||||
|
||||
@@ -78,15 +81,17 @@ export const buildAdminNavigationItems = (options: {
|
||||
icon: "project",
|
||||
keywords: ["project"],
|
||||
});
|
||||
if (options.isAdmin || options.canAccessAuditLogs) {
|
||||
items.push({
|
||||
label: TEXT.menu.auditLogs,
|
||||
path: "/admin/audit-logs",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "audit",
|
||||
keywords: ["audit"],
|
||||
});
|
||||
}
|
||||
if (options.isAdmin) {
|
||||
items.push(
|
||||
{
|
||||
label: TEXT.menu.auditLogs,
|
||||
path: "/admin/audit-logs",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "audit",
|
||||
keywords: ["audit"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.systemMonitoring,
|
||||
path: "/admin/system-monitoring",
|
||||
@@ -95,11 +100,20 @@ export const buildAdminNavigationItems = (options: {
|
||||
keywords: ["monitoring"],
|
||||
},
|
||||
{
|
||||
label: "邮件服务",
|
||||
label: "系统设置",
|
||||
path: "/admin/email-settings",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "settings",
|
||||
keywords: ["email"],
|
||||
keywords: ["settings"],
|
||||
children: [
|
||||
{
|
||||
label: "邮件服务",
|
||||
path: "/admin/email-settings",
|
||||
group: "系统设置",
|
||||
icon: "settings",
|
||||
keywords: ["email", "settings"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -119,7 +133,7 @@ export const buildAdminNavigationItems = (options: {
|
||||
keywords: ["permission", "system"],
|
||||
},
|
||||
{
|
||||
label: "项目权限配置",
|
||||
label: "项目访问控制",
|
||||
path: "/admin/permissions/project",
|
||||
group: TEXT.menu.permissionManagement,
|
||||
icon: "key",
|
||||
@@ -280,6 +294,13 @@ export const buildProjectNavigationItems = (options: {
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "instruction"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeCollaboration,
|
||||
path: "/knowledge/collaboration",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "document",
|
||||
keywords: ["knowledge", "collaboration", "onlyoffice", "协作"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -295,4 +316,4 @@ export const buildProjectNavigationItems = (options: {
|
||||
};
|
||||
|
||||
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
|
||||
items.flatMap((item) => [item, ...(item.children || [])]);
|
||||
items.flatMap((item) => item.children?.length ? item.children : [item]);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canMoveWorkspaceTabHistory,
|
||||
createWorkspaceTabHistory,
|
||||
currentWorkspaceTabPath,
|
||||
moveWorkspaceTabHistory,
|
||||
recordWorkspaceTabPath,
|
||||
replaceWorkspaceTabPath,
|
||||
} from "./workspaceTabHistory";
|
||||
|
||||
describe("workspace tab history", () => {
|
||||
it("records full paths without duplicating the current entry", () => {
|
||||
const initial = createWorkspaceTabHistory("/subjects");
|
||||
const detail = recordWorkspaceTabPath(initial, "/subjects/subject-1?tab=ae");
|
||||
|
||||
expect(detail).toEqual({ entries: ["/subjects", "/subjects/subject-1?tab=ae"], index: 1 });
|
||||
expect(recordWorkspaceTabPath(detail, "/subjects/subject-1?tab=ae")).toBe(detail);
|
||||
});
|
||||
|
||||
it("moves backward and forward without adding entries", () => {
|
||||
const detail = recordWorkspaceTabPath(createWorkspaceTabHistory("/subjects"), "/subjects/subject-1");
|
||||
const backward = moveWorkspaceTabHistory(detail, -1);
|
||||
const forward = moveWorkspaceTabHistory(backward?.history, 1);
|
||||
|
||||
expect(backward?.path).toBe("/subjects");
|
||||
expect(forward?.path).toBe("/subjects/subject-1");
|
||||
expect(canMoveWorkspaceTabHistory(backward?.history, -1)).toBe(false);
|
||||
expect(canMoveWorkspaceTabHistory(backward?.history, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops forward entries after a new navigation branch", () => {
|
||||
const detail = recordWorkspaceTabPath(createWorkspaceTabHistory("/subjects"), "/subjects/subject-1");
|
||||
const backward = moveWorkspaceTabHistory(detail, -1);
|
||||
const branch = recordWorkspaceTabPath(backward?.history, "/subjects/new");
|
||||
|
||||
expect(branch).toEqual({ entries: ["/subjects", "/subjects/new"], index: 1 });
|
||||
expect(canMoveWorkspaceTabHistory(branch, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces canonical redirects without creating a backward loop", () => {
|
||||
const canonical = replaceWorkspaceTabPath(
|
||||
createWorkspaceTabHistory("/file-versions"),
|
||||
"/trial/study-1/documents",
|
||||
);
|
||||
|
||||
expect(canonical).toEqual({ entries: ["/trial/study-1/documents"], index: 0 });
|
||||
expect(canMoveWorkspaceTabHistory(canonical, -1)).toBe(false);
|
||||
expect(currentWorkspaceTabPath(canonical, "/file-versions")).toBe("/trial/study-1/documents");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export type WorkspaceTabHistory = {
|
||||
entries: string[];
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type WorkspaceTabHistoryMove = {
|
||||
history: WorkspaceTabHistory;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const WORKSPACE_TAB_HISTORY_MAX_ENTRIES = 50;
|
||||
|
||||
export const createWorkspaceTabHistory = (path: string): WorkspaceTabHistory => ({ entries: [path], index: 0 });
|
||||
|
||||
export const recordWorkspaceTabPath = (
|
||||
history: WorkspaceTabHistory | undefined,
|
||||
path: string,
|
||||
): WorkspaceTabHistory => {
|
||||
if (!history?.entries.length) return createWorkspaceTabHistory(path);
|
||||
if (history.entries[history.index] === path) return history;
|
||||
|
||||
const entries = [...history.entries.slice(0, history.index + 1), path].slice(-WORKSPACE_TAB_HISTORY_MAX_ENTRIES);
|
||||
return { entries, index: entries.length - 1 };
|
||||
};
|
||||
|
||||
export const replaceWorkspaceTabPath = (
|
||||
history: WorkspaceTabHistory | undefined,
|
||||
path: string,
|
||||
): WorkspaceTabHistory => {
|
||||
if (!history?.entries.length) return createWorkspaceTabHistory(path);
|
||||
if (history.entries[history.index] === path) return history;
|
||||
const entries = [...history.entries];
|
||||
entries[history.index] = path;
|
||||
return { entries, index: history.index };
|
||||
};
|
||||
|
||||
export const moveWorkspaceTabHistory = (
|
||||
history: WorkspaceTabHistory | undefined,
|
||||
offset: -1 | 1,
|
||||
): WorkspaceTabHistoryMove | null => {
|
||||
if (!history?.entries.length) return null;
|
||||
const index = history.index + offset;
|
||||
if (index < 0 || index >= history.entries.length) return null;
|
||||
return { history: { ...history, index }, path: history.entries[index] };
|
||||
};
|
||||
|
||||
export const currentWorkspaceTabPath = (history: WorkspaceTabHistory | undefined, fallbackPath: string) =>
|
||||
history?.entries[history.index] || fallbackPath;
|
||||
|
||||
export const canMoveWorkspaceTabHistory = (history: WorkspaceTabHistory | undefined, offset: -1 | 1) => {
|
||||
if (!history?.entries.length) return false;
|
||||
const index = history.index + offset;
|
||||
return index >= 0 && index < history.entries.length;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTabTitle,
|
||||
buildWorkspaceToolbarTitle,
|
||||
resolveWorkspaceTabContextTitle,
|
||||
} from "./workspaceTabTitle";
|
||||
|
||||
describe("workspace tab title", () => {
|
||||
it("keeps the module title when no detail context is available", () => {
|
||||
expect(buildWorkspaceTabTitle({ moduleTitle: "参与者管理", routeTitle: "参与者详情" })).toBe("参与者管理");
|
||||
});
|
||||
|
||||
it("combines the detail object type with its loaded identifier", () => {
|
||||
expect(
|
||||
buildWorkspaceTabTitle({
|
||||
moduleTitle: "参与者管理",
|
||||
routeTitle: "参与者详情",
|
||||
contextTitle: "SUBJ-001",
|
||||
}),
|
||||
).toBe("参与者 · SUBJ-001");
|
||||
});
|
||||
|
||||
it("uses a non-detail route title as the context prefix", () => {
|
||||
expect(
|
||||
buildWorkspaceTabTitle({
|
||||
moduleTitle: "文件版本管理",
|
||||
routeTitle: "文件版本管理",
|
||||
contextTitle: "知情同意书",
|
||||
}),
|
||||
).toBe("文件版本管理 · 知情同意书");
|
||||
});
|
||||
|
||||
it("avoids duplicate fallback titles", () => {
|
||||
expect(
|
||||
buildWorkspaceTabTitle({
|
||||
moduleTitle: "设备管理",
|
||||
routeTitle: "设备详情",
|
||||
contextTitle: "设备详情",
|
||||
}),
|
||||
).toBe("设备详情");
|
||||
});
|
||||
|
||||
it("keeps a loaded context when the same detail route temporarily clears its live context", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabContextTitle({
|
||||
routePath: "/subjects/subject-1",
|
||||
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
|
||||
}),
|
||||
).toBe("SUBJ-001");
|
||||
});
|
||||
|
||||
it("does not leak a stored title into another detail route", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabContextTitle({
|
||||
routePath: "/subjects/subject-2",
|
||||
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the business object type in the desktop toolbar", () => {
|
||||
expect(
|
||||
buildWorkspaceToolbarTitle({
|
||||
moduleTitle: "文件版本管理",
|
||||
routeTitle: "文件版本管理",
|
||||
contextTitle: "11",
|
||||
contextObjectType: "SOP",
|
||||
}),
|
||||
).toBe("SOP · 11");
|
||||
});
|
||||
|
||||
it("derives a concise object label from a detail route", () => {
|
||||
expect(
|
||||
buildWorkspaceToolbarTitle({
|
||||
moduleTitle: "参与者管理",
|
||||
routeTitle: "参与者详情",
|
||||
contextTitle: "S01",
|
||||
}),
|
||||
).toBe("参与者 · S01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
interface WorkspaceTabTitleInput {
|
||||
moduleTitle: string;
|
||||
routeTitle?: string;
|
||||
contextTitle?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabContext {
|
||||
routePath: string;
|
||||
pageTitle: string;
|
||||
objectType?: string;
|
||||
}
|
||||
|
||||
interface WorkspaceTabContextTitleInput {
|
||||
liveContextTitle?: string;
|
||||
storedContext?: WorkspaceTabContext;
|
||||
routePath: string;
|
||||
}
|
||||
|
||||
export const resolveWorkspaceTabContextTitle = ({
|
||||
liveContextTitle,
|
||||
storedContext,
|
||||
routePath,
|
||||
}: WorkspaceTabContextTitleInput) =>
|
||||
liveContextTitle || (storedContext?.routePath === routePath ? storedContext.pageTitle : undefined);
|
||||
|
||||
interface WorkspaceToolbarTitleInput extends WorkspaceTabTitleInput {
|
||||
contextObjectType?: string;
|
||||
}
|
||||
|
||||
export const buildWorkspaceToolbarTitle = ({
|
||||
moduleTitle,
|
||||
routeTitle,
|
||||
contextTitle,
|
||||
contextObjectType,
|
||||
}: WorkspaceToolbarTitleInput) => {
|
||||
const contextLabel = contextTitle?.trim();
|
||||
if (!contextLabel) return moduleTitle.trim();
|
||||
const objectLabel = contextObjectType?.trim() || routeTitle?.replace(/详情$/, "").trim() || moduleTitle.trim();
|
||||
if (!objectLabel || contextLabel === objectLabel) return contextLabel;
|
||||
return `${objectLabel} · ${contextLabel}`;
|
||||
};
|
||||
|
||||
export const buildWorkspaceTabTitle = ({ moduleTitle, routeTitle, contextTitle }: WorkspaceTabTitleInput) => {
|
||||
const moduleLabel = moduleTitle.trim();
|
||||
const routeLabel = routeTitle?.trim() || moduleLabel;
|
||||
const contextLabel = contextTitle?.trim();
|
||||
if (!contextLabel || contextLabel === moduleLabel) return moduleLabel;
|
||||
if (contextLabel === routeLabel) return routeLabel;
|
||||
|
||||
const objectLabel = routeLabel.replace(/详情$/, "").trim() || moduleLabel;
|
||||
if (contextLabel === objectLabel) return routeLabel;
|
||||
return `${objectLabel} · ${contextLabel}`;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { InjectionKey } from "vue";
|
||||
|
||||
export interface WorkspaceTaskController {
|
||||
closeTransientTask(path: string): boolean;
|
||||
}
|
||||
|
||||
export const workspaceTaskControllerKey: InjectionKey<WorkspaceTaskController> = Symbol("workspaceTaskController");
|
||||
Reference in New Issue
Block a user