feat(collaboration): 完善在线文档协作与通知闭环
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 14:14:54 +08:00
parent c68dddfc01
commit 1d26646a96
97 changed files with 11911 additions and 316 deletions
@@ -384,12 +384,13 @@ const SECTION_LABELS: Record<string, string> = {
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
collaboration: "在线协作",
};
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
shared_library: ["医学咨询/FAQ", "注意事项"],
shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"],
};
const getOperationSection = (row: Operation): string => {
+52 -82
View File
@@ -4,10 +4,11 @@
:class="{
'is-sidebar-hidden': !desktopSidebarVisible,
'is-macos': desktopMetadata.platform === 'macos',
'is-immersive-workspace': route.meta.immersiveWorkspace,
}"
@click="closeWorkspaceTabMenu"
>
<aside class="desktop-sidebar">
<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
<div class="sidebar-app-brand">
@@ -122,7 +123,7 @@
</aside>
<section class="desktop-main">
<header class="desktop-toolbar">
<header v-if="!route.meta.immersiveWorkspace" class="desktop-toolbar">
<div class="toolbar-left" data-tauri-drag-region>
<button
class="toolbar-nav-button sidebar-toggle-button"
@@ -254,7 +255,12 @@
</div>
</el-popover>
<el-dropdown trigger="click" placement="bottom-end" @command="handleReminderCommand">
<el-dropdown
trigger="click"
placement="bottom-end"
@command="handleReminderCommand"
@visible-change="handleReminderVisibilityChange"
>
<button class="icon-button reminder-button" type="button" title="项目提醒">
<el-badge v-if="headerReminderTotal > 0" :value="headerReminderBadgeValue" class="reminder-badge">
<el-icon><Bell /></el-icon>
@@ -277,13 +283,14 @@
:key="item.key"
:command="item.command"
class="reminder-item"
:class="{ 'is-read': item.isRead }"
>
<span class="reminder-mark" :class="`is-${item.tone}`"></span>
<span class="reminder-body">
<span>{{ item.title }}</span>
<small>{{ item.description }}</small>
</span>
<strong>{{ item.count }}</strong>
<strong>{{ item.timeLabel }}</strong>
</el-dropdown-item>
</template>
<div v-else class="reminder-empty">
@@ -326,7 +333,7 @@
</div>
</header>
<div v-if="workspaceTabs.length > 1" class="workspace-tabs">
<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">
<div
class="workspace-tabs-list"
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
@@ -532,9 +539,8 @@ import {
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import { useProjectNotifications } from "../composables/useProjectNotifications";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
import {
@@ -657,8 +663,17 @@ const workspaceTabMenu = ref<{
});
const expandedNavigationGroups = ref<Set<string>>(new Set());
const collapsedNavigationGroups = ref<Set<string>>(new Set());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
const {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
} = useProjectNotifications();
let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
let desktopActivityUnlisten: (() => void) | undefined;
@@ -802,8 +817,8 @@ const currentDesktopRoutePreference = computed(() => {
if (!item && route.meta.transientWorkspaceTask) {
return {
path: route.path,
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "文档预览"),
group: "文档预览",
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "工作区任务"),
group: String(route.meta.workspaceTaskGroup || "工作区任务"),
};
}
if (!item) return null;
@@ -888,41 +903,6 @@ const activeWorkspaceTabHistory = computed(() => workspaceTabHistories.value[act
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
const canNavigateWorkspaceTabForward = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
};
const headerReminderItems = computed(() => [
canReadRiskIssueAes.value && headerReminderStats.value.overdueAes > 0 ? {
key: "overdueAes",
command: "overdueAes",
title: TEXT.common.labels.overdueAes,
description: TEXT.common.labels.overdueAesDesc,
count: headerReminderStats.value.overdueAes,
tone: "danger",
} : null,
canReadMonitoringIssues.value && headerReminderStats.value.overdueMonitoringIssues > 0 ? {
key: "overdueMonitoringIssues",
command: "overdueMonitoringIssues",
title: TEXT.common.labels.overdueMonitoringIssues,
description: TEXT.common.labels.overdueMonitoringIssuesDesc,
count: headerReminderStats.value.overdueMonitoringIssues,
tone: "warning",
} : null,
].filter(Boolean) as Array<{
key: string;
command: string;
title: string;
description: string;
count: number;
tone: "danger" | "warning";
}>);
const headerReminderTotal = computed(() => headerReminderItems.value.reduce((sum, item) => sum + item.count, 0));
const headerReminderBadgeValue = computed(() => (headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value));
const refreshDesktopRoutePreferences = () => {
const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path));
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => allowedPaths.has(item.path));
@@ -1328,42 +1308,6 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
];
});
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
headerRemindersLoading.value = true;
try {
const [aesRes, monitoringRes] = await Promise.allSettled([
canReadRiskIssueAes.value ? fetchOverdueAesCount(studyId) : Promise.resolve(null),
canReadMonitoringIssues.value ? listMonitoringVisitIssues(studyId, { overdue: true, limit: 500 }) : Promise.resolve(null),
]);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
headerReminderStats.value = {
overdueAes: aesRes.status === "fulfilled" && aesRes.value ? toHeaderNumber((aesRes.value.data as any)?.total) : 0,
overdueMonitoringIssues: monitoringRes.status === "fulfilled" && monitoringRes.value && Array.isArray(monitoringRes.value.data)
? monitoringRes.value.data.length
: 0,
};
} finally {
if (study.currentStudy?.id === studyId) {
headerRemindersLoading.value = false;
}
}
};
const handleReminderCommand = (cmd: string) => {
if (cmd === "overdueAes" && canReadRiskIssueAes.value) {
router.push("/risk-issues/sae");
return;
}
if (cmd === "overdueMonitoringIssues" && canReadMonitoringIssues.value) {
router.push("/risk-issues/monitoring-visits");
}
};
const logoutImmediately = async () => {
await auth.logout();
study.clearCurrentStudy();
@@ -1521,6 +1465,7 @@ onMounted(async () => {
}
}
loadHeaderReminders();
startHeaderNotificationPolling();
});
onBeforeUnmount(() => {
@@ -1529,6 +1474,7 @@ onBeforeUnmount(() => {
desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
desktopActivityUnlisten?.();
stopHeaderNotificationPolling();
});
useDesktopShortcuts(
@@ -1575,6 +1521,18 @@ useDesktopShortcuts(
grid-template-columns: 0 minmax(0, 1fr);
}
.desktop-workbench.is-immersive-workspace {
grid-template-columns: minmax(0, 1fr);
}
.desktop-workbench.is-immersive-workspace .desktop-main {
grid-template-rows: minmax(0, 1fr);
}
.desktop-workbench.is-immersive-workspace .desktop-content {
grid-row: 1;
}
.desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {
padding-left: 88px;
}
@@ -1823,6 +1781,7 @@ useDesktopShortcuts(
}
.desktop-toolbar {
grid-row: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -2132,6 +2091,7 @@ useDesktopShortcuts(
padding: 8px 7px !important;
border-radius: 7px;
}
:global(.reminder-item.is-read) { opacity: 0.62 !important; }
.reminder-mark {
width: 7px;
@@ -2146,6 +2106,14 @@ useDesktopShortcuts(
.reminder-mark.is-warning {
background: #c58b2a;
}
.reminder-mark.is-info { background: var(--ctms-primary); }
.reminder-item > strong {
color: #7b8da3;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.reminder-body {
display: inline-flex;
@@ -2353,6 +2321,7 @@ useDesktopShortcuts(
}
.workspace-tabs {
grid-row: 2;
display: flex;
align-items: center;
min-width: 0;
@@ -2726,6 +2695,7 @@ useDesktopShortcuts(
}
.desktop-content {
grid-row: 3;
min-width: 0;
min-height: 0;
overflow: auto;
+24 -3
View File
@@ -76,7 +76,8 @@ describe("desktop layout shell", () => {
it("keeps web project switching routed through the workbench entry", () => {
const webLayout = readWebLayoutSource();
expect(webLayout).toContain('v-if="hasProjectContext" class="workbench-return-button"');
expect(webLayout).not.toContain("workbench-return-button");
expect(webLayout).toContain('<el-dropdown-item command="workbench">');
expect(webLayout).toContain('await router.push("/workbench")');
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
@@ -218,6 +219,18 @@ describe("desktop layout shell", () => {
expect(layout).toContain("pointer-events: none;");
});
it("uses the collaboration route immersive mode in the desktop shell", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(layout).toContain('<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">');
expect(layout).toContain('<header v-if="!route.meta.immersiveWorkspace" class="desktop-toolbar">');
expect(layout).toContain('v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1"');
expect(layout).toContain(".desktop-workbench.is-immersive-workspace {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-main {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-content {");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -302,7 +315,9 @@ describe("desktop layout shell", () => {
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
@@ -378,6 +393,7 @@ describe("desktop layout shell", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
expect(source).toContain('group: String(route.meta.workspaceTaskGroup || "工作区任务")');
expect(source).toContain("if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path)");
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
expect(source).toContain("provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });");
@@ -406,7 +422,9 @@ describe("desktop layout shell", () => {
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
@@ -590,6 +608,9 @@ describe("desktop layout shell", () => {
it("keeps routed desktop pages stretched to the content boundary", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain(".desktop-toolbar {\n grid-row: 1;");
expect(layout).toContain(".workspace-tabs {\n grid-row: 2;");
expect(layout).toContain(".desktop-content {\n grid-row: 3;");
expect(layout).toContain(".desktop-route-shell {");
expect(layout).toContain("width: 100%;");
expect(layout).toContain("max-width: 100%;");
@@ -1,6 +1,10 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy, reactive } from "vue";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
vi.mock("../runtime", () => ({
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
@@ -102,10 +106,102 @@ describe("OnlyOfficeViewer bridge", () => {
wrapper.unmount();
});
it("enables clipboard only for the explicit collaboration editor mode", () => {
const preview = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(preview.get("iframe").attributes("allow")).toContain("'none'");
preview.unmount();
const editor = mount(OnlyOfficeViewer, { props: { config: {}, allowClipboard: true } });
expect(editor.get("iframe").attributes("allow")).toBe("clipboard-read; clipboard-write");
editor.unmount();
});
it("permits iframe downloads only when the document capability allows them", () => {
const restricted = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(restricted.get("iframe").attributes("sandbox")).not.toContain("allow-downloads");
restricted.unmount();
const downloadable = mount(OnlyOfficeViewer, {
props: { config: {}, allowDownload: true },
});
expect(downloadable.get("iframe").attributes("sandbox")).toContain("allow-downloads");
downloadable.unmount();
});
it("reports host and document timeouts without falling back to a download", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
vi.advanceTimersByTime(15_000);
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
wrapper.unmount();
});
it("forwards a nonce-bound edit-right request from ONLYOFFICE", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as { nonce: string };
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.request-edit-rights",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("requestEditRights")).toHaveLength(1);
wrapper.unmount();
});
it("forwards independent Save As and download capabilities and accepts only nonce-bound file bytes", () => {
const wrapper = mount(OnlyOfficeViewer, {
props: { config: {}, allowSaveAs: true, allowDownload: true },
});
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as {
nonce: string;
allowSaveAs: boolean;
allowDownload: boolean;
};
expect(initMessage.allowSaveAs).toBe(true);
expect(initMessage.allowDownload).toBe(true);
const data = new ArrayBuffer(8);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.save-as",
nonce: initMessage.nonce,
detail: { fileType: "xlsx", title: "副本.xlsx", mimeType: "application/octet-stream", data },
});
expect(wrapper.emitted("saveAs")?.[0]?.[0]).toMatchObject({ fileType: "xlsx", title: "副本.xlsx", data });
const downloadData = new ArrayBuffer(12);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.download",
nonce: initMessage.nonce,
detail: {
fileType: "xlsx",
title: "原始文件.xlsx",
mimeType: "application/octet-stream",
data: downloadData,
},
});
expect(wrapper.emitted("download")?.[0]?.[0]).toMatchObject({
fileType: "xlsx",
title: "原始文件.xlsx",
data: downloadData,
});
wrapper.unmount();
});
it("keeps Save As URLs inside the isolated host and only posts validated bytes to the parent", () => {
const hostSource = readFileSync(resolve(__dirname, "../../public/onlyoffice-host.js"), "utf8");
expect(hostSource).toContain("events.onRequestSaveAs = handleRequestSaveAs");
expect(hostSource).toContain("events.onDownloadAs = handleDownloadAs");
expect(hostSource).toContain('url.origin !== window.location.origin');
expect(hostSource).toContain('url.pathname.startsWith("/onlyoffice/")');
expect(hostSource).toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])");
expect(hostSource).toContain("postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data])");
expect(hostSource).not.toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, url");
});
});
+76 -7
View File
@@ -5,9 +5,9 @@
ref="frameRef"
class="onlyoffice-viewer__frame"
:src="hostUrl.href"
title="ONLYOFFICE 文档查看器"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
allow="clipboard-read 'none'; clipboard-write 'none'"
:title="frameTitle"
:sandbox="sandboxPolicy"
:allow="allowPolicy"
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
@@ -15,27 +15,60 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { resolveOnlyOfficeHostUrl } from "../runtime";
import type { OnlyOfficeHostMessage } from "../types/onlyoffice";
import type { OnlyOfficeHostMessage, OnlyOfficeSaveAsPayload } from "../types/onlyoffice";
const HOST_READY = "ctms.onlyoffice.host-ready";
const INIT = "ctms.onlyoffice.init";
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
const WARNING = "ctms.onlyoffice.warning";
const ERROR = "ctms.onlyoffice.error";
const SAVE_AS = "ctms.onlyoffice.save-as";
const SAVE_AS_ERROR = "ctms.onlyoffice.save-as-error";
const DOWNLOAD = "ctms.onlyoffice.download";
const DOWNLOAD_ERROR = "ctms.onlyoffice.download-error";
const REQUEST_EDIT_RIGHTS = "ctms.onlyoffice.request-edit-rights";
const HOST_LOAD_TIMEOUT_MS = 15_000;
const DOCUMENT_LOAD_TIMEOUT_MS = 120_000;
const props = defineProps<{ config: Record<string, unknown> }>();
const props = withDefaults(defineProps<{
config: Record<string, unknown>;
allowClipboard?: boolean;
allowSaveAs?: boolean;
allowDownload?: boolean;
frameTitle?: string;
}>(), {
allowClipboard: false,
allowSaveAs: false,
allowDownload: false,
frameTitle: "ONLYOFFICE 文档查看器",
});
const emit = defineEmits<{
ready: [];
warning: [detail: Record<string, unknown>];
error: [detail: Record<string, unknown>];
stateChange: [changed: boolean];
saveAs: [payload: OnlyOfficeSaveAsPayload];
saveAsError: [message: string];
download: [payload: OnlyOfficeSaveAsPayload];
downloadError: [message: string];
requestEditRights: [];
}>();
const frameRef = ref<HTMLIFrameElement | null>(null);
const hostUrl = resolveOnlyOfficeHostUrl();
const allowPolicy = props.allowClipboard
? "clipboard-read; clipboard-write"
: "clipboard-read 'none'; clipboard-write 'none'";
const sandboxPolicy = computed(() => [
"allow-scripts",
"allow-same-origin",
"allow-forms",
"allow-popups",
"allow-modals",
...(props.allowDownload ? ["allow-downloads"] : []),
].join(" "));
const createNonce = () => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
@@ -67,7 +100,13 @@ const initializeHost = () => {
// Vue 会将接口响应递归包装为 Proxy,而 Proxy 不能通过 structured clone
// 传递给 iframe。JSON 配置本身只包含 ONLYOFFICE 支持的 JSON 类型。
const config = JSON.parse(JSON.stringify(toRaw(props.config))) as Record<string, unknown>;
frameWindow.postMessage({ type: INIT, nonce, config }, hostUrl.origin);
frameWindow.postMessage({
type: INIT,
nonce,
config,
allowSaveAs: props.allowSaveAs,
allowDownload: props.allowDownload,
}, hostUrl.origin);
} catch {
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
return;
@@ -107,6 +146,36 @@ const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
} else if (message.type === ERROR) {
clearTimers();
emit("error", message.detail || {});
} else if (message.type === "ctms.onlyoffice.document-state-change") {
emit("stateChange", Boolean(message.detail?.changed));
} else if (message.type === SAVE_AS) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("saveAsError", "另存为文件数据无效");
return;
}
emit("saveAs", { fileType, title, mimeType, data });
} else if (message.type === SAVE_AS_ERROR) {
emit("saveAsError", typeof message.detail?.message === "string" ? message.detail.message : "另存为失败");
} else if (message.type === DOWNLOAD) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("downloadError", "下载文件数据无效");
return;
}
emit("download", { fileType, title, mimeType, data });
} else if (message.type === DOWNLOAD_ERROR) {
emit("downloadError", typeof message.detail?.message === "string" ? message.detail.message : "下载失败");
} else if (message.type === REQUEST_EDIT_RIGHTS) {
emit("requestEditRights");
}
};
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
describe("web layout fullscreen control", () => {
it("offers an accessible browser fullscreen toggle and synchronizes external exits", () => {
const source = readWebLayoutSource();
expect(source).toContain('v-if="webFullscreenAvailable"');
expect(source).toContain(':aria-label="webFullscreenLabel"');
expect(source).toContain(':aria-pressed="webFullscreenActive"');
expect(source).toContain("toggleWebFullscreen");
expect(source).toContain("listenWebFullscreenChange(syncWebFullscreenState)");
expect(source).toContain("webFullscreenUnlisten?.()");
});
it("removes the application chrome for immersive workspace routes", () => {
const source = readWebLayoutSource();
expect(source).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(source).toContain('v-if="!route.meta.immersiveWorkspace"');
expect(source).toContain('v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"');
});
});
+89 -152
View File
@@ -1,10 +1,11 @@
<template>
<el-container
class="layout-container web-layout-container"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport }"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport, 'is-immersive-workspace': route.meta.immersiveWorkspace }"
@keydown.esc="closeMobileNav"
>
<el-aside
v-if="!route.meta.immersiveWorkspace"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
@@ -121,7 +122,7 @@
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.etmf }}</span>
</el-menu-item>
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files', '/knowledge/collaboration'])" index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>{{ TEXT.menu.sharedLibrary }}</span>
@@ -130,13 +131,14 @@
<el-menu-item v-if="canAccessProjectPath('/knowledge/precautions')" index="/knowledge/precautions">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/collaboration')" index="/knowledge/collaboration">{{ TEXT.menu.knowledgeCollaboration }}</el-menu-item>
</el-sub-menu>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container class="main-container">
<el-header class="layout-header">
<el-header v-if="!route.meta.immersiveWorkspace" class="layout-header">
<div class="header-left">
<el-button
class="collapse-btn"
@@ -189,11 +191,6 @@
</div>
<div class="header-right">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
<span>工作台</span>
</button>
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>搜索命令</span>
@@ -233,7 +230,25 @@
</button>
</el-tooltip>
<el-dropdown trigger="click" placement="bottom-end" class="header-reminder-dropdown" @command="handleReminderCommand">
<el-tooltip v-if="webFullscreenAvailable" :content="webFullscreenLabel" placement="bottom">
<button
class="header-fullscreen-button"
type="button"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
<el-dropdown
trigger="click"
placement="bottom-end"
class="header-reminder-dropdown"
@command="handleReminderCommand"
@visible-change="handleReminderVisibilityChange"
>
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
<el-badge
v-if="headerReminderTotal > 0"
@@ -261,27 +276,20 @@
:key="item.key"
:command="item.command"
class="header-reminder-item"
:class="{ 'is-read': item.isRead }"
>
<span class="header-reminder-item-mark" :class="`is-${item.tone}`"></span>
<span class="header-reminder-item-main">
<span class="header-reminder-item-title">{{ item.title }}</span>
<span class="header-reminder-item-desc">{{ item.description }}</span>
</span>
<span class="header-reminder-item-count">{{ item.count }}</span>
<span class="header-reminder-item-count">{{ item.timeLabel }}</span>
</el-dropdown-item>
</template>
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<el-dropdown-item
v-if="hasAnyRiskReminderAccess"
command="riskIssues"
divided
class="header-reminder-footer"
>
{{ TEXT.common.actions.view }}{{ TEXT.menu.riskIssues }}
</el-dropdown-item>
</div>
</el-dropdown-menu>
</template>
@@ -344,7 +352,7 @@
</el-container>
<button
v-if="isMobileNavOpen"
v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"
type="button"
class="mobile-nav-backdrop"
aria-label="关闭导航菜单"
@@ -396,15 +404,13 @@ import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor
Search, Star, StarFilled, Clock, Monitor, FullScreen, ScaleToOriginal
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
@@ -415,9 +421,14 @@ import {
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
refreshWebFullscreenState,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
toggleDesktopFavoriteRoute,
toggleWebFullscreen,
type DesktopRoutePreference,
} from "../runtime";
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
@@ -428,6 +439,7 @@ import AccountConnectionStatus from "./AccountConnectionStatus.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue";
import { getActiveLayoutPath } from "./layout/navigation";
import { useProjectNotifications } from "../composables/useProjectNotifications";
const auth = useAuthStore();
const study = useStudyStore();
@@ -459,14 +471,23 @@ const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({
overdueAes: 0,
overdueMonitoringIssues: 0,
});
const {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
} = useProjectNotifications();
let desktopMenuUnlisten: (() => void) | undefined;
let webFullscreenUnlisten: (() => void) | undefined;
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
@@ -492,76 +513,6 @@ const userDisplayInitial = computed(() => {
});
const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
};
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
headerRemindersLoading.value = true;
try {
const [aesRes, monitoringRes] = await Promise.allSettled([
canReadRiskIssueAes.value ? fetchOverdueAesCount(studyId) : Promise.resolve(null),
canReadMonitoringIssues.value ? listMonitoringVisitIssues(studyId, { overdue: true, limit: 500 }) : Promise.resolve(null),
]);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
headerReminderStats.value = {
overdueAes: aesRes.status === "fulfilled" && aesRes.value ? toHeaderNumber((aesRes.value.data as any)?.total) : 0,
overdueMonitoringIssues: monitoringRes.status === "fulfilled" && monitoringRes.value && Array.isArray(monitoringRes.value.data)
? monitoringRes.value.data.length
: 0,
};
} finally {
if (study.currentStudy?.id === studyId) {
headerRemindersLoading.value = false;
}
}
};
const headerReminderItems = computed(() => [
canReadRiskIssueAes.value && headerReminderStats.value.overdueAes > 0 ? {
key: "overdueAes",
command: "overdueAes",
title: TEXT.common.labels.overdueAes,
description: TEXT.common.labels.overdueAesDesc,
count: headerReminderStats.value.overdueAes,
tone: "danger",
} : null,
canReadMonitoringIssues.value && headerReminderStats.value.overdueMonitoringIssues > 0 ? {
key: "overdueMonitoringIssues",
command: "overdueMonitoringIssues",
title: TEXT.common.labels.overdueMonitoringIssues,
description: TEXT.common.labels.overdueMonitoringIssuesDesc,
count: headerReminderStats.value.overdueMonitoringIssues,
tone: "warning",
} : null,
].filter(Boolean) as Array<{
key: string;
command: string;
title: string;
description: string;
count: number;
tone: "danger" | "warning";
}>);
const headerReminderTotal = computed(() =>
headerReminderItems.value.reduce((sum, item) => sum + item.count, 0)
);
const headerReminderBadgeValue = computed(() =>
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
);
const accountProjectRoleLabel = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
@@ -615,6 +566,7 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "note"] },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "support"] },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "instruction"] },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "collaboration", "onlyoffice", "协作"] },
];
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
}
@@ -851,6 +803,7 @@ const breadcrumbs = computed(() => {
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files" },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files" },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration" },
]},
];
@@ -940,25 +893,6 @@ const handleMenuSelect = (index: string) => {
router.push(index);
};
const handleReminderCommand = (cmd: string) => {
if (cmd === "overdueAes" && canReadRiskIssueAes.value) {
router.push("/risk-issues/sae");
return;
}
if (cmd === "overdueMonitoringIssues" && canReadMonitoringIssues.value) {
router.push("/risk-issues/monitoring-visits");
return;
}
if (cmd === "riskIssues") {
const path = canReadRiskIssueAes.value
? "/risk-issues/sae"
: canReadMonitoringIssues.value
? "/risk-issues/monitoring-visits"
: "";
if (path) router.push(path);
}
};
watch(() => route.path, () => {
closeMobileNav();
study.setViewContext(null);
@@ -987,6 +921,28 @@ const closeMobileNav = () => {
isMobileNavOpen.value = false;
};
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
webFullscreenActive.value = isWebFullscreenActive();
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
syncWebFullscreenState();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
syncWebFullscreenState();
}
};
const syncMobileViewport = (matches: boolean) => {
isMobileViewport.value = matches;
if (!matches) closeMobileNav();
@@ -1036,6 +992,9 @@ const onLogout = async () => {
};
onMounted(async () => {
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
if (typeof window.matchMedia === "function") {
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
syncMobileViewport(mobileViewportMediaQuery.matches);
@@ -1059,14 +1018,17 @@ onMounted(async () => {
}
loadStudies();
loadHeaderReminders();
startHeaderNotificationPolling();
});
onBeforeUnmount(() => {
webFullscreenUnlisten?.();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
}
desktopMenuUnlisten?.();
stopHeaderNotificationPolling();
});
const onCommand = (cmd: string) => {
@@ -1572,26 +1534,6 @@ useDesktopShortcuts(
flex-shrink: 0;
}
.workbench-return-button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border: 1px solid #c7d7ec;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.workbench-return-button:hover {
border-color: #93c5fd;
background: #dbeafe;
}
.desktop-command-trigger {
display: inline-grid;
grid-template-columns: auto minmax(0, auto) auto;
@@ -1743,6 +1685,7 @@ useDesktopShortcuts(
align-items: center;
}
.header-fullscreen-button,
.header-reminder-button {
display: inline-flex;
align-items: center;
@@ -1758,6 +1701,7 @@ useDesktopShortcuts(
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.header-fullscreen-button:hover,
.header-reminder-button:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.14);
@@ -1765,6 +1709,7 @@ useDesktopShortcuts(
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.header-fullscreen-button .el-icon,
.header-reminder-button .el-icon {
font-size: 17px;
}
@@ -1835,6 +1780,7 @@ useDesktopShortcuts(
padding: 8px 7px !important;
border-radius: 7px;
}
:global(.header-reminder-item.is-read) { opacity: 0.62 !important; }
.header-reminder-item :deep(.el-dropdown-menu__item) {
width: 100%;
@@ -1854,6 +1800,7 @@ useDesktopShortcuts(
.header-reminder-item-mark.is-warning {
background: #f59e0b;
}
.header-reminder-item-mark.is-info { background: var(--ctms-primary); }
.header-reminder-item-main {
display: inline-flex;
@@ -1881,9 +1828,10 @@ useDesktopShortcuts(
}
.header-reminder-item-count {
color: #0f172a;
font-size: 16px;
font-weight: 750;
color: #7c8da3;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.header-reminder-empty {
@@ -2111,17 +2059,6 @@ useDesktopShortcuts(
gap: 3px;
}
.workbench-return-button {
width: 32px;
padding: 0;
font-size: 0;
}
.workbench-return-button .el-icon {
margin: 0;
font-size: 16px;
}
.header-account-role,
.user-profile .username,
.user-profile .el-icon,
@@ -2161,9 +2098,9 @@ useDesktopShortcuts(
max-width: 42vw;
}
.header-fullscreen-button,
.header-reminder-button,
.collapse-btn,
.workbench-return-button,
.dropdown-trigger.compact {
width: 30px;
height: 30px;
@@ -0,0 +1,101 @@
<template>
<el-dialog
:model-value="modelValue"
width="420px"
class="collaboration-download-dialog"
append-to-body
destroy-on-close
:show-close="!saving"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="collaboration-download-dialog__title">下载文件</div>
</template>
<div class="collaboration-download-dialog__content">
<div class="collaboration-download-dialog__icon" aria-hidden="true">
<el-icon><Download /></el-icon>
</div>
<div class="collaboration-download-dialog__body">
<div class="collaboration-download-dialog__name" :title="fileName">{{ fileName }}</div>
<p>文件已生成,请选择本机保存位置。</p>
</div>
</div>
<template #footer>
<el-button :disabled="saving" @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('confirm')">选择保存位置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Download } from "@element-plus/icons-vue";
defineProps<{
modelValue: boolean;
fileName: string;
saving?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [];
}>();
</script>
<style>
.collaboration-download-dialog {
border-radius: 12px;
}
.collaboration-download-dialog .el-dialog__header {
margin-right: 0;
padding: 18px 20px 8px;
}
.collaboration-download-dialog .el-dialog__body {
padding: 14px 20px 18px;
}
.collaboration-download-dialog .el-dialog__footer {
padding: 0 20px 18px;
}
.collaboration-download-dialog__title {
color: #172033;
font-size: 17px;
font-weight: 650;
}
.collaboration-download-dialog__content {
display: flex;
align-items: center;
gap: 14px;
}
.collaboration-download-dialog__icon {
display: grid;
flex: 0 0 42px;
width: 42px;
height: 42px;
place-items: center;
border-radius: 10px;
color: #3f6783;
background: #edf3f7;
font-size: 21px;
}
.collaboration-download-dialog__body {
min-width: 0;
}
.collaboration-download-dialog__name {
overflow: hidden;
color: #24364a;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaboration-download-dialog__body p {
margin: 5px 0 0;
color: #718096;
font-size: 13px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,237 @@
<template>
<el-dialog
:model-value="modelValue"
width="900px"
append-to-body
class="collaboration-action-dialog revision-save-as-dialog"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header><strong class="revision-save-as__title">另存为</strong></template>
<div class="revision-save-as">
<aside class="revision-save-as__sidebar">
<div>
<span class="revision-save-as__sidebar-label">保存位置</span>
<button type="button" class="is-active" @click="selectedFolderId = null">
<el-icon><FolderOpened /></el-icon>
<span>项目工作区</span>
</button>
</div>
</aside>
<div class="revision-save-as__browser">
<div class="revision-save-as__toolbar">
<div class="revision-save-as__breadcrumbs">
<button type="button" @click="selectedFolderId = null">项目工作区</button>
<template v-for="folder in breadcrumbs" :key="folder.id">
<span>/</span>
<button type="button" @click="selectedFolderId = folder.id">{{ folder.name }}</button>
</template>
</div>
<el-input v-model="query" clearable placeholder="搜索文件" class="revision-save-as__search" />
</div>
<div v-loading="loadingWorkspace" class="revision-save-as__items">
<button
v-for="folder in visibleFolders"
:key="folder.id"
type="button"
class="revision-save-as__folder"
@click="selectedFolderId = folder.id"
>
<el-icon><Folder /></el-icon>
<span>{{ folder.name }}</span>
</button>
<div v-for="file in visibleFiles" :key="file.id" class="revision-save-as__file" aria-disabled="true">
<span class="revision-save-as__file-badge" :class="`is-${file.file_type}`">{{ file.file_type === "word" ? "W" : file.file_type === "cell" ? "X" : "P" }}</span>
<span>{{ file.title }}</span>
</div>
<el-empty
v-if="!loadingWorkspace && !visibleFolders.length && !visibleFiles.length"
description="当前目录暂无内容"
:image-size="52"
/>
</div>
<div class="revision-save-as__bottom">
<label class="revision-save-as__filename">
<span>文件名称</span>
<el-input v-model="title" maxlength="240" @keyup.enter="submit" />
</label>
<div class="revision-save-as__actions">
<el-button v-if="canCreateFolder" :icon="Plus" text class="revision-save-as__new-folder" @click="createFolder">新建文件夹</el-button>
<span class="revision-save-as__actions-spacer" />
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">另存</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Folder, FolderOpened, Plus } from "@element-plus/icons-vue";
import {
createCollaborationFolder,
fetchCollaborationFiles,
fetchCollaborationFolders,
} from "../../api/collaboration";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { CollaborationFile, CollaborationFolder } from "../../types/collaboration";
const props = withDefaults(defineProps<{
modelValue: boolean;
studyId: string;
initialTitle: string;
initialFolderId?: string | null;
saving?: boolean;
canCreateFolder?: boolean;
}>(), {
initialFolderId: null,
saving: false,
canCreateFolder: false,
});
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [payload: { title: string; folderId: string | null }];
}>();
const folders = ref<CollaborationFolder[]>([]);
const files = ref<CollaborationFile[]>([]);
const selectedFolderId = ref<string | null>(null);
const title = ref("");
const query = ref("");
const loadingWorkspace = ref(false);
const breadcrumbs = computed(() => {
const result: CollaborationFolder[] = [];
const seen = new Set<string>();
let folderId = selectedFolderId.value;
while (folderId && !seen.has(folderId)) {
seen.add(folderId);
const folder = folders.value.find((item) => item.id === folderId);
if (!folder) break;
result.unshift(folder);
folderId = folder.parent_id || null;
}
return result;
});
const visibleFolders = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
if (keyword) return folders.value.filter((item) => item.name.toLocaleLowerCase("zh-CN").includes(keyword));
return folders.value.filter((item) => (item.parent_id || null) === selectedFolderId.value);
});
const visibleFiles = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
return files.value.filter((item) =>
item.status !== "DELETED" &&
(item.folder_id || null) === selectedFolderId.value &&
(!keyword || item.title.toLocaleLowerCase("zh-CN").includes(keyword)),
);
});
const loadWorkspace = async () => {
if (!props.studyId) return;
loadingWorkspace.value = true;
try {
const [folderResponse, fileResponse] = await Promise.all([
fetchCollaborationFolders(props.studyId),
fetchCollaborationFiles(props.studyId),
]);
folders.value = folderResponse.data;
files.value = fileResponse.data;
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "工作区加载失败"));
} finally {
loadingWorkspace.value = false;
}
};
const createFolder = async () => {
try {
const { value } = await ElMessageBox.prompt("请输入文件夹名称", "新建文件夹", {
inputPlaceholder: "文件夹名称",
inputValidator: (input) => Boolean(String(input || "").trim()) || "文件夹名称不能为空",
confirmButtonText: "新建",
});
const { data } = await createCollaborationFolder(props.studyId, {
name: value.trim(),
parent_id: selectedFolderId.value,
});
folders.value = [...folders.value, data];
selectedFolderId.value = data.id;
ElMessage.success(`文件夹“${data.name}”已创建`);
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "文件夹创建失败"));
}
};
const submit = () => {
const normalized = title.value.trim();
if (!normalized) {
ElMessage.warning("请输入文件名称");
return;
}
emit("confirm", { title: normalized, folderId: selectedFolderId.value });
};
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
title.value = props.initialTitle;
selectedFolderId.value = props.initialFolderId || null;
query.value = "";
void loadWorkspace();
},
);
</script>
<style scoped>
.revision-save-as { display: grid; height: min(560px, calc(100vh - 112px)); min-height: 430px; overflow: hidden; grid-template-columns: 180px minmax(0, 1fr); background: var(--ctms-bg-card); }
.revision-save-as__title { position: relative; z-index: 1; color: var(--ctms-text-main); font-size: 15px; font-weight: 650; }
.revision-save-as__sidebar { display: flex; min-height: 0; padding: 26px 14px 18px; flex-direction: column; background: color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)); }
.revision-save-as__sidebar-label { display: block; margin: 0 12px 10px; color: var(--ctms-text-disabled); font-size: 11px; letter-spacing: 0.04em; }
.revision-save-as__sidebar button { display: flex; width: 100%; height: 42px; align-items: center; gap: 9px; padding: 0 13px; border: 0; border-radius: 8px; color: var(--ctms-text-regular); background: transparent; font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
.revision-save-as__sidebar button .el-icon { font-size: 17px; }
.revision-save-as__sidebar button.is-active { color: var(--ctms-primary); background: color-mix(in srgb, var(--ctms-primary) 11%, var(--ctms-bg-card)); font-weight: 650; }
.revision-save-as__browser { display: grid; min-width: 0; min-height: 0; grid-template-rows: 66px minmax(0, 1fr) auto; }
.revision-save-as__toolbar { display: flex; min-width: 0; align-items: center; gap: 16px; padding: 12px 18px; }
.revision-save-as__breadcrumbs { display: flex; min-width: 0; align-items: center; gap: 6px; flex: 1; overflow: hidden; color: var(--ctms-text-disabled); }
.revision-save-as__breadcrumbs button { max-width: 150px; overflow: hidden; padding: 3px 2px; border: 0; color: var(--ctms-text-main); background: transparent; font: inherit; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.revision-save-as__breadcrumbs button:hover { color: var(--ctms-primary); }
.revision-save-as__search { width: 220px; }
.revision-save-as__search :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__items { min-height: 0; padding: 16px 22px; overflow: auto; }
.revision-save-as__folder,
.revision-save-as__file { display: flex; width: 100%; min-width: 0; height: 42px; align-items: center; gap: 10px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; font: inherit; font-size: 13px; text-align: left; }
.revision-save-as__folder { color: var(--ctms-text-main); cursor: pointer; }
.revision-save-as__folder:hover { background: var(--ctms-bg-muted); }
.revision-save-as__folder .el-icon { flex: 0 0 auto; color: var(--ctms-primary); font-size: 21px; }
.revision-save-as__folder span,
.revision-save-as__file > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.revision-save-as__file { color: var(--ctms-text-disabled); cursor: default; }
.revision-save-as__file-badge { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 4px; color: #fff; font-size: 9px; font-weight: 750; opacity: 0.42; }
.revision-save-as__file-badge.is-word { background: #4472c4; }
.revision-save-as__file-badge.is-cell { background: #2e8b57; }
.revision-save-as__file-badge.is-slide { background: #d05a35; }
.revision-save-as__items > :deep(.el-empty) { padding-top: 62px; }
.revision-save-as__items > :deep(.el-empty__description) { margin-top: 10px; }
.revision-save-as__items > :deep(.el-empty__description p) { color: var(--ctms-text-disabled); font-size: 12px; }
.revision-save-as__bottom { display: grid; gap: 12px; padding: 14px 18px 16px; background: var(--ctms-bg-card); }
.revision-save-as__filename { display: flex; min-width: 0; align-items: center; gap: 12px; }
.revision-save-as__filename > span { flex: 0 0 auto; color: var(--ctms-text-main); font-size: 13px; }
.revision-save-as__filename .el-input { flex: 1; }
.revision-save-as__filename :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__actions { display: flex; min-width: 0; align-items: center; gap: 10px; }
.revision-save-as__actions > .el-button { min-width: 70px; height: 34px; margin-left: 0; border-radius: 7px; }
.revision-save-as__new-folder.el-button { min-width: auto; padding-left: 0; color: var(--ctms-text-main); }
.revision-save-as__actions-spacer { flex: 1; }
:global(.revision-save-as-dialog.el-dialog) { max-width: calc(100vw - 32px); overflow: hidden; padding: 0; border-radius: 12px; box-shadow: 0 20px 54px rgb(15 23 42 / 20%); }
:global(.revision-save-as-dialog.el-dialog .el-dialog__header) { min-height: 50px; margin: 0; padding: 15px 18px; border-bottom: 0 !important; background: linear-gradient(to right, color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)) 0 180px, var(--ctms-bg-card) 180px 100%); box-shadow: none !important; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__headerbtn) { top: 6px; right: 9px; width: 38px; height: 38px; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__body) { padding: 0; }
</style>
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildAdminNavigationItems } from "./navigation";
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", () => {
@@ -26,3 +29,22 @@ describe("system management navigation", () => {
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";
@@ -292,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", "协作"],
},
],
},
];