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
+102 -7
View File
@@ -7,8 +7,15 @@
DOCUMENT_READY: "ctms.onlyoffice.document-ready",
WARNING: "ctms.onlyoffice.warning",
ERROR: "ctms.onlyoffice.error",
DOCUMENT_STATE_CHANGE: "ctms.onlyoffice.document-state-change",
SAVE_AS: "ctms.onlyoffice.save-as",
SAVE_AS_ERROR: "ctms.onlyoffice.save-as-error",
DOWNLOAD: "ctms.onlyoffice.download",
DOWNLOAD_ERROR: "ctms.onlyoffice.download-error",
REQUEST_EDIT_RIGHTS: "ctms.onlyoffice.request-edit-rights",
});
const API_SCRIPT_PATH = "/onlyoffice/web-apps/apps/api/documents/api.js";
const MAX_SAVE_AS_BYTES = 64 * 1024 * 1024;
const ALLOWED_TAURI_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
@@ -21,6 +28,7 @@
let apiScriptPromise = null;
let readyTimer = null;
let readyDeadlineTimer = null;
let documentTitle = "download";
const isLoopbackOrigin = (origin) => {
try {
@@ -34,9 +42,9 @@
const isAllowedParentOrigin = (origin) =>
origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(origin);
const postToParent = (type, detail) => {
const postToParent = (type, detail, transfer = []) => {
if (!parentOrigin || !requestNonce) return;
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin);
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin, transfer);
};
const safeEventDetail = (event) => {
@@ -67,6 +75,83 @@
return apiScriptPromise;
};
const saveAsEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : null;
if (!raw || typeof raw !== "object") throw new Error("另存为数据格式不正确");
const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : "";
const title = typeof raw.title === "string" ? raw.title.trim().slice(0, 240) : "";
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || typeof raw.url !== "string") {
throw new Error("另存为文件信息不完整");
}
const url = new URL(raw.url, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) {
throw new Error("另存为文件地址不可信");
}
return { fileType, title, url };
};
const handleRequestSaveAs = async (event) => {
try {
const { fileType, title, url } = saveAsEventDetail(event);
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
});
if (!response.ok) throw new Error("另存为文件生成失败");
const declaredSize = Number(response.headers.get("content-length") || "0");
if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("另存为文件超出大小限制");
const data = await response.arrayBuffer();
if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("另存为文件内容无效");
const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120);
postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data]);
} catch (error) {
postToParent(MESSAGE.SAVE_AS_ERROR, {
message: error instanceof Error ? error.message.slice(0, 200) : "另存为失败",
});
}
};
const downloadEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : null;
if (!raw || typeof raw !== "object") throw new Error("下载文件数据格式不正确");
const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : "";
if (!/^[a-z0-9]{1,16}$/.test(fileType) || typeof raw.url !== "string") {
throw new Error("下载文件信息不完整");
}
const url = new URL(raw.url, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) {
throw new Error("下载文件地址不可信");
}
const suffix = `.${fileType}`;
const stem = documentTitle.toLowerCase().endsWith(suffix)
? documentTitle.slice(0, -suffix.length)
: documentTitle.replace(/\.[^.]+$/, "");
return { fileType, title: `${stem}${suffix}`, url };
};
const handleDownloadAs = async (event) => {
try {
const { fileType, title, url } = downloadEventDetail(event);
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
});
if (!response.ok) throw new Error("下载文件生成失败");
const declaredSize = Number(response.headers.get("content-length") || "0");
if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("下载文件超出大小限制");
const data = await response.arrayBuffer();
if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("下载文件内容无效");
const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120);
postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data]);
} catch (error) {
postToParent(MESSAGE.DOWNLOAD_ERROR, {
message: error instanceof Error ? error.message.slice(0, 200) : "下载失败",
});
}
};
const destroyEditor = () => {
if (editor && typeof editor.destroyEditor === "function") {
try {
@@ -95,14 +180,24 @@
throw new Error("预览配置格式不正确");
}
requestNonce = message.nonce;
documentTitle = typeof config.document?.title === "string" && config.document.title.trim()
? config.document.title.trim().slice(0, 240)
: "download";
await loadOnlyOfficeApi();
const events = {
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
onDocumentStateChange: (event) => postToParent(MESSAGE.DOCUMENT_STATE_CHANGE, {
changed: Boolean(event && typeof event === "object" && "data" in event ? event.data : event),
}),
onRequestEditRights: () => postToParent(MESSAGE.REQUEST_EDIT_RIGHTS),
};
if (message?.allowSaveAs === true) events.onRequestSaveAs = handleRequestSaveAs;
if (message?.allowDownload === true) events.onDownloadAs = handleDownloadAs;
const editorConfig = {
...config,
events: {
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
},
events,
};
editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig);
};
@@ -206,6 +206,11 @@ const verifyOnlyOfficeBoundary = async () => {
assert(hostSource.includes("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin.");
assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization.");
assert(hostSource.includes("message?.nonce"), "ONLYOFFICE host must require the in-memory nonce.");
assert(hostSource.includes("url.origin !== window.location.origin"), "ONLYOFFICE save-as URL must remain same-origin.");
assert(hostSource.includes('url.pathname.startsWith("/onlyoffice/")'), "ONLYOFFICE save-as URL must remain under the fixed proxy path.");
assert(hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])"), "ONLYOFFICE save-as bridge must transfer validated file bytes.");
assert(!hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, url"), "ONLYOFFICE save-as bridge must not expose the signed result URL to business pages.");
assert(viewerSource.includes("allowSaveAs: props.allowSaveAs"), "ONLYOFFICE save-as must stay behind an explicit server capability.");
assert(!/\b(?:localStorage|sessionStorage|console\.)\b/.test(hostSource), "ONLYOFFICE host must not persist or log signed configuration.");
assert(apiSource.includes("cache: false"), "ONLYOFFICE signed config must not enter the desktop data cache.");
assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated.");
@@ -290,22 +295,31 @@ const verifyRustBoundary = async () => {
"updates::desktop_update_install",
"desktop_menu_set_shortcuts",
"desktop_window_set_theme",
"desktop_window_get_fullscreen",
"desktop_window_set_fullscreen",
];
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
const missing = allowedCommands.filter((command) => !commands.includes(command));
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
assert(libSource.includes("window.is_fullscreen()"), "Desktop fullscreen state must be read from the native window.");
assert(libSource.includes(".set_fullscreen(fullscreen)"), "Desktop fullscreen changes must target the native window.");
assert(libSource.includes("DESKTOP_FULLSCREEN_CHANGED_EVENT"), "Native fullscreen state must be emitted back to the WebView.");
};
const verifyDesktopUiNativeSync = async () => {
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8");
const fullscreenSource = await readFile(resolve(sourceDir, "runtime/webFullscreen.ts"), "utf8");
const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8");
assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command.");
assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes.");
assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance.");
assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_get_fullscreen"'), "Desktop fullscreen state must use the controlled Tauri query command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_set_fullscreen"'), "Desktop fullscreen changes must use the controlled Tauri mutation command.");
assert(fullscreenSource.includes("ctms:desktop-fullscreen-changed"), "Desktop fullscreen changes must synchronize native window state back to the WebView.");
assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance.");
assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup.");
+9 -1
View File
@@ -9,6 +9,14 @@ const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
const toPosixPath = (path) => path.split("\\").join("/");
const platformSource = await readFile(resolve(runtimeDir, "platform.ts"), "utf8");
if (!platformSource.includes("runtimeGlobal.isTauri")) {
violations.push("src/runtime/platform.ts: Tauri v2 runtime detection must use the official isTauri marker");
}
if (platformSource.includes("__TAURI")) {
violations.push("src/runtime/platform.ts: Tauri runtime detection must not depend on legacy or internal markers");
}
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
return (
@@ -28,7 +36,7 @@ for (const path of await walk(sourceDir)) {
const source = await readFile(path, "utf8");
const file = toPosixPath(relative(frontendDir, path));
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
if (source.includes("@tauri-apps/") || source.includes("__TAURI") || /(?:globalThis|window)\.isTauri/.test(source)) {
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
}
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
+40 -6
View File
@@ -7,9 +7,9 @@ use serde::Deserialize;
use tauri::menu::{
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder};
use tauri::{
Emitter, Manager, RunEvent, Runtime, Theme, WebviewWindow, WebviewWindowBuilder, WindowEvent,
};
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
const VIEW_MENU_ID: &str = "ctms.menu.view";
@@ -18,6 +18,7 @@ const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh";
const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar";
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
const DESKTOP_FULLSCREEN_CHANGED_EVENT: &str = "ctms:desktop-fullscreen-changed";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -343,6 +344,25 @@ fn desktop_window_set_theme<R: Runtime>(
.map_err(|error| format!("更新桌面窗口主题失败:{error}"))
}
#[tauri::command]
fn desktop_window_get_fullscreen<R: Runtime>(window: WebviewWindow<R>) -> Result<bool, String> {
window
.is_fullscreen()
.map_err(|error| format!("读取桌面窗口全屏状态失败:{error}"))
}
#[tauri::command]
fn desktop_window_set_fullscreen<R: Runtime>(
window: WebviewWindow<R>,
fullscreen: bool,
) -> Result<bool, String> {
window
.set_fullscreen(fullscreen)
.map_err(|error| format!("切换桌面窗口全屏状态失败:{error}"))?;
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
Ok(fullscreen)
}
pub fn run() {
let app = tauri::Builder::default()
.menu(desktop_menu)
@@ -372,19 +392,33 @@ pub fn run() {
updates::desktop_update_install,
desktop_menu_set_shortcuts,
desktop_window_set_theme,
desktop_window_get_fullscreen,
desktop_window_set_fullscreen,
])
.build(tauri::generate_context!())
.expect("error while building CTMS desktop application");
app.run(|app, event| {
#[cfg(target_os = "macos")]
if let RunEvent::Reopen { .. } = event {
if let RunEvent::Reopen { .. } = &event {
if let Err(error) = restore_main_window(app) {
eprintln!("{error}");
}
}
#[cfg(not(target_os = "macos"))]
let _ = (app, event);
if let RunEvent::WindowEvent {
label,
event: WindowEvent::Resized(_),
..
} = &event
{
if label == "main" {
if let Some(window) = app.get_webview_window(label) {
if let Ok(fullscreen) = window.is_fullscreen() {
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
}
}
}
}
});
}
+24
View File
@@ -110,6 +110,30 @@ describe("api axios retry", () => {
expect(adapter).toHaveBeenCalledTimes(2);
});
it("does not attach the CTMS login token to public share requests", async () => {
const auth = await import("../utils/auth");
vi.mocked(auth.getToken).mockReturnValue("private-login-token");
const api = (await import("./axios")).default;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await api.get("/api/v1/collaboration/shares/metadata", {
adapter,
publicRequest: true,
headers: { "X-CTMS-Share-Token": "share-token" },
} as any);
const config = adapter.mock.calls[0]?.[0] as InternalAxiosRequestConfig;
expect(config.headers.Authorization).toBeUndefined();
expect(config.headers["X-CTMS-Share-Token"]).toBe("share-token");
vi.mocked(auth.getToken).mockReturnValue(null);
});
it("keeps request headers in the dedupe key when they can affect the response", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
+4 -2
View File
@@ -20,6 +20,7 @@ const DEFAULT_GET_CACHE_TTL_MS = 30_000;
const API_RESPONSE_CACHE_SCHEMA_VERSION = 1;
export type ApiRequestConfig = AxiosRequestConfig & {
publicRequest?: boolean;
suppressErrorMessage?: boolean;
_retry?: boolean;
_networkRetryCount?: number;
@@ -225,7 +226,7 @@ instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiReque
config.headers = config.headers || {};
Object.assign(config.headers, getAppMetadataHeaders());
const token = getToken();
if (token) {
if (token && !config.publicRequest) {
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
@@ -237,12 +238,13 @@ instance.interceptors.response.use(
const status = error.response?.status;
const data = error.response?.data;
const reqUrl = error.config?.url || "";
const isPublicRequest = Boolean((error.config as ApiRequestConfig | undefined)?.publicRequest);
const isAuthEndpoint =
reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/password-reset") ||
reqUrl.includes("/api/v1/auth/extend");
if (isAuthEndpoint) {
if (isAuthEndpoint || isPublicRequest) {
// 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error);
}
+174
View File
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiPatch = vi.fn();
const apiDelete = vi.fn();
vi.mock("./axios", () => ({ apiGet, apiPost, apiPut, apiPatch, apiDelete }));
describe("collaboration API", () => {
beforeEach(() => vi.clearAllMocks());
it("keeps editor configuration outside request dedupe and desktop cache", async () => {
const { fetchCollaborationEditorConfig } = await import("./collaboration");
fetchCollaborationEditorConfig("study-1", "file-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/editor-config",
{
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
},
);
});
it("uses an independent collaboration namespace", async () => {
const { createCollaborationFile, fetchCollaborationFiles } = await import("./collaboration");
createCollaborationFile("study-1", { title: "方案", file_type: "word" });
fetchCollaborationFiles("study-1", { keyword: "方案" });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files",
{ title: "方案", file_type: "word" },
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files",
{ params: { keyword: "方案" } },
);
});
it("records a completed Save As operation for audit", async () => {
const { recordCollaborationExport } = await import("./collaboration");
recordCollaborationExport("study-1", "file-1", "xlsx");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/exports",
{ file_type: "xlsx" },
);
});
it("records a completed local download separately from workspace Save As", async () => {
const { recordCollaborationDownload } = await import("./collaboration");
recordCollaborationDownload("study-1", "file-1", "xlsx");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/downloads",
{ file_type: "xlsx" },
);
});
it("copies and downloads collaboration files through authenticated endpoints", async () => {
const { copyCollaborationFile, downloadCollaborationFile } = await import("./collaboration");
copyCollaborationFile("study-1", "file-1");
downloadCollaborationFile("study-1", "file-1");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/copy",
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/download",
{
responseType: "blob",
cache: false,
suppressErrorMessage: true,
},
);
});
it("always reloads revision history without cache or pending-request reuse", async () => {
const { fetchCollaborationRevisions } = await import("./collaboration");
fetchCollaborationRevisions("study-1", "file-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions",
{ cache: false, disableRequestDedupe: true },
);
});
it("supports naming, copying, and deleting a selected historical revision", async () => {
const { copyCollaborationRevision, deleteCollaborationRevision, updateCollaborationRevision } = await import("./collaboration");
updateCollaborationRevision("study-1", "file-1", "revision-2", { change_summary: "送审版" });
copyCollaborationRevision("study-1", "file-1", "revision-2", { title: "方案-R2.docx", folder_id: "folder-2" });
deleteCollaborationRevision("study-1", "file-1", "revision-2");
expect(apiPatch).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2",
{ change_summary: "送审版" },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2/copy",
{ title: "方案-R2.docx", folder_id: "folder-2" },
);
expect(apiDelete).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2",
);
});
it("keeps share tokens in a redacted request header and public requests outside auth recovery", async () => {
const {
fetchCollaborationShareLink,
fetchPublicCollaborationShareMetadata,
updateCollaborationShareLink,
verifyPublicCollaborationSharePassword,
} = await import("./collaboration");
fetchCollaborationShareLink("study-1", "file-1");
updateCollaborationShareLink("study-1", "file-1", {
enabled: true,
access_mode: "EDIT",
expiry_policy: "SEVEN_DAYS",
password_mode: "SET",
password: "1234",
});
fetchPublicCollaborationShareMetadata("share-secret");
verifyPublicCollaborationSharePassword("share-secret", "1234");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/share-link",
{ cache: false, disableRequestDedupe: true },
);
expect(apiPut).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/share-link",
expect.objectContaining({ enabled: true, expiry_policy: "SEVEN_DAYS", password: "1234" }),
);
const publicConfig = expect.objectContaining({
headers: { "X-CTMS-Share-Token": "share-secret" },
publicRequest: true,
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
invalidateCache: false,
});
expect(apiGet).toHaveBeenCalledWith("/api/v1/collaboration/shares/metadata", publicConfig);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/collaboration/shares/access",
{ password: "1234" },
publicConfig,
);
});
it("supports edit-request approval and contact ownership transfer", async () => {
const {
createCollaborationEditRequest,
fetchCollaborationEditRequests,
resolveCollaborationEditRequest,
transferCollaborationOwnership,
} = await import("./collaboration");
createCollaborationEditRequest("study-1", "file-1");
fetchCollaborationEditRequests("study-1", "file-1");
resolveCollaborationEditRequest("study-1", "file-1", "request-1", "APPROVED");
transferCollaborationOwnership("study-1", "file-1", "user-2");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests",
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests",
{ cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests/request-1/resolve",
{ status: "APPROVED" },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/transfer-ownership",
{ new_owner_id: "user-2" },
);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
import type {
CollaborationCandidate,
CollaborationEditorConfig,
CollaborationEditRequest,
CollaborationFile,
CollaborationFileType,
CollaborationFolder,
CollaborationMember,
CollaborationMemberRole,
CollaborationPublicShareMetadata,
CollaborationRevision,
CollaborationShareAccessGrant,
CollaborationShareAccessMode,
CollaborationShareExpiryPolicy,
CollaborationShareLink,
} from "../types/collaboration";
const baseUrl = (studyId: string) => `/api/v1/studies/${studyId}/collaboration`;
export const fetchCollaborationFolders = (studyId: string) =>
apiGet<CollaborationFolder[]>(`${baseUrl(studyId)}/folders`);
export const createCollaborationFolder = (studyId: string, payload: { name: string; parent_id?: string | null }) =>
apiPost<CollaborationFolder>(`${baseUrl(studyId)}/folders`, payload);
export const updateCollaborationFolder = (studyId: string, folderId: string, payload: Record<string, unknown>) =>
apiPatch<CollaborationFolder>(`${baseUrl(studyId)}/folders/${folderId}`, payload);
export const deleteCollaborationFolder = (studyId: string, folderId: string) =>
apiDelete(`${baseUrl(studyId)}/folders/${folderId}`);
export const fetchCollaborationFiles = (
studyId: string,
params?: { folder_id?: string; keyword?: string; deleted?: boolean },
) => apiGet<CollaborationFile[]>(`${baseUrl(studyId)}/files`, { params });
export const fetchCollaborationFile = (studyId: string, fileId: string) =>
apiGet<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}`, {
cache: false,
disableRequestDedupe: true,
});
export const createCollaborationFile = (
studyId: string,
payload: { title: string; file_type: CollaborationFileType; folder_id?: string | null },
) => apiPost<CollaborationFile>(`${baseUrl(studyId)}/files`, payload);
export const importCollaborationFile = (studyId: string, payload: FormData) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/import`, payload, {
headers: { "Content-Type": "multipart/form-data" },
});
export const copyCollaborationFile = (studyId: string, fileId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/copy`);
export const downloadCollaborationFile = (studyId: string, fileId: string) =>
apiGet<Blob>(`${baseUrl(studyId)}/files/${fileId}/download`, {
responseType: "blob",
cache: false,
suppressErrorMessage: true,
});
export const updateCollaborationFile = (studyId: string, fileId: string, payload: Record<string, unknown>) =>
apiPatch<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}`, payload);
export const trashCollaborationFile = (studyId: string, fileId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}`);
export const restoreCollaborationFile = (studyId: string, fileId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/restore`);
export const fetchCollaborationMembers = (studyId: string, fileId: string) =>
apiGet<CollaborationMember[]>(`${baseUrl(studyId)}/files/${fileId}/members`);
export const fetchCollaborationCandidates = (studyId: string) =>
apiGet<CollaborationCandidate[]>(`${baseUrl(studyId)}/member-candidates`);
export const upsertCollaborationMember = (
studyId: string,
fileId: string,
payload: { user_id: string; role: CollaborationMemberRole },
) => apiPut<CollaborationMember>(`${baseUrl(studyId)}/files/${fileId}/members`, payload);
export const removeCollaborationMember = (studyId: string, fileId: string, userId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}/members/${userId}`);
export const createCollaborationEditRequest = (studyId: string, fileId: string) =>
apiPost<CollaborationEditRequest>(`${baseUrl(studyId)}/files/${fileId}/edit-requests`);
export const fetchCollaborationEditRequests = (studyId: string, fileId: string) =>
apiGet<CollaborationEditRequest[]>(`${baseUrl(studyId)}/files/${fileId}/edit-requests`, {
cache: false,
disableRequestDedupe: true,
});
export const resolveCollaborationEditRequest = (
studyId: string,
fileId: string,
requestId: string,
status: "APPROVED" | "REJECTED",
) => apiPost<CollaborationEditRequest>(
`${baseUrl(studyId)}/files/${fileId}/edit-requests/${requestId}/resolve`,
{ status },
);
export const transferCollaborationOwnership = (studyId: string, fileId: string, newOwnerId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/transfer-ownership`, {
new_owner_id: newOwnerId,
});
export const fetchCollaborationShareLink = (studyId: string, fileId: string) =>
apiGet<CollaborationShareLink>(`${baseUrl(studyId)}/files/${fileId}/share-link`, {
cache: false,
disableRequestDedupe: true,
});
export const updateCollaborationShareLink = (
studyId: string,
fileId: string,
payload: {
enabled: boolean;
access_mode: CollaborationShareAccessMode;
expiry_policy: CollaborationShareExpiryPolicy;
password_mode: "KEEP" | "SET" | "CLEAR";
password?: string;
},
) => apiPut<CollaborationShareLink>(`${baseUrl(studyId)}/files/${fileId}/share-link`, payload);
export const fetchCollaborationRevisions = (studyId: string, fileId: string) =>
apiGet<CollaborationRevision[]>(`${baseUrl(studyId)}/files/${fileId}/revisions`, {
cache: false,
disableRequestDedupe: true,
});
export const restoreCollaborationRevision = (studyId: string, fileId: string, revisionId: string) =>
apiPost<CollaborationRevision>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/restore`, {});
export const updateCollaborationRevision = (
studyId: string,
fileId: string,
revisionId: string,
payload: { change_summary: string },
) => apiPatch<CollaborationRevision>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`, payload);
export const deleteCollaborationRevision = (studyId: string, fileId: string, revisionId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`);
export const copyCollaborationRevision = (
studyId: string,
fileId: string,
revisionId: string,
payload: { title: string; folder_id?: string | null },
) => apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/copy`, payload);
export const fetchCollaborationEditorConfig = (studyId: string, fileId: string) =>
apiGet<CollaborationEditorConfig>(`${baseUrl(studyId)}/files/${fileId}/editor-config`, {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
});
export const recordCollaborationExport = (studyId: string, fileId: string, fileType: string) =>
apiPost(`${baseUrl(studyId)}/files/${fileId}/exports`, { file_type: fileType });
export const recordCollaborationDownload = (studyId: string, fileId: string, fileType: string) =>
apiPost(`${baseUrl(studyId)}/files/${fileId}/downloads`, { file_type: fileType });
const publicShareHeaders = (shareToken: string) => ({ "X-CTMS-Share-Token": shareToken });
const publicShareRequestConfig = (shareToken: string) => ({
headers: publicShareHeaders(shareToken),
publicRequest: true,
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
invalidateCache: false,
});
export const fetchPublicCollaborationShareMetadata = (shareToken: string) =>
apiGet<CollaborationPublicShareMetadata>("/api/v1/collaboration/shares/metadata", publicShareRequestConfig(shareToken));
export const verifyPublicCollaborationSharePassword = (shareToken: string, password: string) =>
apiPost<CollaborationShareAccessGrant>(
"/api/v1/collaboration/shares/access",
{ password },
publicShareRequestConfig(shareToken),
);
export const fetchPublicCollaborationEditorConfig = (
shareToken: string,
payload: { access_token?: string; client_id: string; display_name?: string },
) => apiPost<CollaborationEditorConfig>(
"/api/v1/collaboration/shares/editor-config",
payload,
publicShareRequestConfig(shareToken),
);
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({ apiGet, apiPost }));
describe("general notification API", () => {
beforeEach(() => vi.clearAllMocks());
it("loads an uncached recipient feed and updates read state", async () => {
const {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} = await import("./notifications");
listGeneralNotifications("study-1", 8);
markGeneralNotificationRead("study-1", "notification-1");
markAllGeneralNotificationsRead("study-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/feed",
{ params: { limit: 8 }, cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/notification-1/read",
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/read-all",
);
});
});
+17 -2
View File
@@ -1,5 +1,20 @@
import { apiGet } from "./axios";
import type { NotificationItem } from "../types/notifications";
import { apiGet, apiPost } from "./axios";
import type { GeneralNotificationFeed, GeneralNotificationItem, NotificationItem } from "../types/notifications";
export const listNotifications = (studyId: string, params?: Record<string, any>) =>
apiGet<NotificationItem[]>(`/api/v1/studies/${studyId}/notifications`, { params });
export const listGeneralNotifications = (studyId: string, limit = 10) =>
apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit },
cache: false,
disableRequestDedupe: true,
});
export const markGeneralNotificationRead = (studyId: string, notificationId: string) =>
apiPost<GeneralNotificationItem>(
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
);
export const markAllGeneralNotificationsRead = (studyId: string) =>
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
+9
View File
@@ -13,3 +13,12 @@ export const fetchAttachmentOnlyOfficeConfig = (attachmentId: string) =>
export const fetchVersionOnlyOfficeConfig = (versionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig);
export const fetchCollaborationRevisionOnlyOfficeConfig = (
studyId: string,
fileId: string,
revisionId: string,
) => apiGet<OnlyOfficePreviewConfig>(
`/api/v1/studies/${studyId}/collaboration/files/${fileId}/revisions/${revisionId}/preview-config`,
noStoreConfig,
);
@@ -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", "协作"],
},
],
},
];
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./useProjectNotifications.ts"), "utf8");
const webLayout = readFileSync(resolve(__dirname, "../components/WebLayout.vue"), "utf8");
const desktopLayout = readFileSync(resolve(__dirname, "../components/DesktopLayout.vue"), "utf8");
describe("project notification feed contract", () => {
it("uses recipient notifications for both web and desktop reminder bells", () => {
expect(source).toContain("listGeneralNotifications");
expect(source).toContain("markGeneralNotificationRead");
expect(source).toContain("POLL_INTERVAL_MS = 60_000");
expect(source).toContain("PROJECT_NOTIFICATIONS_CHANGED_EVENT");
expect(webLayout).toContain("useProjectNotifications()");
expect(desktopLayout).toContain("useProjectNotifications()");
expect(webLayout).not.toContain("fetchOverdueAesCount");
expect(desktopLayout).not.toContain("fetchOverdueAesCount");
});
});
@@ -0,0 +1,116 @@
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { listGeneralNotifications, markGeneralNotificationRead } from "../api/notifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
export const PROJECT_NOTIFICATIONS_CHANGED_EVENT = "ctms:project-notifications-changed";
const POLL_INTERVAL_MS = 60_000;
export const notifyProjectNotificationsChanged = () => {
if (typeof window !== "undefined") window.dispatchEvent(new Event(PROJECT_NOTIFICATIONS_CHANGED_EVENT));
};
const formatNotificationTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const elapsed = Date.now() - date.getTime();
if (elapsed < 60_000) return "刚刚";
if (elapsed < 3_600_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`;
if (elapsed < 86_400_000) return `${Math.max(1, Math.floor(elapsed / 3_600_000))} 小时前`;
return `${date.getMonth() + 1}-${String(date.getDate()).padStart(2, "0")}`;
};
const notificationTone = (item: GeneralNotificationItem): "danger" | "warning" | "info" => {
if (item.priority === "URGENT") return "danger";
if (item.priority === "HIGH") return "warning";
return "info";
};
export const useProjectNotifications = () => {
const study = useStudyStore();
const route = useRoute();
const router = useRouter();
const headerRemindersLoading = ref(false);
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, items: [] });
let requestId = 0;
let pollTimer: number | undefined;
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId || route.path.startsWith("/admin")) {
feed.value = { unread_count: 0, items: [] };
return;
}
headerRemindersLoading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, 10);
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
// 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。
} finally {
if (currentRequest === requestId) headerRemindersLoading.value = false;
}
};
const headerReminderItems = computed(() => feed.value.items.map((item) => ({
key: item.id,
command: `notification:${item.id}`,
title: item.title,
description: item.message,
timeLabel: formatNotificationTime(item.created_at),
tone: notificationTone(item),
isRead: Boolean(item.read_at),
})));
const headerReminderTotal = computed(() => feed.value.unread_count);
const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value);
const handleReminderCommand = async (command: string) => {
if (!command.startsWith("notification:")) return;
const notificationId = command.slice("notification:".length);
const item = feed.value.items.find((candidate) => candidate.id === notificationId);
const studyId = study.currentStudy?.id;
if (!item || !studyId) return;
if (!item.read_at) {
item.read_at = new Date().toISOString();
feed.value.unread_count = Math.max(0, feed.value.unread_count - 1);
await markGeneralNotificationRead(studyId, item.id).catch(() => {
item.read_at = null;
feed.value.unread_count += 1;
});
}
if (item.action_path?.startsWith("/")) await router.push(item.action_path);
};
const handleReminderVisibilityChange = (visible: boolean) => {
if (visible) void loadHeaderReminders();
};
const handleWindowFocus = () => { void loadHeaderReminders(); };
const startHeaderNotificationPolling = () => {
if (pollTimer !== undefined || typeof window === "undefined") return;
pollTimer = window.setInterval(() => { void loadHeaderReminders(); }, POLL_INTERVAL_MS);
window.addEventListener("focus", handleWindowFocus);
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleWindowFocus);
};
const stopHeaderNotificationPolling = () => {
if (pollTimer !== undefined) window.clearInterval(pollTimer);
pollTimer = undefined;
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleWindowFocus);
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleWindowFocus);
}
};
return {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
};
};
+14 -2
View File
@@ -98,8 +98,8 @@ export const TEXT = {
plannedEnrollment: "入组",
dataUpdatedAt: "数据",
projectReminders: "提醒",
projectRemindersSubtitle: "逾期与风险提示",
projectRemindersEmpty: "暂无逾期风险",
projectRemindersSubtitle: "业务通知与待办",
projectRemindersEmpty: "暂无未处理通知",
overdueAes: "逾期 AE",
overdueAesDesc: "需关注安全性事件处理时效",
overdueMonitoringIssues: "逾期监查问题",
@@ -306,6 +306,7 @@ export const TEXT = {
knowledgeNotes: "注意事项",
knowledgeSupportFiles: "支持性文件",
knowledgeInstructionFiles: "说明文件",
knowledgeCollaboration: "在线协作",
profile: "个人中心",
logout: "退出登录",
},
@@ -733,6 +734,17 @@ export const TEXT = {
listTitle: "说明文件列表",
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
},
knowledgeCollaboration: {
title: "在线协作",
newFile: "新建协作文件",
importFile: "导入 Office 文件",
newFolder: "新建文件夹",
allFiles: "全部文件",
recycleBin: "回收站",
empty: "暂无协作文件",
loadFailed: "协作文件加载失败",
editorLoadFailed: "在线协作编辑器加载失败",
},
etmf: {
title: "eTMF",
subtitle: "按 TMF 目录归档项目级与中心级文件",
+26 -2
View File
@@ -12,11 +12,35 @@ describe("admin project route permissions", () => {
expect(source).toContain('name: "OfficeAttachmentPreview"');
expect(source).toContain('path: "office-preview/version/:id"');
expect(source).toContain('name: "OfficeVersionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(2);
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(3);
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
it("registers the independent collaboration library and transient editor workspace", () => {
const source = readRouter();
expect(source).toContain('path: "knowledge/collaboration"');
expect(source).toContain('name: "KnowledgeCollaboration"');
expect(source).toContain('meta: { title: TEXT.menu.knowledgeCollaboration, requiresStudy: true, fullBleed: true }');
expect(source).toContain('path: "knowledge/collaboration/:fileId"');
expect(source).toContain('name: "KnowledgeCollaborationWorkspace"');
expect(source).toContain("component: CollaborationWorkspace");
expect(source).toContain("immersiveWorkspace: true");
expect(source).toContain("workspaceTaskGroup: TEXT.menu.knowledgeCollaboration");
});
it("registers the collaboration share page outside authenticated project layout", () => {
const source = readRouter();
expect(source).toContain('path: "/collaboration/share"');
expect(source).toContain('name: "CollaborationPublicShare"');
expect(source).toContain("component: CollaborationShareWorkspace");
expect(source).toContain('meta: { public: true, title: "共享协作文件" }');
expect(source).toContain("!to.meta.public && !auth.user");
});
it("lets any authorized project role open the project management detail page", () => {
const source = readRouter();
@@ -138,7 +162,7 @@ describe("admin project route permissions", () => {
it("validates restored session tokens through /me before entering protected routes", () => {
const source = readRouter();
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken() && !isDesktopSessionRestoreRoute)");
const restoreGuardIndex = source.indexOf("if (!to.meta.public && !auth.user && getToken() && !isDesktopSessionRestoreRoute)");
const fetchMeIndex = source.indexOf("await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })", restoreGuardIndex);
const preserveIndex = source.indexOf("shouldPreserveDesktopSessionOnAuthCheckFailure(error)", fetchMeIndex);
const restoreRedirectIndex = source.indexOf("next({ path: DESKTOP_SESSION_RESTORE_PATH", preserveIndex);
+35 -1
View File
@@ -47,6 +47,9 @@ import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import Precautions from "../views/ia/Precautions.vue";
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import CollaborationLibrary from "../views/knowledge/CollaborationLibrary.vue";
import CollaborationWorkspace from "../views/knowledge/CollaborationWorkspace.vue";
import CollaborationShareWorkspace from "../views/knowledge/CollaborationShareWorkspace.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
import EthicsForm from "../views/startup/EthicsForm.vue";
@@ -93,6 +96,12 @@ const routes: RouteRecordRaw[] = [
component: ForgotPassword,
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
},
{
path: "/collaboration/share",
name: "CollaborationPublicShare",
component: CollaborationShareWorkspace,
meta: { public: true, title: "共享协作文件" },
},
{
path: "/desktop/server-settings",
name: "DesktopServerSettings",
@@ -206,6 +215,12 @@ const routes: RouteRecordRaw[] = [
component: OfficePreviewWorkspace,
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "office-preview/collaboration/:fileId/revision/:id",
name: "OfficeCollaborationRevisionPreview",
component: OfficePreviewWorkspace,
meta: { title: "协作历史版本预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics",
@@ -372,6 +387,25 @@ const routes: RouteRecordRaw[] = [
component: KnowledgeInstructionFiles,
meta: { title: TEXT.menu.knowledgeInstructionFiles, requiresStudy: true },
},
{
path: "knowledge/collaboration",
name: "KnowledgeCollaboration",
component: CollaborationLibrary,
meta: { title: TEXT.menu.knowledgeCollaboration, requiresStudy: true, fullBleed: true },
},
{
path: "knowledge/collaboration/:fileId",
name: "KnowledgeCollaborationWorkspace",
component: CollaborationWorkspace,
meta: {
title: TEXT.menu.knowledgeCollaboration,
requiresStudy: true,
fullBleed: true,
immersiveWorkspace: true,
transientWorkspaceTask: true,
workspaceTaskGroup: TEXT.menu.knowledgeCollaboration,
},
},
],
},
{
@@ -575,7 +609,7 @@ router.beforeEach(async (to, _from, next) => {
const getToken = () => auth.token;
let token = getToken();
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
if (!to.meta.public && !auth.user && getToken() && !isDesktopSessionRestoreRoute) {
try {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch (error) {
+3 -3
View File
@@ -4,7 +4,7 @@ import { clientRuntime } from "./clientRuntime";
import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
vi.unstubAllEnvs();
});
@@ -28,7 +28,7 @@ describe("client runtime", () => {
});
it("exposes only the implemented desktop capability", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
expect(getAppMetadata().clientType).toBe("desktop");
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
@@ -44,7 +44,7 @@ describe("client runtime", () => {
"X-CTMS-Client-Platform": "web",
});
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
expect(getAppMetadataHeaders()).toMatchObject({
"X-CTMS-Client-Type": "desktop",
"X-CTMS-Client-Source": "ctms-desktop",
+2 -2
View File
@@ -10,7 +10,7 @@ vi.mock("@tauri-apps/api/core", () => ({
afterEach(() => {
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
});
describe("desktop native menu", () => {
@@ -21,7 +21,7 @@ describe("desktop native menu", () => {
});
it("synchronizes validated shortcut preferences through the controlled command", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
@@ -10,9 +10,9 @@ import {
const setTauriRuntime = (enabled: boolean) => {
if (enabled) {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
} else {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
}
};
@@ -49,7 +49,7 @@ describe("desktop UI preferences", () => {
});
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
vi.unstubAllGlobals();
});
@@ -131,7 +131,7 @@ describe("desktop UI preferences", () => {
});
it("synchronizes the selected theme through the controlled desktop command", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
setDesktopThemePreference("system");
+13
View File
@@ -47,4 +47,17 @@ describe("web file saving", () => {
await expect(saveFile({ suggestedName: "protocol.pdf", data: "content" }, destination)).resolves.toBe("saved");
expect(click).toHaveBeenCalledOnce();
});
it("downloads directly to the runtime default destination without opening a save picker", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
(URL as any).createObjectURL = vi.fn().mockReturnValue("blob:download");
(URL as any).revokeObjectURL = vi.fn();
(window as any).showSaveFilePicker = vi.fn();
const { downloadFile } = await import("./files");
await downloadFile({ suggestedName: "研究方案.xlsx", data: "content" });
expect(click).toHaveBeenCalledOnce();
expect((window as any).showSaveFilePicker).not.toHaveBeenCalled();
});
});
+12
View File
@@ -158,6 +158,18 @@ export const saveFile = async (
return "saved";
};
export const downloadFile = async (output: FileOutput): Promise<void> => {
const url = URL.createObjectURL(toBlob(output));
const link = document.createElement("a");
link.href = url;
link.download = sanitizeFileName(output.suggestedName);
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
};
export const openFile = async (output: FileOutput): Promise<void> => {
if (!isTauriRuntime()) {
const url = URL.createObjectURL(toBlob(output));
+8
View File
@@ -62,6 +62,7 @@ export {
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export {
cleanupTemporaryFiles,
downloadFile,
openFile,
pickFiles,
prepareSaveFile,
@@ -79,6 +80,13 @@ export {
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export {
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
refreshWebFullscreenState,
toggleWebFullscreen,
} from "./webFullscreen";
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
export {
clearSessionToken,
+2 -2
View File
@@ -12,7 +12,7 @@ vi.mock("@tauri-apps/plugin-notification", () => ({
}));
const enableTauriRuntime = () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
};
const setBrowserNotificationPermission = (permission: NotificationPermission) => {
@@ -27,7 +27,7 @@ const setBrowserNotificationPermission = (permission: NotificationPermission) =>
};
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
Reflect.deleteProperty(window, "Notification");
vi.clearAllMocks();
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from "vitest";
import { isTauriRuntime } from "./platform";
const clearRuntimeMarkers = () => {
Reflect.deleteProperty(window, "isTauri");
};
afterEach(clearRuntimeMarkers);
describe("runtime platform detection", () => {
it("detects the official Tauri v2 runtime marker", () => {
window.isTauri = true;
expect(isTauriRuntime()).toBe(true);
});
it("does not classify an ordinary browser as desktop", () => {
clearRuntimeMarkers();
expect(isTauriRuntime()).toBe(false);
});
});
+10 -4
View File
@@ -2,13 +2,19 @@ export type RuntimePlatform = "web" | "macos" | "windows" | "linux";
declare global {
interface Window {
__TAURI__?: unknown;
__TAURI_INTERNALS__?: unknown;
isTauri?: boolean;
}
}
export const isTauriRuntime = (): boolean =>
typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
type TauriRuntimeGlobal = typeof globalThis & {
isTauri?: boolean;
};
export const isTauriRuntime = (): boolean => {
if (typeof globalThis === "undefined") return false;
const runtimeGlobal = globalThis as TauriRuntimeGlobal;
return runtimeGlobal.isTauri === true;
};
export const getRuntimePlatform = (): RuntimePlatform => {
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
@@ -37,7 +37,7 @@ describe("saved login credentials", () => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
@@ -45,13 +45,13 @@ describe("saved login credentials", () => {
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
it("stores desktop remembered passwords in the system credential store", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
let stored = "";
invokeMock.mockImplementation(async (command: string, args: any) => {
@@ -88,7 +88,7 @@ describe("saved login credentials", () => {
});
it("deletes malformed desktop remembered credentials", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
invokeMock.mockImplementation(async (command: string) => {
if (command === "login_credential_get") return JSON.stringify({ password: "missing-email" });
@@ -47,7 +47,7 @@ describe("secure session storage", () => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
@@ -56,7 +56,7 @@ describe("secure session storage", () => {
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
});
it("stores desktop tokens as a 30 day secure session record", async () => {
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invokeMock, listenMock, unlistenMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
listenMock: vi.fn(),
unlistenMock: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }));
describe("desktop fullscreen runtime", () => {
let nativeListener: ((event: { payload: boolean }) => void) | undefined;
beforeEach(() => {
vi.resetModules();
window.isTauri = true;
nativeListener = undefined;
invokeMock.mockReset();
listenMock.mockReset();
unlistenMock.mockReset();
listenMock.mockImplementation(async (_event: string, listener: (event: { payload: boolean }) => void) => {
nativeListener = listener;
return unlistenMock;
});
});
afterEach(() => {
Reflect.deleteProperty(window, "isTauri");
vi.restoreAllMocks();
});
it("uses controlled native commands and tracks window-driven fullscreen changes", async () => {
let fullscreen = false;
invokeMock.mockImplementation(async (command: string, args?: { fullscreen?: boolean }) => {
if (command === "desktop_window_get_fullscreen") return fullscreen;
if (command === "desktop_window_set_fullscreen") {
fullscreen = Boolean(args?.fullscreen);
return fullscreen;
}
throw new Error(`unexpected command: ${command}`);
});
const runtime = await import("./webFullscreen");
const listener = vi.fn();
expect(runtime.isWebFullscreenAvailable()).toBe(true);
const unlisten = runtime.listenWebFullscreenChange(listener);
await vi.waitFor(() => expect(invokeMock).toHaveBeenCalledWith("desktop_window_get_fullscreen"));
await runtime.toggleWebFullscreen();
expect(invokeMock).toHaveBeenCalledWith("desktop_window_set_fullscreen", { fullscreen: true });
expect(runtime.isWebFullscreenActive()).toBe(true);
nativeListener?.({ payload: false });
expect(runtime.isWebFullscreenActive()).toBe(false);
expect(listener).toHaveBeenCalled();
unlisten();
expect(unlistenMock).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
toggleWebFullscreen,
} from "./webFullscreen";
const configureFullscreenDocument = () => {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(async () => {
fullscreenElement = document.documentElement;
document.dispatchEvent(new Event("fullscreenchange"));
});
const exitFullscreen = vi.fn(async () => {
fullscreenElement = null;
document.dispatchEvent(new Event("fullscreenchange"));
});
Object.defineProperty(document, "fullscreenEnabled", { value: true, configurable: true });
Object.defineProperty(document, "fullscreenElement", {
get: () => fullscreenElement,
configurable: true,
});
Object.defineProperty(document, "exitFullscreen", { value: exitFullscreen, configurable: true });
Object.defineProperty(document.documentElement, "requestFullscreen", {
value: requestFullscreen,
configurable: true,
});
return { exitFullscreen, requestFullscreen };
};
afterEach(() => {
Reflect.deleteProperty(document, "fullscreenEnabled");
Reflect.deleteProperty(document, "fullscreenElement");
Reflect.deleteProperty(document, "exitFullscreen");
Reflect.deleteProperty(document.documentElement, "requestFullscreen");
vi.restoreAllMocks();
});
describe("web fullscreen runtime", () => {
it("reports fullscreen as unavailable when the browser does not expose the API", () => {
expect(isWebFullscreenAvailable()).toBe(false);
expect(isWebFullscreenActive()).toBe(false);
});
it("enters and exits fullscreen while requesting hidden browser navigation", async () => {
const { exitFullscreen, requestFullscreen } = configureFullscreenDocument();
expect(isWebFullscreenAvailable()).toBe(true);
await toggleWebFullscreen();
expect(requestFullscreen).toHaveBeenCalledWith({ navigationUI: "hide" });
expect(isWebFullscreenActive()).toBe(true);
await toggleWebFullscreen();
expect(exitFullscreen).toHaveBeenCalledOnce();
expect(isWebFullscreenActive()).toBe(false);
});
it("tracks browser-driven fullscreen changes and removes the listener", async () => {
configureFullscreenDocument();
const listener = vi.fn();
const unlisten = listenWebFullscreenChange(listener);
await toggleWebFullscreen();
expect(listener).toHaveBeenCalledOnce();
unlisten();
await toggleWebFullscreen();
expect(listener).toHaveBeenCalledOnce();
});
it("rechecks fullscreen state when the browser window changes outside the API", () => {
configureFullscreenDocument();
const listener = vi.fn();
const unlisten = listenWebFullscreenChange(listener);
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("focus"));
document.dispatchEvent(new Event("visibilitychange"));
expect(listener).toHaveBeenCalledTimes(3);
unlisten();
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("focus"));
document.dispatchEvent(new Event("visibilitychange"));
expect(listener).toHaveBeenCalledTimes(3);
});
it("rejects fullscreen requests when the capability is unavailable", async () => {
await expect(toggleWebFullscreen()).rejects.toThrow("当前浏览器不支持页面全屏");
});
});
+92
View File
@@ -0,0 +1,92 @@
import { isTauriRuntime } from "./platform";
const DESKTOP_FULLSCREEN_CHANGED_EVENT = "ctms:desktop-fullscreen-changed";
let desktopFullscreenActive = false;
const getBrowserDocument = (): Document | null =>
typeof document === "undefined" ? null : document;
export const isWebFullscreenAvailable = (): boolean => {
if (isTauriRuntime()) return true;
const browserDocument = getBrowserDocument();
return Boolean(
browserDocument?.fullscreenEnabled &&
typeof browserDocument.documentElement?.requestFullscreen === "function" &&
typeof browserDocument.exitFullscreen === "function",
);
};
export const isWebFullscreenActive = (): boolean =>
isTauriRuntime() ? desktopFullscreenActive : Boolean(getBrowserDocument()?.fullscreenElement);
export const refreshWebFullscreenState = async (): Promise<boolean> => {
if (!isTauriRuntime()) return isWebFullscreenActive();
const { invoke } = await import("@tauri-apps/api/core");
desktopFullscreenActive = await invoke<boolean>("desktop_window_get_fullscreen");
return desktopFullscreenActive;
};
export const toggleWebFullscreen = async (): Promise<void> => {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
const current = await refreshWebFullscreenState();
desktopFullscreenActive = await invoke<boolean>("desktop_window_set_fullscreen", {
fullscreen: !current,
});
return;
}
const browserDocument = getBrowserDocument();
if (!browserDocument || !isWebFullscreenAvailable()) {
throw new Error("当前浏览器不支持页面全屏");
}
if (browserDocument.fullscreenElement) {
await browserDocument.exitFullscreen();
return;
}
await browserDocument.documentElement.requestFullscreen({ navigationUI: "hide" });
};
export const listenWebFullscreenChange = (listener: () => void): (() => void) => {
if (isTauriRuntime()) {
let disposed = false;
let desktopUnlisten: (() => void) | undefined;
const syncDesktopState = () => {
void refreshWebFullscreenState()
.then(() => { if (!disposed) listener(); })
.catch(() => {});
};
void import("@tauri-apps/api/event")
.then(({ listen }) => listen<boolean>(DESKTOP_FULLSCREEN_CHANGED_EVENT, (event) => {
desktopFullscreenActive = Boolean(event.payload);
if (!disposed) listener();
}))
.then((unlisten) => {
if (disposed) void unlisten();
else desktopUnlisten = unlisten;
})
.catch(() => {});
window.addEventListener("focus", syncDesktopState);
syncDesktopState();
return () => {
disposed = true;
window.removeEventListener("focus", syncDesktopState);
void desktopUnlisten?.();
};
}
const browserDocument = getBrowserDocument();
if (!browserDocument) return () => undefined;
browserDocument.addEventListener("fullscreenchange", listener);
browserDocument.addEventListener("visibilitychange", listener);
window.addEventListener("focus", listener);
window.addEventListener("resize", listener);
return () => {
browserDocument.removeEventListener("fullscreenchange", listener);
browserDocument.removeEventListener("visibilitychange", listener);
window.removeEventListener("focus", listener);
window.removeEventListener("resize", listener);
};
};
+2 -2
View File
@@ -225,12 +225,12 @@ body {
transition: var(--ctms-transition);
}
.el-button--primary {
.el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
background-color: var(--ctms-primary);
border-color: var(--ctms-primary);
}
.el-button--primary:hover {
.el-button--primary:not(.is-plain):not(.is-link):not(.is-text):hover {
background-color: var(--ctms-primary-hover);
border-color: var(--ctms-primary-hover);
}
+146
View File
@@ -0,0 +1,146 @@
export type CollaborationFileType = "word" | "cell" | "slide";
export type CollaborationMemberRole = "EDITOR" | "MANAGER";
export type CollaborationShareAccessMode = "VIEW" | "EDIT";
export type CollaborationShareExpiryPolicy = "ONE_DAY" | "SEVEN_DAYS" | "THIRTY_DAYS" | "PERMANENT";
export type CollaborationEditRequestStatus = "PENDING" | "APPROVED" | "REJECTED";
export interface CollaborationFileCollaborator {
user_id: string;
full_name: string;
role: CollaborationMemberRole;
avatar_url?: string | null;
}
export interface CollaborationFolder {
id: string;
study_id: string;
parent_id?: string | null;
name: string;
sort_order: number;
created_by: string;
deleted_at?: string | null;
created_at: string;
updated_at: string;
}
export interface CollaborationFile {
id: string;
study_id: string;
folder_id?: string | null;
title: string;
file_type: CollaborationFileType;
extension: "docx" | "xlsx" | "pptx";
status: "ACTIVE" | "ARCHIVED" | "DELETED";
owner_id: string;
current_revision_id?: string | null;
generation: number;
allow_export: boolean;
allow_edit_request: boolean;
allow_sheet_structure_edit: boolean;
deleted_at?: string | null;
created_at: string;
updated_at: string;
owner_name?: string | null;
folder_name?: string | null;
current_revision_no?: number | null;
current_revision_file_size?: number | null;
current_revision_mime_type?: string | null;
current_revision_created_at?: string | null;
collaboration_role?: CollaborationMemberRole | null;
collaborators: CollaborationFileCollaborator[];
can_edit: boolean;
can_manage: boolean;
can_export: boolean;
can_request_edit: boolean;
edit_request_status?: CollaborationEditRequestStatus | null;
can_transfer_ownership: boolean;
}
export interface CollaborationMember {
id: string;
file_id: string;
user_id: string;
role: CollaborationMemberRole;
invited_by: string;
full_name: string;
email: string;
created_at: string;
}
export interface CollaborationCandidate {
user_id: string;
full_name: string;
email: string;
role_in_study: string;
can_be_editor: boolean;
can_be_manager: boolean;
}
export interface CollaborationEditRequest {
id: string;
file_id: string;
requester_id: string;
requester_name: string;
requester_email: string;
status: CollaborationEditRequestStatus;
resolved_by?: string | null;
resolved_at?: string | null;
created_at: string;
}
export interface CollaborationRevision {
id: string;
file_id: string;
revision_no: number;
parent_revision_id?: string | null;
original_filename: string;
file_hash: string;
file_size: number;
mime_type: string;
source: string;
change_summary?: string | null;
created_by?: string | null;
created_by_name?: string | null;
created_by_avatar_url?: string | null;
created_at: string;
}
export interface CollaborationEditorConfig {
file_id: string;
file_name: string;
access_mode: "view" | "edit";
can_save_as: boolean;
can_download: boolean;
can_request_edit: boolean;
host_path: "/onlyoffice-host.html";
expires_at: string;
config: Record<string, unknown>;
}
export interface CollaborationShareLink {
id: string;
file_id: string;
enabled: boolean;
access_mode: CollaborationShareAccessMode;
expiry_policy: CollaborationShareExpiryPolicy;
expires_at?: string | null;
has_password: boolean;
share_path: "/collaboration/share";
share_token?: string | null;
created_at: string;
updated_at: string;
}
export interface CollaborationPublicShareMetadata {
file_name: string;
file_type: CollaborationFileType;
access_mode: "view" | "edit";
allow_export: boolean;
requires_password: boolean;
expires_at?: string | null;
}
export interface CollaborationShareAccessGrant {
access_token: string;
expires_at: string;
}
+20
View File
@@ -13,3 +13,23 @@ export interface NotificationItem {
delivered_at?: string | null;
read_at?: string | null;
}
export interface GeneralNotificationItem {
id: string;
study_id: string;
recipient_id: string;
category: string;
priority: "LOW" | "NORMAL" | "HIGH" | "URGENT";
title: string;
message: string;
action_path?: string | null;
source_type: string;
source_id: string;
read_at?: string | null;
created_at: string;
}
export interface GeneralNotificationFeed {
unread_count: number;
items: GeneralNotificationItem[];
}
+8 -1
View File
@@ -1,4 +1,4 @@
export type OnlyOfficeResourceType = "attachment" | "version";
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
@@ -14,3 +14,10 @@ export interface OnlyOfficeHostMessage {
nonce?: string;
detail?: Record<string, unknown>;
}
export interface OnlyOfficeSaveAsPayload {
fileType: string;
title: string;
mimeType?: string;
data: ArrayBuffer;
}
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const capabilitiesMock = vi.hoisted(() => vi.fn());
const downloadFileMock = vi.hoisted(() => vi.fn());
const openFileMock = vi.hoisted(() => vi.fn());
const pickFilesMock = vi.hoisted(() => vi.fn());
const saveFileMock = vi.hoisted(() => vi.fn());
@@ -14,6 +15,7 @@ vi.mock("../runtime", () => ({
clientRuntime: {
capabilities: capabilitiesMock,
},
downloadFile: downloadFileMock,
openFile: openFileMock,
pickFiles: pickFilesMock,
saveFile: saveFileMock,
@@ -45,6 +47,7 @@ describe("file task feedback", () => {
pickFilesMock.mockResolvedValue([]);
saveFileMock.mockResolvedValue("saved");
openFileMock.mockResolvedValue(undefined);
downloadFileMock.mockResolvedValue(undefined);
startActivityMock.mockReturnValue("activity-1");
});
@@ -80,6 +83,32 @@ describe("file task feedback", () => {
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
});
it("downloads directly without invoking the save flow", async () => {
const { downloadFileWithFeedback } = await import("./fileTaskFeedback");
await downloadFileWithFeedback(output);
expect(downloadFileMock).toHaveBeenCalledWith(output);
expect(saveFileMock).not.toHaveBeenCalled();
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
});
it("reports direct downloads through persistent desktop activity", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { downloadFileWithFeedback } = await import("./fileTaskFeedback");
await downloadFileWithFeedback(output, { title: "下载 report.pdf" });
expect(downloadFileMock).toHaveBeenCalledWith(output);
expect(saveFileMock).not.toHaveBeenCalled();
expect(startActivityMock).toHaveBeenCalledWith({
kind: "download",
title: "下载 report.pdf",
detail: "正在准备下载",
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "文件下载已开始" });
});
it("writes to a destination that was selected before the download", async () => {
const destination = { kind: "web-file-handle", handle: { createWritable: vi.fn() } } as const;
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
+25
View File
@@ -1,6 +1,7 @@
import { ElMessage } from "element-plus";
import {
clientRuntime,
downloadFile,
openFile,
pickFiles,
saveFile,
@@ -28,6 +29,30 @@ const desktopFileActivitiesEnabled = () => clientRuntime.capabilities().nativeFi
const defaultSaveTitle = (kind: FileSaveActivityKind) => (kind === "export" ? "导出文件" : "保存文件");
export const downloadFileWithFeedback = async (
output: FileOutput,
options: Omit<FileTaskFeedbackOptions, "kind"> = {},
): Promise<void> => {
if (!desktopFileActivitiesEnabled()) {
await downloadFile(output);
ElMessage.success("文件下载已开始");
return;
}
const activityId = options.activityId || startDesktopActivity({
kind: "download",
title: options.title || "下载文件",
detail: options.pendingDetail || "正在准备下载",
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "正在准备下载", progress: 70 });
try {
await downloadFile(output);
finishDesktopActivity(activityId, "completed", { detail: options.completedDetail || "文件下载已开始" });
} catch (error) {
finishDesktopActivity(activityId, "failed", { detail: "文件下载失败" });
throw error;
}
};
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
const files = await pickFiles(options);
if (files.length) {
+18
View File
@@ -50,6 +50,12 @@ const PERMISSIONS: Record<string, string[]> = {
"documents.create": ["ADMIN"],
"documents.update": ["ADMIN"],
"documents.delete": ["ADMIN"],
"collaboration.read": ["ADMIN"],
"collaboration.create": ["ADMIN"],
"collaboration.edit": ["ADMIN"],
"collaboration.manage": ["ADMIN"],
"collaboration.export": ["ADMIN"],
"collaboration.delete": ["ADMIN"],
};
const REASONS: Record<string, string> = {
@@ -94,6 +100,12 @@ const REASONS: Record<string, string> = {
"documents.create": TEXT.modules.permissions.default,
"documents.update": TEXT.modules.permissions.default,
"documents.delete": TEXT.modules.permissions.default,
"collaboration.read": TEXT.modules.permissions.default,
"collaboration.create": TEXT.modules.permissions.default,
"collaboration.edit": TEXT.modules.permissions.default,
"collaboration.manage": TEXT.modules.permissions.default,
"collaboration.export": TEXT.modules.permissions.default,
"collaboration.delete": TEXT.modules.permissions.default,
};
export const usePermission = () => {
@@ -154,6 +166,12 @@ export const usePermission = () => {
"documents.create": "documents:create",
"documents.update": "documents:update",
"documents.delete": "documents:delete",
"collaboration.read": "collaboration:read",
"collaboration.create": "collaboration:create",
"collaboration.edit": "collaboration:edit",
"collaboration.manage": "collaboration:manage",
"collaboration.export": "collaboration:export",
"collaboration.delete": "collaboration:delete",
"audit.export.read": "audit_logs:read",
};
const operationKey = permissionMap[action];
@@ -30,6 +30,8 @@ describe("project route permissions", () => {
expect(getProjectRoutePermission("/startup/training/new")).toBeNull();
expect(getProjectRoutePermission("/startup/training/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/collaboration")).toEqual({ operationKey: "collaboration:read" });
expect(getProjectRoutePermission("/knowledge/collaboration/file-1")).toEqual({ operationKey: "collaboration:read" });
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toBeNull();
@@ -28,6 +28,7 @@ const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePerm
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:read" } },
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/collaboration"], permission: { operationKey: "collaboration:read" } },
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
];
@@ -59,6 +60,7 @@ export const projectRouteLandingPaths = [
"/risk-issues/monitoring-visits",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/collaboration",
"/knowledge/precautions",
"/knowledge/support-files",
"/knowledge/instruction-files",
@@ -12,6 +12,8 @@ describe("OfficePreviewWorkspace contract", () => {
expect(source).not.toContain("downloadDocumentVersion");
expect(source).not.toContain("downloadAttachment");
expect(source).not.toContain("<iframe");
expect(source).toContain('route.name === "OfficeCollaborationRevisionPreview"');
expect(source).toContain("fetchCollaborationRevisionOnlyOfficeConfig");
});
it("destroys signed configuration on deactivation, server changes, and unmount", () => {
+18 -5
View File
@@ -39,7 +39,11 @@ import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounte
import { useRoute, useRouter } from "vue-router";
import { Loading } from "@element-plus/icons-vue";
import OnlyOfficeViewer from "../components/OnlyOfficeViewer.vue";
import { fetchAttachmentOnlyOfficeConfig, fetchVersionOnlyOfficeConfig } from "../api/onlyoffice";
import {
fetchAttachmentOnlyOfficeConfig,
fetchCollaborationRevisionOnlyOfficeConfig,
fetchVersionOnlyOfficeConfig,
} from "../api/onlyoffice";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
@@ -61,10 +65,13 @@ const requestSequence = ref(0);
const viewerSequence = ref(0);
let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() =>
route.name === "OfficeAttachmentPreview" ? "attachment" : "version",
);
const resourceType = computed<OnlyOfficeResourceType>(() => {
if (route.name === "OfficeAttachmentPreview") return "attachment";
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
return "version";
});
const resourceId = computed(() => String(route.params.id || ""));
const collaborationFileId = computed(() => String(route.params.fileId || ""));
const displayFileName = computed(() => previewConfig.value?.file_name || String(route.meta.title || "Office 文件预览"));
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
@@ -104,7 +111,13 @@ const loadConfig = async () => {
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
: resourceType.value === "collaboration_revision"
? await fetchCollaborationRevisionOnlyOfficeConfig(
study.currentStudy?.id || "",
collaborationFileId.value,
resourceId.value,
)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
if (sequence !== requestSequence.value) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
throw new Error("Office 预览宿主页配置不合法");
@@ -0,0 +1,332 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
describe("CollaborationLibrary UI contract", () => {
it("opens the create dialog directly from the primary sidebar action", () => {
expect(source).toContain(':icon="Plus" @click="openCreateDialog">新建</el-button>');
expect(source).toContain(':icon="Upload" :loading="importing"');
expect(source).not.toContain("新建协作文件⌄");
});
it("provides compact document-library views and file type filtering", () => {
expect(source).toContain("全部文件");
expect(source).toContain("最近更新");
expect(source).toContain("与我共享");
expect(source).toContain('v-model="fileTypeFilter"');
expect(source).toContain("currentViewTitle");
});
it("uses folder ellipsis actions on web and a context menu on desktop", () => {
expect(source).toContain('const isDesktop = isTauriRuntime();');
expect(source).toContain(":trigger=\"isDesktop ? 'contextmenu' : 'click'\"");
expect(source).toContain('v-if="!isDesktop && canManage');
expect(source).toContain('class="folder-more"');
expect(source).toContain('command="rename"');
expect(source).toContain('command="delete"');
expect(source).not.toContain('class="folder-actions"');
});
it("renames folders inline and keeps destructive deletion confirmed", () => {
expect(source).toContain(':data-folder-rename="folder.id"');
expect(source).toContain('@keydown.enter.stop.prevent="submitRenameFolder(folder)"');
expect(source).toContain('@keydown.esc.stop.prevent="cancelRenameFolder"');
expect(source).toContain('确认删除空文件夹“${folder.name}”');
});
it("renders action dialogs as a single desktop surface", () => {
expect(source.match(/class="collaboration-action-dialog(?:\s|\")/g)?.length).toBe(8);
expect(source).toContain("body.is-desktop-runtime .collaboration-action-dialog.el-dialog");
expect(source).toContain("--el-dialog-padding-primary: 0;");
expect(source).toContain("padding: 0 !important;");
});
it("offers the complete file action menu with explicit permission gates", () => {
for (const command of ["permissions", "rename", "copy", "move", "info", "history", "download", "trash"]) {
expect(source).toContain(`command: "${command}"`);
}
expect(source).toContain("item.can_export && canCreate.value");
expect(source).toContain("if (item.can_export) actions.push");
expect(source).toContain('label: "访问与权限"');
expect(source).not.toContain('command: "members"');
expect(source).not.toContain('command: "share"');
expect(source).not.toContain('command="manage"');
});
it("keeps rename and move as separate focused operations", () => {
expect(source).toContain('v-model="renameDialogVisible" title="重命名"');
expect(source).toContain('v-model="moveDialogVisible" title="移动到"');
expect(source).toContain("submitRenameFile");
expect(source).toContain("submitMoveFile");
expect(source).toContain("const targetFolderId = moveFolderId.value || null;");
expect(source).toContain("{ folder_id: targetFolderId }");
});
it("shows file metadata and downloads the current revision without a save picker", () => {
expect(source).toContain('v-model="infoDialogVisible" title="文件信息"');
expect(source).toContain("current_revision_file_size");
expect(source).toContain("current_revision_no");
expect(source).not.toContain('<el-table-column label="修订"');
expect(source).not.toContain('<el-descriptions-item label="当前版本"');
expect(source).not.toContain("R${");
expect(source).toContain("downloadCollaborationFile");
expect(source).toContain("downloadFileWithFeedback(");
expect(source).not.toContain("prepareSaveFile");
});
it("refreshes history immediately after restoring a revision", () => {
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
expect(source).toContain(':loading="restoringRevisionId === row.id"');
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
expect(source).toContain("current_revision_id: restoredForDisplay.id");
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
expect(source).toContain("const [revisionResponse] = await Promise.all([");
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
});
it("loads fresh history on open and follows delayed ONLYOFFICE session callbacks", () => {
expect(source).toContain('@closed="closeHistory"');
expect(source).toContain("const historyRefreshTimers: number[] = []");
expect(source).toContain("scheduleHistoryRefresh();");
expect(source).toContain("refreshHistory({ silent: true })");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
expect(source).toContain("historyFile.value = fileResponse.data");
expect(source).toContain("requestSequence !== historyRequestSequence");
});
it("presents version history as a date-grouped file timeline", () => {
expect(source).toContain('class="collaboration-action-dialog history-dialog"');
expect(source).toContain('width="840px"');
expect(source).toContain('modal-class="history-dialog-overlay"');
expect(source).toContain("history-file-badge");
expect(source).toContain("创建者:{{ historyFile.owner_name");
expect(source).toContain('v-model="historyOnlyDescribed"');
expect(source).toContain('v-model="historySourceFilter"');
expect(source).toContain('v-for="group in historyRevisionGroups"');
expect(source).toContain("revision.created_by_name");
expect(source).toContain(":src=\"row.created_by_avatar_url || undefined\"");
expect(source).toContain("当前版本");
expect(source).toContain("版本名称");
expect(source).not.toContain("<strong>R{{ row.revision_no }}</strong>");
expect(source).toContain("仅显示已命名版本");
expect(source).toContain("未命名");
expect(source).toContain(">命名</el-button>");
expect(source).toContain(">预览</el-button>");
expect(source).toContain(">另存为</el-button>");
expect(source).toContain("nameRevision(row)");
expect(source).toContain("previewRevision(row)");
expect(source).toContain("saveRevisionAs(row)");
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
expect(source).not.toContain("historyDialogVisible.value = false");
expect(source).toContain("copyCollaborationRevision");
expect(source).toContain("updateCollaborationRevision");
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
expect(source).toContain("<CollaborationSaveAsDialog");
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
expect(source).toContain("submitRevisionSaveAs");
expect(source).toContain("folder_id: payload.folderId");
expect(source).toContain('`${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`');
expect(source).toContain("${pad(date.getMinutes())}");
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
expect(source).toContain('command="delete"');
expect(source).toContain("删除版本");
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
expect(source).toContain("deleteCollaborationRevision");
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
expect(source).toContain("height: min(760px, 82vh)");
expect(source).toContain("min-height: 48px");
expect(source).toContain("body.is-desktop-runtime .history-dialog.el-dialog");
expect(source).toContain("margin: 0 auto !important;");
expect(source).not.toContain('<el-table :data="revisions"');
});
it("supports inviting several members inside unified access management", () => {
expect(source).toContain('v-model="accessDialogVisible"');
expect(source).toContain('<strong>{{ accessPanelTitle }}</strong>');
expect(source).toContain("openAccessManagement");
expect(source).toContain('@click="openMemberInvite"');
expect(source).toContain('class="access-section__manage-button"');
expect(source).toContain("<span>管理协作者</span>");
expect(source).toContain('class="access-member-identity"');
expect(source).toContain('class="access-member-role"');
expect(source).toContain('class="access-member-owner">所有者</span>');
expect(source).not.toContain('<el-table :data="members"');
expect(source).toContain('v-model="memberInviteDialogVisible"');
expect(source).toContain('title="管理协作者"');
expect(source).not.toContain('title="添加协作者"');
expect(source).toContain('placeholder="搜索姓名、账号或角色"');
expect(source).not.toContain('member-picker-group--recent');
expect(source).not.toContain('member-picker-recent-empty');
expect(source).not.toContain('最近选择的账号将显示在这里');
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
expect(source).toContain("selectedMemberCandidates.length");
expect(source).toContain('aria-label="文件授权角色"');
expect(source).toContain('label="可编辑" value="EDITOR"');
expect(source).toContain('label="可管理" value="MANAGER"');
expect(source).toContain('class="member-picker-selection__footer"');
expect(source).toContain("Promise.allSettled(selectedUserIds.map");
expect(source).toContain("个账号,${failed.length} 个账号授权失败");
expect(source).toContain("closeAccessManagement");
expect(source).not.toContain("memberDialogVisible");
expect(source).not.toContain("shareDialogVisible");
});
it("opens permission settings as a returnable view in the same dialog", () => {
expect(source).toContain('const accessPanel = ref<"overview" | "permissions">("overview")');
expect(source).toContain('@click="openPermissionSettings"');
expect(source).toContain('aria-label="返回访问与权限"');
expect(source).toContain('@click="handleAccessPanelBack"');
expect(source).toContain("const handleAccessPanelBack = () =>");
expect(source).toContain('v-else-if="accessPanel === \'permissions\'" class="access-section access-section--common"');
expect(source).not.toContain("permissionDialogVisible");
});
it("implements edit requests, sheet structure control, and contact ownership transfer", () => {
expect(source).toContain("允许申请编辑权限");
expect(source).toContain("handleEditRequestPermissionChange");
expect(source).toContain("待处理申请");
expect(source).toContain("resolveCollaborationEditRequest");
expect(source).toContain("notifyProjectNotificationsChanged");
expect(source).toContain("route.query.editRequestFile");
expect(source).toContain("openEditRequestNotificationTarget");
expect(source).toContain('class="access-section__pending-count"');
expect(source).toContain('ref="editRequestSectionRef"');
expect(source).toContain("editRequestSectionRef.value?.focus");
const accountAuthorizationIndex = source.indexOf('class="access-section access-section--members"');
const pendingRequestIndex = source.indexOf('class="edit-request-list"');
const permissionSettingsIndex = source.indexOf("v-else-if=\"accessPanel === 'permissions'\"");
expect(pendingRequestIndex).toBeGreaterThan(accountAuthorizationIndex);
expect(pendingRequestIndex).toBeLessThan(permissionSettingsIndex);
expect(source).toContain("允许所有协作者添加、删除工作表");
expect(source).toContain("handleSheetStructurePermissionChange");
expect(source).toContain("转让文档所有权");
expect(source).toContain('v-model="transferDialogVisible"');
expect(source).toContain('title="请选择新的所有者"');
expect(source).toContain('class="ownership-transfer-layout"');
expect(source).toContain('role="listbox" aria-label="可转让联系人"');
expect(source).toContain('v-for="candidate in transferCandidates"');
expect(source).toContain('{{ candidate.role_in_study }}');
expect(source).toContain('{{ selectedTransferCandidate.role_in_study }}');
expect(source).toContain('currentOwnerCandidate?.role_in_study');
expect(source).toContain('class="collaboration-role-tag"');
expect(source).toContain('--el-tag-bg-color: color-mix(in srgb, var(--ctms-primary) 13%, var(--ctms-bg-muted))');
expect(source).toContain('v-if="selectedTransferCandidate"');
expect(source).toContain('description="暂未选择联系人"');
expect(source).toContain(':disabled="!selectedTransferCandidate"');
expect(source).toContain('>确定</el-button>');
expect(source).not.toContain('accessPanel === "transfer"');
expect(source).toContain("transferCollaborationOwnership");
});
it("saves permission switches silently while retaining failure feedback", () => {
expect(source).not.toContain('ElMessage.success("内容操作权限已更新")');
expect(source).not.toContain('ElMessage.success("编辑权限申请设置已更新")');
expect(source).not.toContain('ElMessage.success("工作表结构权限已更新")');
expect(source).toContain('getApiErrorMessage(error, "内容操作权限保存失败")');
expect(source).toContain('getApiErrorMessage(error, "编辑权限申请设置保存失败")');
expect(source).toContain('getApiErrorMessage(error, "工作表结构权限保存失败")');
});
it("configures real immutable link sharing with mutable access settings", () => {
expect(source).toContain('v-model="accessDialogVisible"');
expect(source).toContain("const openAccessManagement = async");
expect(source).toContain("accessFile.value.id");
expect(source).toContain('v-model="shareForm.enabled"');
expect(source).toContain('class="access-setting-status"');
expect(source).toContain('class="share-settings-list"');
expect(source).toContain('class="share-setting-row share-setting-row--password"');
expect(source).toContain('class="share-link-panel"');
expect(source).toContain('class="access-setting-navigation"');
expect(source).toContain("accessPermissionSummary");
expect(source).toContain('v-model="shareForm.access_mode"');
expect(source).toContain('v-model="commonExportEnabled"');
expect(source).toContain('@change="handleCommonExportChange"');
expect(source).toContain("{ allow_export: next }");
expect(source).not.toContain("shareForm.allow_export");
expect(source).toContain('v-model="shareForm.expiry_policy"');
for (const policy of ["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]) {
expect(source).toContain(`value="${policy}"`);
}
expect(source).toContain('v-model="shareForm.password_enabled"');
expect(source).toContain("handleCommonExportChange");
expect(source).toContain("promptSharePassword");
expect(source).toContain('label="可查看" value="VIEW"');
expect(source).toContain('label="可编辑" value="EDIT"');
expect(source).not.toContain("获得链接的人可编辑");
expect(source).not.toContain("获得链接的人可查看");
expect(source).toContain("允许编辑者和匿名访问者下载、打印、另存和复制");
expect(source).not.toContain("系统内账号登录后按文件角色访问");
expect(source).not.toContain("无需 CTMS 账号,通过链接访问文件");
expect(source).not.toContain("同时约束账号授权和匿名访问");
expect(source).toContain('@change="persistShareSettings()"');
expect(source).not.toContain("保存链接设置");
expect(source).toContain("fetchCollaborationShareLink");
expect(source).toContain("updateCollaborationShareLink");
expect(source).not.toContain("regenerateCollaborationShareLink");
expect(source).not.toContain("重新生成链接");
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
expect(source).not.toContain("匿名访问已关闭,之前复制的访问链接无法继续使用");
expect(source).not.toContain("share-link-disabled");
expect(source).not.toContain("修改后自动保存");
expect(source).not.toContain("通过链接邀请外部人员查看或协作编辑");
expect(source).not.toContain("邀请项目内成员并分配协作权限");
expect(source).toContain("copyShareUrl");
expect(source).not.toContain("链接分享暂未开放");
});
it("uses an edge-to-edge compact table with a single row action menu", () => {
expect(source).toContain('size="small"');
expect(source).toContain('label="操作" width="72"');
expect(source).toContain('popper-class="collaboration-row-actions-popper"');
expect(source).toContain('@command="handleFileAction($event, row)"');
expect(source).toContain(".collaboration-library__main { display: flex; min-width: 0; min-height: 0;");
expect(source).toContain("height: 100%;");
expect(source).toContain(".collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto;");
expect(source).not.toContain('label="操作" width="300"');
});
it("opens the same action definitions from a desktop row context menu", () => {
expect(source).toContain('@row-contextmenu="openFileContextMenu"');
expect(source).toContain('v-if="isDesktop"');
expect(source).toContain('ref="fileContextMenuRef"');
expect(source).toContain("fileActions(fileContextMenuRow)");
expect(source).toContain("handleFileContextAction");
expect(source).toContain("event.preventDefault();");
});
it("keeps the fixed operation column background consistent with the table", () => {
expect(source).toContain(".el-table__header-wrapper .el-table-fixed-column--right");
expect(source).toContain("background: var(--unified-table-header-bg) !important;");
expect(source).not.toContain("background: var(--el-table-header-bg-color) !important;");
expect(source).toContain(".el-table__body tr:hover > .el-table-fixed-column--right");
});
it("uses shared theme tokens across the full collaboration canvas", () => {
expect(source).toContain("background: var(--ctms-bg-card);");
expect(source).toContain("background: var(--ctms-bg-base);");
expect(source).toContain("border-right: 1px solid var(--ctms-border-color);");
expect(source).toContain(".folder-item.active { color: var(--ctms-primary); background: var(--ctms-primary-light);");
expect(source).toContain(".collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto; background: var(--ctms-bg-card);");
expect(source).not.toContain("background: #f8fafc;");
});
it("shows collaborators from the file-list response as a compact avatar stack", () => {
expect(source).toContain('label="协作者" width="150"');
expect(source).toContain("row.collaborators");
expect(source).toContain("collaborator-stack");
expect(source).toContain("collaboratorTone(collaborator.user_id)");
});
it("refreshes again after returning from an editor so delayed save callbacks update the list", () => {
expect(source).toContain("const scheduleRevisionRefresh");
expect(source).toContain("[2_500, 12_000]");
expect(source).toContain("onActivated(() =>");
expect(source).toContain("onDeactivated(clearRevisionRefreshTimers)");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationShareWorkspace.vue"), "utf8");
describe("CollaborationShareWorkspace security contract", () => {
it("reads the opaque link from the URL fragment and keeps grants in memory", () => {
expect(source).toContain("window.location.hash.slice(1)");
expect(source).toContain("const accessToken = ref");
expect(source).not.toContain("localStorage");
expect(source).not.toContain("sessionStorage");
expect(source).not.toContain("route.query");
});
it("requires the link password before initializing ONLYOFFICE", () => {
expect(source).toContain("verifyPublicCollaborationSharePassword");
expect(source).toContain("fetchPublicCollaborationEditorConfig");
expect(source).toContain('v-else-if="needsPassword"');
expect(source).toContain(':allow-save-as="false"');
expect(source).toContain(':allow-download="editorConfig.can_download"');
expect(source).toContain(':allow-clipboard="Boolean(metadata?.allow_export)"');
expect(source).toContain("response.data.host_path !== ONLYOFFICE_HOST_PATH");
});
it("downloads export-enabled shares to a user-selected local destination", () => {
expect(source).toContain('@download="handleDownload"');
expect(source).toContain("CollaborationDownloadDialog");
expect(source).toContain('@confirm="confirmDownload"');
expect(source).toContain("downloadDialogVisible.value = true");
expect(source).toContain("const destinationPromise = prepareSaveFile(suggestedName)");
expect(source).toContain('kind: "download"');
});
it("shows view or edit access and destroys sensitive state on unmount", () => {
expect(source).toContain('metadata.access_mode === "edit"');
expect(source).toContain("destroyEditor();");
expect(source).toContain('accessToken.value = ""');
expect(source).toContain('password.value = ""');
});
});
@@ -0,0 +1,368 @@
<template>
<section class="share-workspace">
<header class="share-workspace__header">
<div class="share-workspace__brand">CTMS 在线协作</div>
<div class="share-workspace__title" :title="metadata?.file_name">{{ metadata?.file_name || "共享文件" }}</div>
<span v-if="metadata" class="share-workspace__access" :class="`is-${metadata.access_mode}`">
{{ metadata.access_mode === "edit" ? "可编辑" : "只读查看" }}
</span>
<span v-if="metadata?.expires_at" class="share-workspace__expiry">有效期至 {{ displayDateTime(metadata.expires_at) }}</span>
<span v-else-if="metadata" class="share-workspace__expiry">永久有效</span>
<span class="share-workspace__status" :class="`is-${status}`">{{ statusLabel }}</span>
</header>
<main class="share-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(metadata?.allow_export)"
:allow-save-as="false"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 共享文件:${metadata?.file_name || ''}`"
@ready="status = 'ready'"
@warning="handleWarning"
@error="handleViewerError"
@state-change="handleStateChange"
@download="handleDownload"
@download-error="handleDownloadError"
/>
<div v-else class="share-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="30"><Loading /></el-icon>
<div v-else-if="needsPassword" class="share-password-card">
<div class="share-password-card__icon"><el-icon><Lock /></el-icon></div>
<h1>此链接受密码保护</h1>
<p>{{ metadata?.file_name }}</p>
<el-input
v-model="password"
type="password"
show-password
maxlength="64"
autocomplete="current-password"
placeholder="请输入链接密码"
autofocus
@keyup.enter="submitPassword"
/>
<el-button type="primary" :loading="verifying" @click="submitPassword">打开文件</el-button>
<span v-if="passwordError" class="share-password-card__error">{{ passwordError }}</span>
</div>
<div v-else-if="errorMessage" class="share-error-card">
<h1>无法打开共享文件</h1>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadShare">重新加载</el-button>
</div>
</div>
</main>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Loading, Lock } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import {
fetchPublicCollaborationEditorConfig,
fetchPublicCollaborationShareMetadata,
verifyPublicCollaborationSharePassword,
} from "../../api/collaboration";
import { isTauriRuntime, ONLYOFFICE_HOST_PATH, prepareSaveFile } from "../../runtime";
import { displayDateTime } from "../../utils/display";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import type {
CollaborationEditorConfig,
CollaborationPublicShareMetadata,
} from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type ShareStatus = "loading" | "locked" | "ready" | "saving" | "saved" | "warning" | "error";
const metadata = ref<CollaborationPublicShareMetadata | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const loading = ref(false);
const verifying = ref(false);
const needsPassword = ref(false);
const password = ref("");
const passwordError = ref("");
const errorMessage = ref("");
const status = ref<ShareStatus>("loading");
const warningMessage = ref("");
const accessToken = ref("");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const shareToken = () => {
try {
return decodeURIComponent(window.location.hash.slice(1).trim());
} catch {
return "";
}
};
const clientId = (() => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
})();
const viewerKey = computed(() => `${clientId}:${viewerSequence.value}`);
const statusLabel = computed(() => {
if (downloading.value) return "正在下载";
if (status.value === "locked") return "等待密码";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
if (statusCode === 404) return "链接不存在、已关闭或共享文件已被删除";
if (statusCode === 410) return "共享链接已过期,请联系文件所有者重新分享";
if (statusCode === 429) return "密码尝试次数过多,请稍后再试";
if (statusCode === 503) return "在线协作服务暂时不可用";
return getApiErrorMessage(error, "共享文件加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
};
const loadEditor = async (sequence: number) => {
const response = await fetchPublicCollaborationEditorConfig(shareToken(), {
access_token: accessToken.value || undefined,
client_id: clientId,
display_name: "链接访客",
});
if (requestSequence.value !== sequence) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
editorConfig.value = response.data;
viewerSequence.value += 1;
};
const loadShare = async () => {
const token = shareToken();
if (!token) {
errorMessage.value = "共享链接缺少访问凭证";
status.value = "error";
return;
}
destroyEditor();
const sequence = requestSequence.value;
loading.value = true;
errorMessage.value = "";
passwordError.value = "";
needsPassword.value = false;
status.value = "loading";
try {
const response = await fetchPublicCollaborationShareMetadata(token);
if (requestSequence.value !== sequence) return;
metadata.value = response.data;
needsPassword.value = response.data.requires_password && !accessToken.value;
if (needsPassword.value) {
status.value = "locked";
} else {
await loadEditor(sequence);
}
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const submitPassword = async () => {
if (!password.value || verifying.value) {
if (!password.value) passwordError.value = "请输入链接密码";
return;
}
verifying.value = true;
passwordError.value = "";
try {
const response = await verifyPublicCollaborationSharePassword(shareToken(), password.value);
accessToken.value = response.data.access_token;
password.value = "";
needsPassword.value = false;
status.value = "loading";
loading.value = true;
const sequence = requestSequence.value;
await loadEditor(sequence);
} catch (error) {
const statusCode = Number((error as any)?.response?.status || 0);
passwordError.value = statusCode === 401 ? "链接密码不正确" : await errorForResponse(error);
status.value = statusCode === 401 ? "locked" : "error";
} finally {
verifying.value = false;
loading.value = false;
}
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "共享文件加载或转换失败";
editorConfig.value = null;
status.value = "error";
};
const handleStateChange = (changed: boolean) => {
status.value = changed ? "saving" : "saved";
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const suggestedName = suggestedDownloadName(payload);
// Web
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载共享文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "共享文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "共享文件下载失败");
};
onMounted(loadShare);
onBeforeUnmount(() => {
destroyEditor();
accessToken.value = "";
password.value = "";
});
</script>
<style scoped>
.share-workspace {
display: grid;
grid-template-rows: 48px minmax(0, 1fr);
width: 100vw;
height: 100vh;
height: 100dvh;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #273b50;
background: #f3f5f8;
}
.share-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: auto minmax(120px, 1fr) auto auto auto;
align-items: center;
gap: 12px;
padding: 0 18px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.share-workspace__brand { color: #3d617d; font-size: 14px; font-weight: 700; white-space: nowrap; }
.share-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.share-workspace__access { padding: 3px 9px; border-radius: 11px; color: #52667a; background: #eef2f6; font-size: 12px; white-space: nowrap; }
.share-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.share-workspace__expiry,
.share-workspace__status { color: #738398; font-size: 12px; white-space: nowrap; }
.share-workspace__status.is-ready,
.share-workspace__status.is-saved { color: #25845b; }
.share-workspace__status.is-saving { color: #99651e; }
.share-workspace__status.is-warning,
.share-workspace__status.is-error { color: #b65c29; }
.share-workspace__content { min-width: 0; min-height: 0; overflow: hidden; }
.share-workspace__state { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; padding: 24px; }
.share-password-card,
.share-error-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; gap: 14px; padding: 30px; border: 1px solid #dbe3ec; border-radius: 14px; background: #fff; box-shadow: 0 16px 42px rgba(40, 58, 78, 0.12); text-align: center; }
.share-password-card__icon { display: grid; width: 46px; height: 46px; place-items: center; align-self: center; border-radius: 50%; color: #3f6f91; background: #eaf2f8; font-size: 22px; }
.share-password-card h1,
.share-error-card h1 { margin: 0; color: #26384c; font-size: 19px; }
.share-password-card p,
.share-error-card p { margin: 0 0 4px; color: #75859a; font-size: 13px; }
.share-password-card__error { color: #d14f4f; font-size: 12px; text-align: left; }
@media (max-width: 720px) {
.share-workspace__header { grid-template-columns: minmax(0, 1fr) auto auto; padding: 0 10px; }
.share-workspace__brand,
.share-workspace__expiry { display: none; }
}
</style>
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationWorkspace.vue"), "utf8");
const globalStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
describe("CollaborationWorkspace Save As contract", () => {
it("separates signed Save As and download capabilities", () => {
expect(source).toContain(':allow-save-as="editorConfig.can_save_as"');
expect(source).toContain(':allow-download="editorConfig.can_download"');
expect(source).toContain(':allow-clipboard="Boolean(editorConfig.can_download)"');
expect(source).toContain('@save-as="handleSaveAs"');
expect(source).toContain('@download="handleDownload"');
expect(source).toContain("!editorConfig.value?.can_save_as");
expect(source).toContain("!editorConfig.value?.can_download");
});
it("reuses the workspace picker and imports Save As bytes as a new file", () => {
expect(source).toContain("CollaborationSaveAsDialog");
expect(source).toContain('@confirm="submitSaveAs"');
expect(source).toContain("const form = new FormData()");
expect(source).toContain('form.append("folder_id", selection.folderId)');
expect(source).toContain("importCollaborationFile(studyId.value, form)");
});
it("asks for a local destination from a user-confirmed web dialog and records the completed download", () => {
expect(source).toContain("CollaborationDownloadDialog");
expect(source).toContain('@confirm="confirmDownload"');
expect(source).toContain("downloadDialogVisible.value = true");
expect(source).toContain("const destinationPromise = prepareSaveFile(suggestedName)");
expect(source).toContain("saveFileWithFeedback(");
expect(source).toContain('kind: "download"');
expect(source).toContain("recordCollaborationDownload(studyId.value, fileId.value, extension)");
});
it("submits ONLYOFFICE edit-right requests without granting edit locally", () => {
expect(source).toContain('@request-edit-rights="requestEditRights"');
expect(source).toContain("createCollaborationEditRequest(studyId.value, fileId.value)");
expect(source).toContain('edit_request_status: "PENDING"');
expect(source).toContain("编辑权限申请处理中");
});
it("keeps the plain edit-request action visually distinct from solid primary buttons", () => {
expect(source).toContain('type="primary"');
expect(source).toContain("plain");
expect(source).toContain("申请编辑权限");
expect(globalStyles).toContain(".el-button--primary:not(.is-plain):not(.is-link):not(.is-text)");
expect(globalStyles).not.toMatch(/\.el-button--primary\s*\{[^}]*background-color:/);
});
it("provides an accessible web fullscreen control without duplicating native desktop controls", () => {
expect(source).toContain('v-if="showWorkspaceFullscreenControl"');
expect(source).toContain("computed(() => webFullscreenAvailable.value && !isTauriRuntime())");
expect(source).toContain('class="collaboration-workspace__fullscreen"');
expect(source).toContain(':aria-label="webFullscreenLabel"');
expect(source).toContain(':aria-pressed="webFullscreenActive"');
expect(source).toContain("toggleWebFullscreen");
expect(source).toContain("listenWebFullscreenChange(syncWebFullscreenState)");
expect(source).toContain("await exitWorkspaceFullscreen()");
});
it("reserves the macOS traffic-light safe area only outside native fullscreen", () => {
expect(source).toContain("'is-macos-desktop': isMacDesktop");
expect(source).toContain('isTauriRuntime() && getRuntimePlatform() === "macos"');
expect(source).toContain(".collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header");
expect(source).toContain("padding-left: 72px;");
});
it("keeps the shared web and desktop document bar compact", () => {
expect(source).toContain("grid-template-rows: 36px minmax(0, 1fr);");
expect(source).toContain("grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;");
expect(source).toContain("width: 28px;");
expect(source).toContain("height: 28px;");
});
it("reduces the fullscreen CTMS header to an opaque auto-hiding control", () => {
expect(source).toContain("'is-fullscreen': webFullscreenActive");
expect(source).toContain("'is-fullscreen-toolbar-visible': fullscreenToolbarVisible");
expect(source).toContain('class="collaboration-workspace__fullscreen-hotzone"');
expect(source).toContain('@pointerenter="showFullscreenToolbar"');
expect(source).toContain("fullscreenToolbarHideTimer = window.setTimeout");
expect(source).toContain("}, 900);");
expect(source).toContain('const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);');
expect(source).toContain(".collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }");
expect(source).toContain("width: 88px;");
expect(source).toContain("transform: translateY(calc(-100% - 12px));");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen)");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible)");
expect(source).toContain("background: #fff;");
expect(source).toContain("opacity: 1;");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }");
});
});
@@ -0,0 +1,617 @@
<template>
<section
class="collaboration-workspace"
:class="{
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
}"
>
<div
v-if="webFullscreenActive"
class="collaboration-workspace__fullscreen-hotzone"
aria-hidden="true"
@pointerenter="showFullscreenToolbar"
@pointerleave="scheduleFullscreenToolbarHide"
/>
<header
class="collaboration-workspace__header"
@pointerenter="cancelFullscreenToolbarHide"
@pointerleave="scheduleFullscreenToolbarHide"
@focusin="showFullscreenToolbar"
@focusout="scheduleFullscreenToolbarHide"
>
<button class="collaboration-workspace__back" type="button" title="关闭协作文件并返回" @click="goBack"></button>
<div class="collaboration-workspace__title" :title="fileName">{{ fileName }}</div>
<div class="collaboration-workspace__access" :class="`is-${accessMode}`">
{{ accessMode === "edit" ? "协作编辑" : "只读查看" }}
</div>
<div class="collaboration-workspace__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button
v-if="accessMode === 'view' && file?.edit_request_status === 'PENDING'"
size="small"
disabled
>编辑权限申请处理中</el-button>
<el-button
v-else-if="accessMode === 'view' && (file?.can_request_edit || editorConfig?.can_request_edit)"
size="small"
type="primary"
plain
:loading="requestingEdit"
@click="requestEditRights"
>申请编辑权限</el-button>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadWorkspace">重试</el-button>
<el-tooltip v-if="showWorkspaceFullscreenControl" :content="webFullscreenLabel" placement="bottom">
<button
type="button"
class="collaboration-workspace__fullscreen"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
</header>
<main class="collaboration-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(editorConfig.can_download)"
:allow-save-as="editorConfig.can_save_as"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 在线协作:${fileName}`"
@ready="handleReady"
@warning="handleWarning"
@error="handleError"
@state-change="handleStateChange"
@save-as="handleSaveAs"
@save-as-error="handleSaveAsError"
@download="handleDownload"
@download-error="handleDownloadError"
@request-edit-rights="requestEditRights"
/>
<div v-else class="collaboration-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<strong>无法打开协作文件</strong>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadWorkspace">重新加载</el-button>
</template>
</div>
</main>
<CollaborationSaveAsDialog
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
:initial-folder-id="file?.folder_id || null"
:saving="savingCopy"
:can-create-folder="canCreateWorkspaceFile"
@confirm="submitSaveAs"
/>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { FullScreen, Loading, ScaleToOriginal } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import CollaborationSaveAsDialog from "../../components/collaboration/CollaborationSaveAsDialog.vue";
import { workspaceTaskControllerKey } from "../../components/layout/workspaceTaskController";
import {
fetchCollaborationEditorConfig,
fetchCollaborationFile,
createCollaborationEditRequest,
importCollaborationFile,
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
isWebFullscreenActive,
isWebFullscreenAvailable,
isTauriRuntime,
listenWebFullscreenChange,
ONLYOFFICE_HOST_PATH,
prepareSaveFile,
refreshWebFullscreenState,
toggleWebFullscreen,
} from "../../runtime";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { usePermission } from "../../utils/permission";
import type { CollaborationEditorConfig, CollaborationFile } from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type WorkspaceStatus = "loading" | "ready" | "saving" | "saved" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const file = ref<CollaborationFile | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
const loading = ref(false);
const status = ref<WorkspaceStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const savingCopy = ref(false);
const requestingEdit = ref(false);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
const saveAsDialogVisible = ref(false);
const saveAsTitle = ref("");
const pendingSaveAs = ref<OnlyOfficeSaveAsPayload | null>(null);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const fullscreenToolbarVisible = ref(false);
const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const studyId = computed(() => study.currentStudy?.id || "");
const fileId = computed(() => String(route.params.fileId || ""));
const fileName = computed(() => editorConfig.value?.file_name || file.value?.title || "在线协作");
const accessMode = computed(() => editorConfig.value?.access_mode || (file.value?.can_edit ? "edit" : "view"));
const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);
const canCreateWorkspaceFile = computed(() =>
["read", "create", "edit", "manage", "export", "delete"].every((action) =>
can(`collaboration.${action}`),
),
);
const statusLabel = computed(() => {
if (savingCopy.value) return "正在另存为";
if (downloading.value) return "正在下载";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const showWorkspaceFullscreenControl = computed(() => webFullscreenAvailable.value && !isTauriRuntime());
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const cancelFullscreenToolbarHide = () => {
if (fullscreenToolbarHideTimer !== undefined) window.clearTimeout(fullscreenToolbarHideTimer);
fullscreenToolbarHideTimer = undefined;
};
const showFullscreenToolbar = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = true;
};
const scheduleFullscreenToolbarHide = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarHideTimer = window.setTimeout(() => {
fullscreenToolbarVisible.value = false;
fullscreenToolbarHideTimer = undefined;
}, 900);
};
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
const active = isWebFullscreenActive();
if (active !== webFullscreenActive.value) {
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = false;
}
webFullscreenActive.value = active;
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
} finally {
syncWebFullscreenState();
}
};
const exitWorkspaceFullscreen = async () => {
try {
if (!await refreshWebFullscreenState()) return;
await toggleWebFullscreen();
} catch {
// 退
} finally {
syncWebFullscreenState();
}
};
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "在线协作服务尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "在线协作服务暂时不可用";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有查看此协作文件的权限";
if (statusCode === 404) return "协作文件不存在或已被移入回收站";
return getApiErrorMessage(error, "在线协作编辑器加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
savingCopy.value = false;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
};
const loadWorkspace = async () => {
if (!studyId.value || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
requestSequence.value = sequence;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const [fileResponse, configResponse] = await Promise.all([
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
const code = detail.errorCode ?? detail.warningCode ?? detail.code;
return code === undefined ? "" : `错误代码:${String(code)}`;
};
const handleReady = () => { status.value = "ready"; };
const handleStateChange = (changed: boolean) => { status.value = changed ? "saving" : "saved"; };
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "协作文件加载或转换失败";
status.value = "error";
editorConfig.value = null;
};
const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
if (editorConfig.value) editorConfig.value = { ...editorConfig.value, can_request_edit: false };
ElMessage.success("编辑权限申请已提交");
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "编辑权限申请失败"));
} finally {
requestingEdit.value = false;
}
};
const handleSaveAs = (payload: OnlyOfficeSaveAsPayload) => {
if (savingCopy.value || !editorConfig.value?.can_save_as) return;
const extension = payload.fileType.toLowerCase();
saveAsTitle.value = payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
pendingSaveAs.value = payload;
saveAsDialogVisible.value = true;
};
const submitSaveAs = async (selection: { title: string; folderId: string | null }) => {
const payload = pendingSaveAs.value;
if (!payload || savingCopy.value || !editorConfig.value?.can_save_as) return;
savingCopy.value = true;
try {
const extension = payload.fileType.toLowerCase();
const targetTitle = selection.title.toLowerCase().endsWith(`.${extension}`)
? selection.title
: `${selection.title}.${extension}`;
const form = new FormData();
form.append("file", new File([payload.data], targetTitle, {
type: payload.mimeType || "application/octet-stream",
lastModified: Date.now(),
}));
if (selection.folderId) form.append("folder_id", selection.folderId);
const { data } = await importCollaborationFile(studyId.value, form);
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
ElMessage.success(`已在工作区创建“${data.title}`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件另存为失败"));
} finally {
savingCopy.value = false;
}
};
const handleSaveAsError = (message: string) => {
ElMessage.error(message || "协作文件另存为失败");
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const extension = payload.fileType.toLowerCase();
const suggestedName = suggestedDownloadName(payload);
// Web
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载协作文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
try {
await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "协作文件下载失败");
};
const handleServerChange = () => {
destroyEditor();
loading.value = false;
errorMessage.value = "服务器地址已切换,请重新加载协作文件";
status.value = "error";
};
const goBack = async () => {
await exitWorkspaceFullscreen();
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
onMounted(() => {
mounted = true;
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadWorkspace();
});
onActivated(() => {
syncWebFullscreenState();
if (mounted && !editorConfig.value && !loading.value && !errorMessage.value) void loadWorkspace();
});
onDeactivated(() => {
void exitWorkspaceFullscreen();
destroyEditor();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.collaboration-workspace {
position: relative;
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
top: 0;
left: 0;
width: 88px;
height: 14px;
}
.collaboration-workspace__fullscreen-hotzone::after {
position: absolute;
top: 3px;
left: 32px;
width: 24px;
height: 3px;
border-radius: 999px;
background: rgba(38, 56, 76, 0.34);
content: "";
}
.collaboration-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;
align-items: center;
gap: 8px;
padding: 0 12px 0 6px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header {
padding-left: 72px;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header {
position: absolute;
z-index: 21;
top: 6px;
left: 6px;
display: flex;
width: auto;
height: 34px;
gap: 2px;
padding: 0 4px;
border: 1px solid #dce3eb;
border-radius: 11px;
background: #fff;
opacity: 0;
pointer-events: none;
transform: translateY(calc(-100% - 12px));
box-shadow: 0 8px 24px rgba(20, 32, 51, 0.16);
transition: opacity 150ms ease, transform 180ms ease;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen) {
display: none;
}
.collaboration-workspace.is-fullscreen.is-fullscreen-toolbar-visible .collaboration-workspace__header,
.collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible) {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.collaboration-workspace__back {
width: 28px;
height: 28px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 26px/1 system-ui, sans-serif;
cursor: pointer;
}
.collaboration-workspace__back:hover { background: #edf2f7; }
.collaboration-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.collaboration-workspace__access,
.collaboration-workspace__status { font-size: 12px; white-space: nowrap; }
.collaboration-workspace__access { padding: 3px 8px; border-radius: 10px; color: #52667a; background: #eef2f6; }
.collaboration-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.collaboration-workspace__status { color: #738398; }
.collaboration-workspace__status.is-saving { color: #99651e; }
.collaboration-workspace__fullscreen {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
cursor: pointer;
font-size: 16px;
}
.collaboration-workspace__fullscreen:hover { color: #237451; background: #edf5f1; }
.collaboration-workspace__fullscreen:focus-visible { outline: 2px solid rgba(35, 116, 81, 0.3); outline-offset: 1px; }
.collaboration-workspace__status.is-saved,
.collaboration-workspace__status.is-ready { color: #25845b; }
.collaboration-workspace__status.is-warning,
.collaboration-workspace__status.is-error { color: #b65c29; }
.collaboration-workspace__content { min-height: 0; overflow: hidden; }
.collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }
.collaboration-workspace__state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 100%; height: 100%; color: #718096; text-align: center; }
.collaboration-workspace__state strong { color: #2f4054; font-size: 17px; }
.collaboration-workspace__state p { margin: 0; }
</style>