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

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
@@ -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);
});
});
+32 -11
View File
@@ -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");