功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
Cheng Zhou
2026-07-14 14:19:17 +08:00
parent 44db5db838
commit c68dddfc01
50 changed files with 2429 additions and 677 deletions
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS Office Preview</title>
<style>
html,
body,
#onlyoffice-editor {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: #fff;
}
</style>
</head>
<body>
<div id="onlyoffice-editor" aria-label="Office 文档预览"></div>
<script src="/onlyoffice-host.js" defer></script>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
(() => {
"use strict";
const MESSAGE = Object.freeze({
HOST_READY: "ctms.onlyoffice.host-ready",
INIT: "ctms.onlyoffice.init",
DOCUMENT_READY: "ctms.onlyoffice.document-ready",
WARNING: "ctms.onlyoffice.warning",
ERROR: "ctms.onlyoffice.error",
});
const API_SCRIPT_PATH = "/onlyoffice/web-apps/apps/api/documents/api.js";
const ALLOWED_TAURI_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
]);
let initialized = false;
let editor = null;
let parentOrigin = null;
let requestNonce = null;
let apiScriptPromise = null;
let readyTimer = null;
let readyDeadlineTimer = null;
const isLoopbackOrigin = (origin) => {
try {
const url = new URL(origin);
return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
} catch {
return false;
}
};
const isAllowedParentOrigin = (origin) =>
origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(origin);
const postToParent = (type, detail) => {
if (!parentOrigin || !requestNonce) return;
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin);
};
const safeEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : event;
if (!raw || typeof raw !== "object") return {};
const detail = {};
if (typeof raw.errorCode === "number" || typeof raw.errorCode === "string") detail.errorCode = raw.errorCode;
if (typeof raw.errorDescription === "string") detail.errorDescription = raw.errorDescription.slice(0, 500);
if (typeof raw.warningCode === "number" || typeof raw.warningCode === "string") detail.warningCode = raw.warningCode;
if (typeof raw.warningDescription === "string") detail.warningDescription = raw.warningDescription.slice(0, 500);
return detail;
};
const loadOnlyOfficeApi = () => {
if (window.DocsAPI?.DocEditor) return Promise.resolve();
if (apiScriptPromise) return apiScriptPromise;
apiScriptPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = API_SCRIPT_PATH;
script.async = true;
script.addEventListener("load", () => {
if (window.DocsAPI?.DocEditor) resolve();
else reject(new Error("ONLYOFFICE API 未正确加载"));
}, { once: true });
script.addEventListener("error", () => reject(new Error("ONLYOFFICE API 加载失败")), { once: true });
document.head.appendChild(script);
});
return apiScriptPromise;
};
const destroyEditor = () => {
if (editor && typeof editor.destroyEditor === "function") {
try {
editor.destroyEditor();
} catch {
/* The remote editor may already have been torn down. */
}
}
editor = null;
};
const stopReadyAnnouncements = () => {
if (readyTimer !== null) window.clearInterval(readyTimer);
if (readyDeadlineTimer !== null) window.clearTimeout(readyDeadlineTimer);
readyTimer = null;
readyDeadlineTimer = null;
};
const announceReady = () => {
if (!initialized) window.parent.postMessage({ type: MESSAGE.HOST_READY }, "*");
};
const initializeEditor = async (message) => {
const config = message?.config;
if (!config || typeof config !== "object" || typeof message?.nonce !== "string" || !message.nonce) {
throw new Error("预览配置格式不正确");
}
requestNonce = message.nonce;
await loadOnlyOfficeApi();
const editorConfig = {
...config,
events: {
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
},
};
editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig);
};
window.addEventListener("message", async (event) => {
if (event.source !== window.parent || initialized || !isAllowedParentOrigin(event.origin)) return;
if (event.data?.type !== MESSAGE.INIT) return;
initialized = true;
stopReadyAnnouncements();
parentOrigin = event.origin;
requestNonce = typeof event.data?.nonce === "string" ? event.data.nonce : null;
try {
await initializeEditor(event.data);
} catch (error) {
postToParent(MESSAGE.ERROR, {
errorDescription: error instanceof Error ? error.message.slice(0, 500) : "Office 预览初始化失败",
});
}
});
window.addEventListener("pagehide", () => {
stopReadyAnnouncements();
destroyEditor();
}, { once: true });
announceReady();
readyTimer = window.setInterval(announceReady, 250);
readyDeadlineTimer = window.setTimeout(stopReadyAnnouncements, 15_000);
})();
@@ -30,6 +30,12 @@ const walk = async (directory) => {
const permissionIdentifier = (permission) =>
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
const toPosixPath = (path) => path.split("\\").join("/");
const cspDirective = (csp, name) =>
csp
.split(";")
.map((directive) => directive.trim().split(/\s+/))
.find(([directiveName]) => directiveName === name)
?.slice(1) || [];
const assertPathScope = (permission, expectedPrefix, description) => {
const allow = Array.isArray(permission.allow) ? permission.allow : [];
@@ -70,6 +76,17 @@ const verifyTauriConfig = async () => {
"Tauri CSP must not use wildcard sources.",
);
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
const scriptSources = cspDirective(csp, "script-src");
const frameSources = cspDirective(csp, "frame-src");
assert(
scriptSources.length === 1 && scriptSources[0] === "'self'",
"Tauri script-src must remain self-only when ONLYOFFICE preview is enabled.",
);
assert(
JSON.stringify(frameSources) ===
JSON.stringify(["'self'", "blob:", "https:", "http://localhost:*", "http://127.0.0.1:*"]),
"Tauri frame-src may only add HTTPS and local development servers for the isolated ONLYOFFICE host.",
);
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled.");
@@ -123,6 +140,11 @@ const verifyCapabilities = async () => {
for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file));
assert(!capability.remote, `${file}: remote origins must not receive Tauri capabilities.`);
assert(
Array.isArray(capability.windows) && capability.windows.length === 1 && capability.windows[0] === "main",
`${file}: capabilities must remain scoped to the main local window.`,
);
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
@@ -165,6 +187,30 @@ const verifyCapabilities = async () => {
}
};
const verifyOnlyOfficeBoundary = async () => {
const runtimeSource = await readFile(resolve(sourceDir, "runtime/onlyoffice.ts"), "utf8");
const pageSource = await readFile(resolve(sourceDir, "views/OfficePreviewWorkspace.vue"), "utf8");
const viewerSource = await readFile(resolve(sourceDir, "components/OnlyOfficeViewer.vue"), "utf8");
const apiSource = await readFile(resolve(sourceDir, "api/onlyoffice.ts"), "utf8");
const hostSource = await readFile(resolve(frontendDir, "public/onlyoffice-host.js"), "utf8");
assert(runtimeSource.includes('ONLYOFFICE_HOST_PATH = "/onlyoffice-host.html"'), "ONLYOFFICE host path must remain fixed.");
assert(runtimeSource.includes("resolveApiBaseUrl()"), "ONLYOFFICE host must derive from the validated runtime server base URL.");
assert(!runtimeSource.includes("url:"), "ONLYOFFICE runtime URL resolver must not accept a business-provided URL.");
assert(pageSource.includes("response.data.host_path !== ONLYOFFICE_HOST_PATH"), "ONLYOFFICE config host path must be checked against the fixed host path.");
assert(pageSource.includes("onDeactivated") && pageSource.includes("destroyViewer()"), "ONLYOFFICE viewer must be destroyed when a desktop task is deactivated.");
assert(viewerSource.includes("event.source !== frameWindow"), "ONLYOFFICE bridge must validate the message source window.");
assert(viewerSource.includes("event.origin !== hostUrl.origin"), "ONLYOFFICE bridge must validate message origin.");
assert(viewerSource.includes("message.nonce !== nonce"), "ONLYOFFICE bridge must validate its in-memory nonce.");
assert(hostSource.includes("event.source !== window.parent"), "ONLYOFFICE host must only accept parent-window messages.");
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(!/\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.");
};
const verifyRustBoundary = async () => {
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
@@ -445,6 +491,7 @@ await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary();
await verifyOnlyOfficeBoundary();
await verifyWorkflowGates();
if (failures.length > 0) {
+24
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
const main = readFileSync("src/styles/main.css", "utf8");
const unified = readFileSync("src/styles/unified-page.css", "utf8");
const webLayout = readFileSync("src/components/WebLayout.vue", "utf8");
const requiredMainTokens = [
"--ctms-primary",
@@ -31,6 +32,29 @@ const missing = [
...requiredUnifiedTokens.filter((t) => !unified.includes(t))
];
const requiredWebEdgeTokens = [
".web-layout-container .ctms-route-shell > *",
".web-layout-container .ctms-route-shell > .page > .table-card"
];
for (const token of requiredWebEdgeTokens) {
if (!main.includes(token)) {
missing.push(`src/styles/main.css:${token}`);
}
}
if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*padding:\s*0;/s.test(webLayout)) {
missing.push("src/components/WebLayout.vue:web content wrapper edge alignment");
}
if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;/s.test(webLayout)) {
missing.push("src/components/WebLayout.vue:web content height chain");
}
if (webLayout.includes("padding: 18px 24px 24px;") || webLayout.includes("padding: 22px 32px 32px;")) {
missing.push("src/components/WebLayout.vue:large-screen content padding override");
}
const pageContractChecks = [
{
file: "src/views/ia/ProjectMilestones.vue",
+1 -1
View File
@@ -27,7 +27,7 @@
}
],
"security": {
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
"csp": "default-src 'self' customprotocol: asset:; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob: https: http://localhost:* http://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'self'"
}
},
"bundle": {
+23
View File
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
vi.mock("./axios", () => ({ apiGet }));
describe("ONLYOFFICE config api", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["attachment", "fetchAttachmentOnlyOfficeConfig", "/api/v1/onlyoffice/attachments/resource-1/config"],
["version", "fetchVersionOnlyOfficeConfig", "/api/v1/onlyoffice/versions/resource-1/config"],
] as const)("loads %s config without caching or request dedupe", async (_type, exportName, path) => {
const api = await import("./onlyoffice");
api[exportName]("resource-1");
expect(apiGet).toHaveBeenCalledWith(path, {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import { apiGet } from "./axios";
import type { OnlyOfficePreviewConfig } from "../types/onlyoffice";
const noStoreConfig = {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
} as const;
export const fetchAttachmentOnlyOfficeConfig = (attachmentId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/attachments/${attachmentId}/config`, noStoreConfig);
export const fetchVersionOnlyOfficeConfig = (versionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig);
@@ -36,7 +36,8 @@ describe("account connection status", () => {
expect(component).toContain("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)");
expect(component).toContain("sessionClockTimer !== undefined");
expect(component).toContain("window.clearInterval(sessionClockTimer)");
expect(component).toContain("不代表系统健康评分");
expect(component).not.toContain("连接状态仅表示会话与后端通信");
expect(component).not.toContain("connection-details-note");
expect(component).not.toContain("ip_address");
expect(component).not.toContain("access_token");
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
@@ -83,7 +83,6 @@
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
<p class="connection-details-note">连接状态仅表示会话与后端通信不代表系统健康评分</p>
</div>
</template>
@@ -491,13 +490,6 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.connection-details-note {
margin: 9px 0 0;
color: #8a98aa;
font-size: 10px;
line-height: 1.45;
}
:global([data-ctms-theme="dark"]) .account-connection-details {
color: #dbe5f1;
}
+63 -6
View File
@@ -229,7 +229,7 @@
<kbd>K</kbd>
</button>
<button class="icon-button" type="button" :title="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" @click="toggleCurrentFavorite">
<button v-if="canFavoriteCurrentRoute" class="icon-button" type="button" :title="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" @click="toggleCurrentFavorite">
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
</button>
@@ -424,7 +424,7 @@
</el-dropdown>
</div>
<main class="desktop-content">
<main class="desktop-content" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="desktop-route" mode="out-in">
<KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
@@ -481,7 +481,7 @@
@click.stop
>
<button type="button" @click="navigateWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">切换到标签</button>
<button type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
<button v-if="canFavoriteWorkspaceTab(workspaceTabMenu.tab)" type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
{{ isWorkspaceTabFavorited(workspaceTabMenu.tab) ? "取消收藏模块" : "收藏模块" }}
</button>
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
@@ -495,7 +495,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDown,
@@ -598,6 +598,7 @@ import {
resolveWorkspaceTabContextTitle,
type WorkspaceTabContext,
} from "./layout/workspaceTabTitle";
import { workspaceTaskControllerKey } from "./layout/workspaceTaskController";
const auth = useAuthStore();
const study = useStudyStore();
@@ -636,11 +637,13 @@ const desktopSidebarVisible = ref(readDesktopSidebarVisible());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
const workspaceTaskOrigins = ref<Record<string, string>>({});
const workspaceTabsOverflowOpen = ref(false);
const workspaceTabsSearchQuery = ref("");
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
const lastClosedWorkspaceTaskOrigin = ref<string | null>(null);
const workspaceTabMenu = ref<{
visible: boolean;
x: number;
@@ -796,6 +799,13 @@ const currentWorkspaceContext = computed<WorkspaceTabContext | null>(() => {
});
const currentDesktopRoutePreference = computed(() => {
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
if (!item && route.meta.transientWorkspaceTask) {
return {
path: route.path,
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "文档预览"),
group: "文档预览",
};
}
if (!item) return null;
return {
path: item.path,
@@ -838,6 +848,7 @@ const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
});
const canFavoriteCurrentRoute = computed(() => !route.meta.transientWorkspaceTask && Boolean(currentDesktopRoutePreference.value));
const desktopToolbarTitle = computed(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return String(route.meta?.title || TEXT.common.appName);
@@ -947,6 +958,13 @@ const removeWorkspaceTabContext = (tabPath: string) => {
workspaceTabContexts.value = next;
};
const removeWorkspaceTaskOrigin = (tabPath: string) => {
if (!workspaceTaskOrigins.value[tabPath]) return;
const next = { ...workspaceTaskOrigins.value };
delete next[tabPath];
workspaceTaskOrigins.value = next;
};
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
};
@@ -1009,6 +1027,9 @@ const navigateWorkspaceTabFromMenu = (path: string) => {
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
const canFavoriteWorkspaceTab = (tab: DesktopRoutePreference) =>
desktopNavigationItems.value.some((item) => item.path === tab.path);
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
refreshDesktopRoutePreferences();
@@ -1041,15 +1062,19 @@ const dropWorkspaceTab = (targetPath: string, event: DragEvent) => {
const closeWorkspaceTab = (path: string) => {
const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
const originPath = workspaceTaskOrigins.value[path];
if (index >= 0) {
lastClosedWorkspaceTab.value = workspaceTabs.value[index];
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[path] || createWorkspaceTabHistory(path);
lastClosedWorkspaceTaskOrigin.value = originPath || null;
}
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
removeWorkspaceTabHistory(path);
removeWorkspaceTabContext(path);
removeWorkspaceTaskOrigin(path);
if (activeMenu.value !== path) return;
const next =
workspaceTabs.value.find((tab) => tab.path === originPath) ||
workspaceTabs.value[index] ||
workspaceTabs.value[index - 1] ||
desktopFavoriteRoutes.value.find((item) => item.path !== path);
@@ -1060,6 +1085,15 @@ const closeWorkspaceTab = (path: string) => {
}
};
const closeTransientWorkspaceTask = (path: string) => {
if (!route.meta.transientWorkspaceTask || activeMenu.value !== path) return false;
if (!workspaceTaskOrigins.value[path] && workspaceTabs.value.length <= 1) return false;
closeWorkspaceTab(path);
return true;
};
provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });
const closeWorkspaceTabFromMenu = (path: string) => {
closeWorkspaceTab(path);
closeWorkspaceTabMenu();
@@ -1073,6 +1107,8 @@ const closeOtherWorkspaceTabs = (path: string) => {
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
const targetContext = workspaceTabContexts.value[path];
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
const targetOrigin = workspaceTaskOrigins.value[path];
workspaceTaskOrigins.value = targetOrigin ? { [path]: targetOrigin } : {};
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
closeWorkspaceTabMenu();
};
@@ -1082,10 +1118,12 @@ const closeAllWorkspaceTabs = () => {
if (activeTab) {
lastClosedWorkspaceTab.value = activeTab;
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
lastClosedWorkspaceTaskOrigin.value = workspaceTaskOrigins.value[activeTab.path] || null;
}
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
workspaceTaskOrigins.value = {};
workspaceTabsSearchQuery.value = "";
closeWorkspaceTabMenu();
@@ -1108,9 +1146,16 @@ const restoreLastClosedWorkspaceTab = () => {
if (lastClosedWorkspaceTabHistory.value) {
setWorkspaceTabHistory(tab.path, lastClosedWorkspaceTabHistory.value);
}
if (lastClosedWorkspaceTaskOrigin.value) {
workspaceTaskOrigins.value = {
...workspaceTaskOrigins.value,
[tab.path]: lastClosedWorkspaceTaskOrigin.value,
};
}
void router.push(workspaceTabDestination(tab.path));
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
lastClosedWorkspaceTaskOrigin.value = null;
closeWorkspaceTabMenu();
};
@@ -1140,8 +1185,10 @@ const openDesktopProjectEntry = async () => {
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
workspaceTaskOrigins.value = {};
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
lastClosedWorkspaceTaskOrigin.value = null;
await router.push("/desktop/project-entry");
};
@@ -1266,7 +1313,7 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
id: "desktop:favorite-current",
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
group: "桌面",
visible: Boolean(currentDesktopRoutePreference.value),
visible: canFavoriteCurrentRoute.value,
run: toggleCurrentFavorite,
},
{
@@ -1393,8 +1440,14 @@ watch(
}
const current = currentDesktopRoutePreference.value;
if (current) {
addWorkspaceTab(current);
const previousTabPath = previousRoute ? getActiveLayoutPath(previousRoute.path) : "";
if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path) {
workspaceTaskOrigins.value = {
...workspaceTaskOrigins.value,
[current.path]: previousTabPath,
};
}
addWorkspaceTab(current);
const replacesCurrentTabEntry =
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
if (replacesCurrentTabEntry) {
@@ -2680,6 +2733,10 @@ useDesktopShortcuts(
background: #eef2f6;
}
.desktop-content.is-full-bleed {
overflow: hidden;
}
.desktop-route-shell {
width: 100%;
max-width: 100%;
+24 -26
View File
@@ -1,5 +1,5 @@
<template>
<div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
<div class="faq-list-card faq-list-card--workbench">
<el-table
:data="items"
v-loading="loading"
@@ -7,7 +7,7 @@
class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName"
:height="isDesktop ? '100%' : undefined"
height="100%"
@row-click="onRowClick"
table-layout="fixed"
>
@@ -61,7 +61,6 @@ import { deleteFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
import { displayDateTime, displayEnum } from "../utils/display";
import { TEXT } from "../locales";
import { isTauriRuntime } from "../runtime";
const props = defineProps<{
items: FaqItem[];
@@ -73,7 +72,6 @@ const props = defineProps<{
const emit = defineEmits(["refresh"]);
const router = useRouter();
const isDesktop = isTauriRuntime();
const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -271,7 +269,7 @@ const remove = async (row: FaqItem) => {
cursor: pointer;
}
.faq-list-card--desktop {
.faq-list-card--workbench {
display: flex;
height: 100%;
min-height: 0;
@@ -279,17 +277,17 @@ const remove = async (row: FaqItem) => {
background: #ffffff;
}
.faq-list-card--desktop .faq-table {
.faq-list-card--workbench .faq-table {
height: 100%;
min-height: 0;
flex: 1 1 auto;
}
.faq-list-card--desktop .faq-table :deep(.el-table__inner-wrapper) {
.faq-list-card--workbench .faq-table :deep(.el-table__inner-wrapper) {
height: 100%;
}
.faq-list-card--desktop .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
.faq-list-card--workbench .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
@@ -299,37 +297,37 @@ const remove = async (row: FaqItem) => {
font-weight: 850;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body) {
.faq-list-card--workbench .faq-table :deep(.el-table__body) {
border-collapse: separate;
border-spacing: 0 6px;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper) {
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper) {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap) {
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap) {
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__bar.is-vertical) {
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__bar.is-vertical) {
display: none !important;
}
.faq-list-card--desktop .faq-table :deep(.el-table__empty-block) {
.faq-list-card--workbench .faq-table :deep(.el-table__empty-block) {
min-height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell) {
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell) {
padding: 10px 14px;
border-top: 1px solid rgba(226, 235, 246, 0.92);
border-bottom: 1px solid rgba(226, 235, 246, 0.92);
@@ -337,27 +335,27 @@ const remove = async (row: FaqItem) => {
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:first-child) {
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:first-child) {
border-left: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 9px 0 0 9px;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:last-child) {
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:last-child) {
border-right: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 0 9px 9px 0;
}
.faq-list-card--desktop .faq-table :deep(.faq-row:hover td.el-table__cell) {
.faq-list-card--workbench .faq-table :deep(.faq-row:hover td.el-table__cell) {
border-color: rgba(111, 159, 220, 0.34);
background: #f5faff;
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
}
.faq-list-card--desktop .question-cell {
.faq-list-card--workbench .question-cell {
gap: 10px;
}
.faq-list-card--desktop .question-icon {
.faq-list-card--workbench .question-icon {
width: 28px;
height: 28px;
border-radius: 8px;
@@ -367,13 +365,13 @@ const remove = async (row: FaqItem) => {
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.faq-list-card--desktop .question-link {
.faq-list-card--workbench .question-link {
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.faq-list-card--desktop .category-pill {
.faq-list-card--workbench .category-pill {
height: 24px;
padding: 0 9px;
border-radius: 7px;
@@ -381,17 +379,17 @@ const remove = async (row: FaqItem) => {
font-size: 12px;
}
.faq-list-card--desktop .time-text {
.faq-list-card--workbench .time-text {
gap: 0;
font-size: 11px;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row td.el-table__cell) {
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row td.el-table__cell) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row:hover td.el-table__cell) {
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row:hover td.el-table__cell) {
background: #1d2b42;
}
</style>
+44 -15
View File
@@ -27,6 +27,7 @@ const readMainStyleSource = () => readSource(resolve(__dirname, "../styles/main.
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
const readProjectMilestonesSource = () => readSource(resolve(__dirname, "../views/ia/ProjectMilestones.vue"));
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
@@ -373,6 +374,16 @@ describe("desktop layout shell", () => {
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
});
it("closes transient preview tasks back to their originating workspace tab", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
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 });");
expect(source).toContain("if (!workspaceTaskOrigins.value[path] && workspaceTabs.value.length <= 1) return false;");
});
it("allows desktop shortcuts to be customized from system preferences", () => {
const preferences = readDesktopPreferencesSource();
const tauriLib = readTauriLibSource();
@@ -625,32 +636,50 @@ describe("desktop layout shell", () => {
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog');
});
it("keeps first-pass desktop content pages workbench-oriented", () => {
it("shares the medical-consult workbench UI across web and desktop shells", () => {
const faq = readFaqSource();
const faqList = readFaqListSource();
const projectOverview = readProjectOverviewSource();
const projectMilestones = readProjectMilestonesSource();
const webLayout = readWebLayoutSource();
expect(faq).toContain("const isDesktop = isTauriRuntime();");
expect(faq).toContain(":class=\"{ 'medical-consult-page--desktop': isDesktop }\"");
expect(faq).toContain('v-if="!isDesktop" class="page-bg-dots"');
expect(faq).toContain('<h1 v-if="!isDesktop"');
expect(faq).toContain('v-else class="desktop-toolbar-meta"');
expect(faq).toContain('v-if="isDesktop" action="faq.create"');
expect(faq).toContain('<div v-if="!isDesktop" class="list-toolbar">');
expect(faq).toContain(".medical-consult-page--desktop .faq-workspace");
expect(faq).toContain("medical-consult-page--workbench");
expect(faq).toContain('class="workbench-toolbar-meta"');
expect(faq).toContain('<PermissionAction action="faq.create">');
expect(faq).not.toContain("const isDesktop = isTauriRuntime();");
expect(faq).not.toContain("page-bg-dots");
expect(faq).not.toContain("list-toolbar");
expect(faq).toContain(".medical-consult-page--workbench .faq-workspace");
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
expect(faqList).toContain(":class=\"{ 'faq-list-card--desktop': isDesktop }\"");
expect(faqList).toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--desktop .faq-table");
expect(faqList).toContain('class="faq-list-card faq-list-card--workbench"');
expect(faqList).toContain('height="100%"');
expect(faqList).not.toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--workbench .faq-table");
expect(projectOverview).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).not.toContain("项目关注");
expect(projectOverview).not.toContain("desktop-attention-head");
expect(projectOverview).toContain('class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-updated-at"'));
expect(projectOverview.indexOf('class="overview-updated-at"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain('class="overview-live-clock">数据 {{ overviewClockText }}</span>');
expect(projectOverview).toContain('v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-live-clock"'));
expect(projectOverview.indexOf('class="overview-live-clock"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain("overviewClockTimer = window.setInterval");
expect(webLayout).not.toContain("headerClockText");
expect(webLayout).not.toContain('key: "updatedAt"');
expect(webLayout).not.toContain('class="header-center"');
expect(webLayout).not.toContain("headerProjectInfo");
expect(webLayout).not.toContain("fetchProjectOverview");
expect(webLayout).not.toContain("loadHeaderOverviewStats");
expect(webLayout).not.toContain("header-status-pill");
expect(webLayout).not.toContain("header-meta-item");
expect(projectMilestones).toContain('class="unified-action-bar milestone-action-bar"');
expect(projectMilestones).not.toContain('class="unified-action-bar bar--flush"');
expect(projectMilestones).not.toContain("sourceLabel");
expect(projectMilestones).not.toContain("dataSourceLabel");
expect(projectMilestones).not.toContain("updatedAtLabel");
expect(projectMilestones).toContain(".milestone-action-bar {\n min-height: 56px;\n box-sizing: border-box;");
expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board");
@@ -0,0 +1,111 @@
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy, reactive } from "vue";
vi.mock("../runtime", () => ({
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
}));
import OnlyOfficeViewer from "./OnlyOfficeViewer.vue";
const dispatchHostMessage = (source: MessageEventSource | null, origin: string, data: Record<string, unknown>) => {
window.dispatchEvent(new MessageEvent("message", { source, origin, data }));
};
describe("OnlyOfficeViewer bridge", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("initializes when the host iframe finishes loading without waiting for host-ready", async () => {
const config = reactive({ token: "signed-config", document: { title: "原始文件名.xlsx" } });
expect(isProxy(config)).toBe(true);
const wrapper = mount(OnlyOfficeViewer, { props: { config } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(postMessage).toHaveBeenCalledOnce();
expect(postMessage.mock.calls[0][0]).toMatchObject({
type: "ctms.onlyoffice.init",
config: { token: "signed-config", document: { title: "原始文件名.xlsx" } },
});
expect(isProxy((postMessage.mock.calls[0][0] as { config: object }).config)).toBe(false);
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
wrapper.unmount();
});
it("reports a host initialization error when the config cannot be posted", async () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const frameWindow = {
postMessage: vi.fn(() => {
throw new DOMException("could not be cloned", "DataCloneError");
}),
} as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_INIT_FAILED" });
wrapper.unmount();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("accepts one initialization from the exact host window and validates the nonce", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-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" });
expect(postMessage).toHaveBeenCalledOnce();
const initMessage = postMessage.mock.calls[0][0] as { type: string; nonce: string; config: Record<string, unknown> };
expect(initMessage.type).toBe("ctms.onlyoffice.init");
expect(initMessage.config).toEqual({ token: "signed-config" });
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: "wrong",
});
expect(wrapper.emitted("ready")).toBeUndefined();
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("ready")).toHaveLength(1);
wrapper.unmount();
});
it("rejects messages with the wrong origin or source", () => {
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://evil.example", { type: "ctms.onlyoffice.host-ready" });
dispatchHostMessage(window, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).not.toHaveBeenCalled();
wrapper.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();
});
});
@@ -0,0 +1,147 @@
<template>
<div class="onlyoffice-viewer">
<iframe
:key="nonce"
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'"
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { resolveOnlyOfficeHostUrl } from "../runtime";
import type { OnlyOfficeHostMessage } 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 HOST_LOAD_TIMEOUT_MS = 15_000;
const DOCUMENT_LOAD_TIMEOUT_MS = 120_000;
const props = defineProps<{ config: Record<string, unknown> }>();
const emit = defineEmits<{
ready: [];
warning: [detail: Record<string, unknown>];
error: [detail: Record<string, unknown>];
}>();
const frameRef = ref<HTMLIFrameElement | null>(null);
const hostUrl = resolveOnlyOfficeHostUrl();
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("");
};
const nonce = createNonce();
let initialized = false;
let hostTimer: number | undefined;
let documentTimer: number | undefined;
const clearTimers = () => {
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
hostTimer = undefined;
documentTimer = undefined;
};
const emitTimeout = (code: string, message: string) => {
clearTimers();
initialized = true;
emit("error", { code, message });
};
const initializeHost = () => {
if (initialized) return;
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow) return;
try {
// 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);
} catch {
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
return;
}
initialized = true;
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
hostTimer = undefined;
documentTimer = window.setTimeout(
() => emitTimeout("DOCUMENT_LOAD_TIMEOUT", "文档转换或加载超时,请稍后重试"),
DOCUMENT_LOAD_TIMEOUT_MS,
);
};
// iframe 的 load 事件发生在宿主页 defer 脚本完成执行之后,可以直接初始化。
// host-ready 仍作为慢加载和桌面 WebView 场景的兜底握手。
const handleFrameLoad = initializeHost;
const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow || event.source !== frameWindow || event.origin !== hostUrl.origin) return;
const message = event.data;
if (!message || typeof message !== "object" || typeof message.type !== "string") return;
if (message.type === HOST_READY && !initialized) {
initializeHost();
return;
}
if (message.nonce !== nonce) return;
if (message.type === DOCUMENT_READY) {
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
documentTimer = undefined;
emit("ready");
} else if (message.type === WARNING) {
emit("warning", message.detail || {});
} else if (message.type === ERROR) {
clearTimers();
emit("error", message.detail || {});
}
};
onMounted(() => {
window.addEventListener("message", handleMessage);
hostTimer = window.setTimeout(
() => emitTimeout("HOST_LOAD_TIMEOUT", "预览服务加载超时,请检查服务状态后重试"),
HOST_LOAD_TIMEOUT_MS,
);
});
onBeforeUnmount(() => {
clearTimers();
window.removeEventListener("message", handleMessage);
if (frameRef.value) frameRef.value.src = "about:blank";
});
</script>
<style scoped>
.onlyoffice-viewer,
.onlyoffice-viewer__frame {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.onlyoffice-viewer {
overflow: hidden;
background: #f3f5f8;
}
.onlyoffice-viewer__frame {
display: block;
border: 0;
background: #fff;
}
</style>
+37 -464
View File
@@ -5,9 +5,10 @@
@keydown.esc="closeMobileNav"
>
<el-aside
:width="isMobileViewport ? '280px' : (isCollapsed ? '68px' : '200px')"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
:inert="!isMobileViewport && isCollapsed"
>
<div class="aside-logo">
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
@@ -141,7 +142,7 @@
class="collapse-btn"
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
@click="toggleCollapse"
:aria-label="isMobileViewport ? '打开导航菜单' : TEXT.common.labels.collapseSidebar"
:aria-label="isMobileViewport ? '打开导航菜单' : (isCollapsed ? '显示导航栏' : TEXT.common.labels.collapseSidebar)"
>
<el-icon class="collapse-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -187,35 +188,6 @@
</div>
</div>
<!-- 顶栏中间区常驻上下文信息 -->
<div class="header-center">
<template v-if="headerProjectInfo">
<div class="header-context-rail">
<span
v-if="headerProjectInfo.statusLabel"
class="header-status-pill"
:class="`is-${headerProjectInfo.statusTone}`"
>
<span class="header-status-dot"></span>
{{ headerProjectInfo.statusLabel }}
</span>
<span v-if="headerProjectInfo.isLocked" class="header-meta-item header-locked">
<el-icon><Lock /></el-icon>
{{ TEXT.common.labels.locked }}
</span>
<span
v-for="headerMetric in headerProjectInfo.metrics"
:key="headerMetric.key"
class="header-meta-item header-metric"
:class="`is-${headerMetric.key}`"
>
<span class="header-meta-label">{{ headerMetric.label }}</span>
<span class="header-meta-value">{{ headerMetric.value }}</span>
</span>
</div>
</template>
</div>
<div class="header-right">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
@@ -357,8 +329,8 @@
</div>
</el-header>
<el-main class="layout-main">
<div class="content-wrapper">
<el-main class="layout-main" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<div class="content-wrapper" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="fade-transform" mode="out-in">
<div :key="currentRoute.fullPath" class="ctms-route-shell">
@@ -424,13 +396,12 @@ import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import { fetchProjectOverview } from "../api/overview";
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, Lock, UserFilled, Bell, Setting,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
@@ -490,19 +461,11 @@ const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{
centerActual: number;
enrollmentActual: number;
enrollmentTarget: number;
updatedAt: string;
} | null>(null);
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({
overdueAes: 0,
overdueMonitoringIssues: 0,
});
const headerClockNow = ref(new Date());
let headerClockTimer: number | undefined;
let desktopMenuUnlisten: (() => void) | undefined;
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
@@ -511,7 +474,7 @@ const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdmin
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
watchEffect(() => {
const width = !hasSidebar.value || isMobileViewport.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
const width = !hasSidebar.value || isMobileViewport.value || isCollapsed.value ? '0px' : '200px';
document.body.style.setProperty('--ctms-sidebar-width', width);
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
if (hasSidebar.value) {
@@ -538,45 +501,6 @@ const toHeaderNumber = (value: unknown) => {
return Number.isFinite(num) && num > 0 ? num : 0;
};
const formatHeaderProgress = (actual: number, target?: number | null) =>
target && target > 0 ? `${actual}/${target}` : String(actual);
const formatHeaderDateTime = (date: Date) => {
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value));
const loadHeaderOverviewStats = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value) {
headerOverviewStats.value = null;
return;
}
try {
const { data } = await fetchProjectOverview(studyId);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
const centers = Array.isArray((data as any)?.centers) ? (data as any).centers : [];
headerOverviewStats.value = {
centerActual: centers.length,
enrollmentActual: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_actual), 0),
enrollmentTarget: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_target), 0),
updatedAt: typeof (data as any)?.updated_at === "string" ? (data as any).updated_at : "",
};
} catch {
if (study.currentStudy?.id === studyId) {
headerOverviewStats.value = null;
}
}
};
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
@@ -638,45 +562,6 @@ const headerReminderBadgeValue = computed(() =>
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
);
// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间)
const headerProjectInfo = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return null;
const s = study.currentStudy;
const status = (s.status || "").toUpperCase();
const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || "";
const statusTone = status === "ACTIVE" ? "active" : status === "CLOSED" ? "closed" : "draft";
const plannedSiteCount = s.planned_site_count;
const plannedEnrollmentCount = s.planned_enrollment_count;
const overviewStats = headerOverviewStats.value;
const centerActual = overviewStats?.centerActual || 0;
const enrollmentActual = overviewStats?.enrollmentActual || 0;
const enrollmentTarget = overviewStats?.enrollmentTarget || toHeaderNumber(plannedEnrollmentCount);
const updatedAt = headerClockText.value;
const metrics = [
plannedSiteCount || centerActual ? {
key: "plannedSiteCount",
label: TEXT.common.labels.plannedSites,
value: formatHeaderProgress(centerActual || toHeaderNumber(plannedSiteCount), plannedSiteCount),
} : null,
plannedEnrollmentCount || enrollmentActual || enrollmentTarget ? {
key: "plannedEnrollmentCount",
label: TEXT.common.labels.plannedEnrollment,
value: formatHeaderProgress(enrollmentActual || toHeaderNumber(plannedEnrollmentCount), enrollmentTarget || plannedEnrollmentCount),
} : null,
updatedAt ? {
key: "updatedAt",
label: TEXT.common.labels.dataUpdatedAt,
value: updatedAt,
} : null,
].filter(Boolean) as { key: string; label: string; value: string | number }[];
return {
statusLabel,
statusTone,
isLocked: Boolean((s as any).is_locked),
metrics,
};
});
const accountProjectRoleLabel = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
@@ -1088,7 +973,6 @@ watch(() => route.path, () => {
watch(
() => [study.currentStudy?.id, route.path] as const,
() => {
loadHeaderOverviewStats();
loadHeaderReminders();
refreshDesktopRoutePreferences();
},
@@ -1158,10 +1042,6 @@ onMounted(async () => {
mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches);
mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler);
}
headerClockNow.value = new Date();
headerClockTimer = window.setInterval(() => {
headerClockNow.value = new Date();
}, 1000);
if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
clearLegacyDesktopRouteHistoryPreference();
@@ -1178,14 +1058,10 @@ onMounted(async () => {
}
}
loadStudies();
loadHeaderOverviewStats();
loadHeaderReminders();
});
onBeforeUnmount(() => {
if (headerClockTimer) {
window.clearInterval(headerClockTimer);
}
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
@@ -1273,6 +1149,10 @@ useDesktopShortcuts(
border-right: 1px solid rgba(15, 23, 42, 0.2);
}
.web-layout-container .layout-aside.collapsed {
border-right-width: 0;
}
.web-layout-container .aside-logo {
height: 60px;
padding: 0 16px;
@@ -1298,9 +1178,9 @@ useDesktopShortcuts(
}
.web-layout-container .content-wrapper {
min-height: calc(100vh - 52px);
min-height: calc(100dvh - 52px);
padding: 12px 14px;
height: 100%;
min-height: 0;
padding: 0;
}
.layout-aside {
@@ -1549,6 +1429,8 @@ useDesktopShortcuts(
}
.main-container {
height: 100%;
min-height: 0;
background: #f7f8fb;
min-width: 0;
}
@@ -1826,296 +1708,6 @@ useDesktopShortcuts(
flex-shrink: 0;
}
/* 顶栏中间区:常驻上下文信息 */
.header-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 0 12px;
overflow: hidden;
}
.header-context-rail {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
max-width: 100%;
height: 30px;
padding: 1px 0;
border: 1px solid transparent;
border-radius: 14px;
background: transparent;
box-shadow: none;
overflow: visible;
white-space: nowrap;
}
.header-status-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 13px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 13.5px;
font-weight: 760;
line-height: 1;
white-space: nowrap;
letter-spacing: 0;
overflow: hidden;
transition:
transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.24s ease,
border-color 0.24s ease;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
inset 0 -1px 0 rgba(15, 23, 42, 0.04);
}
/* 状态药丸高光扫光:缓慢横扫一道柔光,营造高级质感 */
.header-status-pill::after {
content: "";
position: absolute;
top: 0;
left: -60%;
width: 45%;
height: 100%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.55) 50%,
transparent 100%
);
transform: skewX(-18deg);
opacity: 0;
pointer-events: none;
}
.header-status-pill:hover {
transform: translateY(-1px);
}
.header-status-pill.is-active {
border-color: rgba(96, 188, 146, 0.78);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.92) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #e6fbf0 0%, #cdeedd 55%, #b9e7d2 100%);
color: #1d6e4f;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(30, 109, 80, 0.12),
0 2px 10px rgba(34, 145, 100, 0.18);
}
.header-status-pill.is-active::after {
animation: header-pill-sheen 4.4s ease-in-out 1.2s infinite;
}
.header-status-pill.is-active:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(30, 109, 80, 0.14),
0 6px 18px rgba(34, 145, 100, 0.28);
}
.header-status-pill.is-closed {
border-color: rgba(232, 175, 96, 0.78);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.94) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #fff7e9 0%, #fbe6bf 55%, #f8dca6 100%);
color: #8f520c;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(143, 82, 12, 0.1),
0 2px 10px rgba(214, 152, 56, 0.16);
}
.header-status-pill.is-closed:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(143, 82, 12, 0.12),
0 6px 18px rgba(214, 152, 56, 0.26);
}
.header-status-pill.is-draft {
border-color: rgba(196, 209, 224, 0.96);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.95) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #fbfdff 0%, #eef3f9 55%, #e4ecf5 100%);
color: #4f667e;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(83, 106, 130, 0.08),
0 2px 9px rgba(120, 138, 163, 0.14);
}
.header-status-pill.is-draft:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
inset 0 -1px 0 rgba(83, 106, 130, 0.1),
0 6px 16px rgba(120, 138, 163, 0.2);
}
.header-status-dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
box-shadow:
0 0 0 3px color-mix(in srgb, currentColor 16%, transparent),
0 1px 2px rgba(15, 23, 42, 0.14);
}
/* 进行中状态点呼吸光环:周期性向外扩散并淡出 */
.header-status-pill.is-active .header-status-dot::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: currentColor;
animation: header-dot-pulse 2.4s ease-out infinite;
}
@keyframes header-pill-sheen {
0%,
78% {
left: -60%;
opacity: 0;
}
82% {
opacity: 0.9;
}
100% {
left: 130%;
opacity: 0;
}
}
@keyframes header-dot-pulse {
0% {
transform: scale(1);
opacity: 0.55;
}
70%,
100% {
transform: scale(2.8);
opacity: 0;
}
}
.header-meta-item {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
height: 28px;
padding: 0 11px;
border: 1px solid rgba(213, 224, 237, 0.94);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(238, 245, 252, 0.86) 100%);
font-size: 12px;
line-height: 1;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(148, 163, 184, 0.08);
transition:
border-color 0.22s ease,
background 0.22s ease,
box-shadow 0.22s ease,
transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 指标项左侧彩色强调条:随类型变色,悬浮时点亮 */
.header-meta-item::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 13px;
border-radius: 0 3px 3px 0;
transform: translateY(-50%) scaleY(0.55);
opacity: 0;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.header-meta-item.is-plannedSiteCount::before {
background: linear-gradient(180deg, #5fb4ec 0%, #3f8fd6 100%);
}
.header-meta-item.is-plannedEnrollmentCount::before {
background: linear-gradient(180deg, #6cc79c 0%, #3fa777 100%);
}
.header-meta-item.is-updatedAt::before {
background: linear-gradient(180deg, #b6a4ec 0%, #8b78d6 100%);
}
.header-meta-item:hover {
border-color: rgba(160, 182, 210, 0.98);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(244, 249, 254, 0.95) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(148, 163, 184, 0.1),
0 5px 16px rgba(38, 63, 102, 0.12);
transform: translateY(-1px);
}
.header-meta-item:hover::before {
opacity: 1;
transform: translateY(-50%) scaleY(1);
}
.header-meta-item.is-updatedAt {
padding: 0 12px;
font-variant-numeric: tabular-nums;
color: #52677f;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(241, 246, 252, 0.9) 100%);
}
.header-meta-item .el-icon {
font-size: 13px;
color: #94a3b8;
}
.header-meta-label {
color: #778aa3;
margin-right: 0;
font-weight: 560;
}
.header-meta-value {
color: #506179;
font-weight: 720;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
transition: color 0.22s ease;
}
.header-meta-item.is-plannedSiteCount:hover .header-meta-value {
color: #2f7fc4;
}
.header-meta-item.is-plannedEnrollmentCount:hover .header-meta-value {
color: #2f8f63;
}
.header-meta-item.is-updatedAt:hover .header-meta-value {
color: #6a5bbf;
}
.header-role {
font-weight: 600;
@@ -2308,26 +1900,6 @@ useDesktopShortcuts(
font-weight: 600;
}
.header-locked {
color: #b45309;
}
.header-locked .el-icon {
color: #d97706;
}
@media (max-width: 1280px) {
.header-metric {
display: none;
}
}
@media (max-width: 1100px) {
.header-center {
display: none;
}
}
.collapse-btn {
border: 1px solid transparent;
color: var(--ctms-text-secondary);
@@ -2411,6 +1983,7 @@ useDesktopShortcuts(
}
.layout-main {
min-height: 0;
padding: 0;
overflow-y: auto;
background: #f7f8fb;
@@ -2424,9 +1997,26 @@ useDesktopShortcuts(
background: #f7f8fb;
}
.layout-main.is-full-bleed {
overflow: hidden;
}
.content-wrapper.is-full-bleed {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
}
.content-wrapper.is-full-bleed .ctms-route-shell {
height: 100%;
min-height: 0;
}
.ctms-route-shell {
width: 100%;
min-height: 100%;
height: 100%;
min-height: 0;
transform-origin: top center;
}
@@ -2494,6 +2084,7 @@ useDesktopShortcuts(
}
.web-layout-container .content-wrapper {
height: auto;
min-height: calc(100dvh - 52px);
padding: 10px;
}
@@ -2592,8 +2183,7 @@ useDesktopShortcuts(
}
.web-layout-container .content-wrapper {
min-height: calc(100dvh - 56px);
padding: 18px 24px 24px;
padding: 0;
}
}
@@ -2603,9 +2193,6 @@ useDesktopShortcuts(
padding-left: 28px;
}
.web-layout-container .content-wrapper {
padding: 22px 32px 32px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -2631,20 +2218,6 @@ useDesktopShortcuts(
transform: none;
}
.header-status-pill,
.header-meta-item {
transition-duration: 1ms !important;
}
.header-status-pill:hover,
.header-meta-item:hover {
transform: none;
}
.header-status-pill.is-active::after,
.header-status-pill.is-active .header-status-dot::before {
animation: none;
}
}
.breadcrumb-dropdown-menu {
min-width: 216px;
@@ -163,6 +163,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Document, UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
@@ -177,6 +178,7 @@ import { TEXT } from "../../locales";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
import { detectFilePreviewKind } from "../../utils/officePreview";
type AttachmentEntityGroup = {
entityType: string;
@@ -223,6 +225,7 @@ const progressMap = reactive<Record<string, number>>({});
const pendingMap = reactive<Record<string, PendingUploadItem[]>>({});
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const previewVisible = ref(false);
const previewUrl = ref("");
const previewObjectUrl = ref("");
@@ -518,17 +521,8 @@ const openExternally = async (row: any) => {
}
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
const getMimeType = (row: any) => (row?.mime_type || row?.content_type || "").toLowerCase();
const detectPreviewType = (row: any) => {
const mime = getMimeType(row);
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getFileName(row);
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
return detectFilePreviewKind(row?.filename, row?.mime_type || row?.content_type);
};
const previewImage = async (row: any) => {
@@ -547,7 +541,12 @@ const previewImage = async (row: any) => {
const preview = async (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
const detectedType = detectPreviewType(row);
if (detectedType === "office") {
await router.push(`/office-preview/attachment/${row.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = row?.filename || TEXT.common.labels.preview;
if (previewType.value === "image" || previewType.value === "pdf") {
previewUrl.value = "";
@@ -0,0 +1,7 @@
import type { InjectionKey } from "vue";
export interface WorkspaceTaskController {
closeTransientTask(path: string): boolean;
}
export const workspaceTaskControllerKey: InjectionKey<WorkspaceTaskController> = Symbol("workspaceTaskController");
+12
View File
@@ -5,6 +5,18 @@ import { resolve } from "node:path";
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8").replace(/\r\n/g, "\n");
describe("admin project route permissions", () => {
it("registers independent full-bleed routes for attachment and version Office previews", () => {
const source = readRouter();
expect(source).toContain('path: "office-preview/attachment/:id"');
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.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
it("lets any authorized project role open the project management detail page", () => {
const source = readRouter();
+13
View File
@@ -35,6 +35,7 @@ import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
import DocumentList from "../views/documents/DocumentList.vue";
import DocumentDetail from "../views/documents/DocumentDetail.vue";
import OfficePreviewWorkspace from "../views/OfficePreviewWorkspace.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
import SubjectManagement from "../views/ia/SubjectManagement.vue";
@@ -193,6 +194,18 @@ const routes: RouteRecordRaw[] = [
component: DocumentDetail,
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
},
{
path: "office-preview/attachment/:id",
name: "OfficeAttachmentPreview",
component: OfficePreviewWorkspace,
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "office-preview/version/:id",
name: "OfficeVersionPreview",
component: OfficePreviewWorkspace,
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics",
+1
View File
@@ -79,6 +79,7 @@ export {
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
export {
clearSessionToken,
getSessionToken,
+12
View File
@@ -0,0 +1,12 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
export const ONLYOFFICE_HOST_PATH = "/onlyoffice-host.html";
/**
* ONLYOFFICE 宿主页只能由受控服务器基址和固定路径组合,业务数据不得参与 frame 地址构造。
*/
export const resolveOnlyOfficeHostUrl = (): URL => {
const browserOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
const apiBase = new URL(resolveApiBaseUrl(), `${browserOrigin}/`);
return new URL(ONLYOFFICE_HOST_PATH, apiBase.origin);
};
+58
View File
@@ -994,6 +994,64 @@ body {
border-bottom-color: #26364a;
}
/* Web workbench edge alignment
* Keep page-level spacing consistent with the desktop shell: the route owns
* the full main-content canvas while cards and sections own their inner
* padding. This also replaces page-specific negative-margin workarounds.
*/
@media (min-width: 768px) {
.web-layout-container .ctms-route-shell {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.web-layout-container .ctms-route-shell > * {
width: 100%;
min-width: 0;
min-height: 100%;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box;
}
.web-layout-container .ctms-route-shell > .page,
.web-layout-container .ctms-route-shell > .ctms-page,
.web-layout-container .ctms-route-shell > .ctms-page-shell,
.web-layout-container .ctms-route-shell > .module-page,
.web-layout-container .ctms-route-shell > .medical-consult-page {
gap: 8px;
}
.web-layout-container .ctms-route-shell .page-inner,
.web-layout-container .ctms-route-shell .page-body,
.web-layout-container .ctms-route-shell .main-content,
.web-layout-container .ctms-route-shell .main-content-card,
.web-layout-container .ctms-route-shell .main-content-flat,
.web-layout-container .ctms-route-shell .study-home-container,
.web-layout-container .ctms-route-shell .overview,
.web-layout-container .ctms-route-shell .content-area {
min-width: 0;
max-width: none;
width: 100%;
margin-right: 0 !important;
margin-left: 0 !important;
padding-right: 0 !important;
padding-left: 0 !important;
}
.web-layout-container .ctms-route-shell > .page > .table-card,
.web-layout-container .ctms-route-shell > .page > .page-inner > .table-card,
.web-layout-container .ctms-route-shell > .page.page--flush > .unified-shell,
.web-layout-container .ctms-route-shell > .ctms-page-shell.page--flush > .unified-shell {
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
}
}
/* ============================================================
* 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
+16
View File
@@ -0,0 +1,16 @@
export type OnlyOfficeResourceType = "attachment" | "version";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
resource_id: string;
file_name: string;
host_path: string;
expires_at: string;
config: Record<string, unknown>;
}
export interface OnlyOfficeHostMessage {
type: string;
nonce?: string;
detail?: Record<string, unknown>;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { detectFilePreviewKind, onlyOfficeDocumentType } from "./officePreview";
describe("ONLYOFFICE format contract", () => {
it.each([
["doc", "word"], ["docx", "word"], ["docm", "word"], ["dot", "word"], ["dotx", "word"],
["dotm", "word"], ["odt", "word"], ["ott", "word"], ["rtf", "word"], ["txt", "word"],
["wps", "word"], ["wpt", "word"], ["xls", "cell"], ["xlsx", "cell"], ["xlsm", "cell"],
["xlsb", "cell"], ["xlt", "cell"], ["xltx", "cell"], ["xltm", "cell"], ["ods", "cell"],
["ots", "cell"], ["csv", "cell"], ["et", "cell"], ["ett", "cell"], ["ppt", "slide"],
["pptx", "slide"], ["pptm", "slide"], ["pps", "slide"], ["ppsx", "slide"], ["ppsm", "slide"],
["pot", "slide"], ["potx", "slide"], ["potm", "slide"], ["odp", "slide"], ["otp", "slide"],
["dps", "slide"], ["dpt", "slide"],
] as const)("maps .%s to %s", (extension, documentType) => {
expect(onlyOfficeDocumentType(`原始文件名.${extension.toUpperCase()}`)).toBe(documentType);
expect(detectFilePreviewKind(`原始文件名.${extension}`, "application/octet-stream")).toBe("office");
});
it("keeps PDF and images on their existing preview paths", () => {
expect(detectFilePreviewKind("方案.pdf", "application/pdf")).toBe("pdf");
expect(detectFilePreviewKind("扫描.PNG", "image/png")).toBe("image");
expect(onlyOfficeDocumentType("方案.pdf")).toBeNull();
});
it("uses the original filename extension before an unreliable MIME type", () => {
expect(detectFilePreviewKind("方案.docx", "application/pdf")).toBe("office");
expect(detectFilePreviewKind("扫描.pdf", "image/png")).toBe("pdf");
});
});
+46
View File
@@ -0,0 +1,46 @@
export type FilePreviewKind = "image" | "pdf" | "office" | "other";
export type OnlyOfficeDocumentType = "word" | "cell" | "slide";
export const ONLYOFFICE_WORD_EXTENSIONS = [
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
] as const;
export const ONLYOFFICE_CELL_EXTENSIONS = [
"xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett",
] as const;
export const ONLYOFFICE_SLIDE_EXTENSIONS = [
"ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt",
] as const;
export const ONLYOFFICE_FILE_EXTENSIONS = [
...ONLYOFFICE_WORD_EXTENSIONS,
...ONLYOFFICE_CELL_EXTENSIONS,
...ONLYOFFICE_SLIDE_EXTENSIONS,
] as const;
const WORD_EXTENSIONS = new Set<string>(ONLYOFFICE_WORD_EXTENSIONS);
const CELL_EXTENSIONS = new Set<string>(ONLYOFFICE_CELL_EXTENSIONS);
const SLIDE_EXTENSIONS = new Set<string>(ONLYOFFICE_SLIDE_EXTENSIONS);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"]);
export const fileExtension = (filename?: string | null): string => {
const normalized = String(filename || "").trim().toLocaleLowerCase();
const separator = normalized.lastIndexOf(".");
return separator >= 0 && separator < normalized.length - 1 ? normalized.slice(separator + 1) : "";
};
export const onlyOfficeDocumentType = (filename?: string | null): OnlyOfficeDocumentType | null => {
const extension = fileExtension(filename);
if (WORD_EXTENSIONS.has(extension)) return "word";
if (CELL_EXTENSIONS.has(extension)) return "cell";
if (SLIDE_EXTENSIONS.has(extension)) return "slide";
return null;
};
export const detectFilePreviewKind = (filename?: string | null, mimeType?: string | null): FilePreviewKind => {
const mime = String(mimeType || "").trim().toLocaleLowerCase();
const extension = fileExtension(filename);
if (IMAGE_EXTENSIONS.has(extension)) return "image";
if (extension === "pdf") return "pdf";
if (onlyOfficeDocumentType(filename)) return "office";
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
return "other";
};
+38 -96
View File
@@ -1,9 +1,7 @@
<template>
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div v-if="!isDesktop" class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page medical-consult-page--workbench">
<section class="faq-hero">
<h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div v-else class="desktop-toolbar-meta">
<div class="workbench-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
@@ -23,13 +21,12 @@
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<PermissionAction v-if="isDesktop" action="faq.create">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
<div v-else class="spacer" />
</div>
</section>
@@ -44,15 +41,6 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
</div>
<FaqList
:items="faqs"
:categories="categories"
@@ -84,12 +72,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { Collection, Plus, Search } from "@element-plus/icons-vue";
import { Plus, Search } from "@element-plus/icons-vue";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
@@ -98,7 +85,6 @@ import { TEXT } from "../locales";
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
@@ -230,15 +216,6 @@ onMounted(async () => {
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
}
.page-bg-dots {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: radial-gradient(#1e40af 1px, transparent 1px);
background-size: 24px 24px;
}
.faq-hero {
position: relative;
padding: 28px 24px 36px;
@@ -262,30 +239,6 @@ onMounted(async () => {
pointer-events: none;
}
.faq-hero h1 {
margin: 0 0 24px;
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.hero-title-icon {
font-size: 28px;
color: #3b82f6;
opacity: 0.85;
}
.hero-desc {
margin: 8px 0 28px;
color: #64748b;
font-size: 15px;
font-weight: 500;
}
.hero-tools {
display: flex;
align-items: center;
@@ -349,13 +302,6 @@ onMounted(async () => {
padding: 24px 28px 0;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 0 16px;
}
.list-title strong {
min-width: 30px;
height: 24px;
@@ -377,11 +323,7 @@ onMounted(async () => {
justify-content: flex-end;
}
.spacer {
flex: 1;
}
.desktop-toolbar-meta {
.workbench-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
@@ -391,7 +333,7 @@ onMounted(async () => {
text-align: left;
}
.desktop-toolbar-meta::before {
.workbench-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
@@ -403,19 +345,19 @@ onMounted(async () => {
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.desktop-toolbar-meta span {
.workbench-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.desktop-toolbar-meta small {
.workbench-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--desktop {
.medical-consult-page--workbench {
display: flex;
height: 100%;
min-height: 0;
@@ -425,7 +367,7 @@ onMounted(async () => {
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--desktop .faq-hero {
.medical-consult-page--workbench .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
@@ -443,22 +385,22 @@ onMounted(async () => {
border-radius: 0 !important;
}
.medical-consult-page--desktop .faq-hero::before {
.medical-consult-page--workbench .faq-hero::before {
display: none;
}
.medical-consult-page--desktop .hero-tools {
.medical-consult-page--workbench .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--desktop .hero-search {
.medical-consult-page--workbench .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--desktop .hero-search :deep(.el-input__wrapper) {
.medical-consult-page--workbench .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
@@ -470,17 +412,17 @@ onMounted(async () => {
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__inner) {
.medical-consult-page--workbench .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__prefix) {
.medical-consult-page--workbench .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--desktop .new-consult-btn {
.medical-consult-page--workbench .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
@@ -492,14 +434,14 @@ onMounted(async () => {
transition: all 0.2s;
}
.medical-consult-page--desktop .new-consult-btn:hover,
.medical-consult-page--desktop .new-consult-btn:focus {
.medical-consult-page--workbench .new-consult-btn:hover,
.medical-consult-page--workbench .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--desktop .faq-workspace {
.medical-consult-page--workbench .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
@@ -512,14 +454,14 @@ onMounted(async () => {
overflow: hidden;
}
.medical-consult-page--desktop .faq-sidebar-col,
.medical-consult-page--desktop .faq-content-col {
.medical-consult-page--workbench .faq-sidebar-col,
.medical-consult-page--workbench .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--desktop .faq-sidebar-col {
.medical-consult-page--workbench .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
@@ -528,13 +470,13 @@ onMounted(async () => {
overflow: hidden;
}
.medical-consult-page--desktop .faq-content-col {
.medical-consult-page--workbench .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--desktop .faq-main {
.medical-consult-page--workbench .faq-main {
display: flex;
width: 100%;
height: 100%;
@@ -547,31 +489,31 @@ onMounted(async () => {
background: #ffffff;
}
.medical-consult-page--desktop .pagination-wrap {
.medical-consult-page--workbench .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--desktop :deep(.faq-category-panel) {
.medical-consult-page--workbench :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
.medical-consult-page--workbench :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu) {
.medical-consult-page--workbench :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu .el-menu-item) {
.medical-consult-page--workbench :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
@@ -579,27 +521,27 @@ onMounted(async () => {
line-height: 36px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .cat-icon) {
.medical-consult-page--workbench :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-sidebar-col) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .desktop-toolbar-meta span) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .workbench-toolbar-meta span) {
color: #e5edf7;
}
@@ -619,16 +561,16 @@ onMounted(async () => {
padding: 18px 16px 0;
}
.medical-consult-page--desktop .faq-workspace {
.medical-consult-page--workbench .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--desktop .faq-sidebar-col {
.medical-consult-page--workbench .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--desktop .faq-main {
.medical-consult-page--workbench .faq-main {
padding: 0;
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./OfficePreviewWorkspace.vue"), "utf8");
const hostSource = readFileSync(resolve(__dirname, "../components/OnlyOfficeViewer.vue"), "utf8");
describe("OfficePreviewWorkspace contract", () => {
it("uses a full-page viewer without dialogs, cards, downloads, or browser iframe fallback", () => {
expect(source).toContain("OnlyOfficeViewer");
expect(source).not.toContain("el-dialog");
expect(source).not.toContain("downloadDocumentVersion");
expect(source).not.toContain("downloadAttachment");
expect(source).not.toContain("<iframe");
});
it("destroys signed configuration on deactivation, server changes, and unmount", () => {
expect(source).toContain("onDeactivated");
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source.match(/destroyViewer\(\)/g)?.length).toBeGreaterThanOrEqual(3);
expect(hostSource).toContain('frameRef.value.src = "about:blank"');
});
it("closes a desktop transient preview task before falling back to browser history", () => {
expect(source).toContain("inject(workspaceTaskControllerKey, null)");
expect(source).toContain("workspaceTaskController?.closeTransientTask(route.path)");
expect(source).toContain('workspaceTaskController ? "关闭预览并返回" : "返回"');
expect(source).toContain("router.back()");
});
});
@@ -0,0 +1,258 @@
<template>
<section class="office-preview-page">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
type="button"
:aria-label="backActionLabel"
:title="backActionLabel"
@click="goBack"
></button>
<div class="office-preview-page__title" :title="displayFileName">{{ displayFileName }}</div>
<div class="office-preview-page__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadConfig">重试</el-button>
</header>
<main class="office-preview-page__content">
<OnlyOfficeViewer
v-if="previewConfig && !errorMessage"
:key="viewerKey"
:config="previewConfig.config"
@ready="handleDocumentReady"
@warning="handleWarning"
@error="handleViewerError"
/>
<div v-else class="office-preview-page__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<div class="office-preview-page__error-title">无法预览此文件</div>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadConfig">重新加载</el-button>
</template>
</div>
</main>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from "vue";
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 { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
type PreviewStatus = "loading" | "ready" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const study = useStudyStore();
const previewConfig = ref<OnlyOfficePreviewConfig | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const warningMessage = ref("");
const status = ref<PreviewStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() =>
route.name === "OfficeAttachmentPreview" ? "attachment" : "version",
);
const resourceId = computed(() => String(route.params.id || ""));
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 ? "关闭预览并返回" : "返回");
const statusLabel = computed(() => {
if (status.value === "ready") 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);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "Office 在线预览尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "Office 预览服务暂时不可用";
if (code === "ONLYOFFICE_FORMAT_UNSUPPORTED" || statusCode === 415) return "该文件格式不支持 Office 在线预览";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有预览此文件的权限";
if (statusCode === 404) return "文件不存在或已被删除";
return getApiErrorMessage(error, "Office 预览配置加载失败");
};
const destroyViewer = () => {
requestSequence.value += 1;
previewConfig.value = null;
viewerSequence.value += 1;
};
const loadConfig = async () => {
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
previewConfig.value = null;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
if (sequence !== requestSequence.value) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
throw new Error("Office 预览宿主页配置不合法");
}
previewConfig.value = response.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (sequence === requestSequence.value) loading.value = false;
}
};
const handleDocumentReady = () => {
status.value = "ready";
};
const viewerDetailMessage = (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 || code === null ? "" : `错误代码:${String(code)}`;
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = viewerDetailMessage(detail) || "预览服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = viewerDetailMessage(detail) || "文件转换失败,请稍后重试";
status.value = "error";
previewConfig.value = null;
};
const handleServerChange = () => {
destroyViewer();
loading.value = false;
status.value = "error";
errorMessage.value = "服务器地址已切换,请重新加载预览";
};
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
router.back();
};
onMounted(() => {
mounted = true;
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadConfig();
});
onActivated(() => {
if (mounted && !previewConfig.value && !loading.value && !errorMessage.value) void loadConfig();
});
onDeactivated(() => {
destroyViewer();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.office-preview-page {
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.office-preview-page__header {
z-index: 1;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 0 14px 0 8px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.office-preview-page__back {
width: 32px;
height: 32px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 30px/1 system-ui, sans-serif;
cursor: pointer;
}
.office-preview-page__back:hover { background: #edf2f7; }
.office-preview-page__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-preview-page__status {
font-size: 12px;
color: #738398;
white-space: nowrap;
}
.office-preview-page__status.is-ready { color: #25845b; }
.office-preview-page__status.is-warning,
.office-preview-page__status.is-error { color: #b65c29; }
.office-preview-page__content {
min-height: 0;
overflow: hidden;
}
.office-preview-page__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
height: 100%;
color: #718096;
text-align: center;
}
.office-preview-page__state p { margin: 0; }
.office-preview-page__error-title { color: #2f4054; font-size: 17px; font-weight: 600; }
</style>
@@ -57,12 +57,13 @@ describe("DocumentDetail permissions", () => {
expect(source).toContain("@click=\"previewVersion(row)\"");
expect(source).toContain("TEXT.common.labels.preview");
expect(source).toContain('return "image"');
expect(source).toContain('return "pdf"');
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("TEXT.common.messages.previewNotSupported");
expect(source).toContain("URL.createObjectURL(blob)");
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
expect(source).toContain("<PdfViewer");
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("/office-preview/version/${version.id}");
expect(source).not.toContain("<iframe");
});
@@ -117,6 +118,11 @@ describe("DocumentDetail breadcrumbs", () => {
expect(source).toContain("siteName: displaySite(detail.site_id)");
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined");
expect(source.indexOf("syncBreadcrumbContext();", source.indexOf("Object.assign(detail, data);") + 1)).toBeGreaterThan(
source.indexOf("Object.assign(detail, data);"),
);
expect(source).toContain("onActivated(() => {");
expect(source).toContain("if (detail.id) syncBreadcrumbContext();");
});
});
+17 -11
View File
@@ -350,8 +350,8 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { computed, defineAsyncComponent, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
import {
@@ -383,8 +383,10 @@ import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } fro
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { getContentDispositionFilename } from "../../utils/contentDisposition";
import { prepareSaveFile } from "../../runtime";
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
const route = useRoute();
const router = useRouter();
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
const auth = useAuthStore();
const study = useStudyStore();
@@ -531,7 +533,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
const triggerFileInput = async () => {
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
accept: [...ONLYOFFICE_FILE_EXTENSIONS, "pdf", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
@@ -669,6 +671,7 @@ const loadDetail = async () => {
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
syncBreadcrumbContext();
if (!users.value.length) await loadUsers();
if (!members.value.length) await loadMembers();
if (!sites.value.length) await loadSites();
@@ -771,13 +774,7 @@ const getVersionFileName = (version: DocumentVersion) => {
};
const detectPreviewType = (version: DocumentVersion) => {
const mime = (version.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getVersionFileName(version).toLowerCase();
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
return detectFilePreviewKind(getVersionFileName(version), version.mime_type);
};
const clearPreviewObjectUrl = () => {
@@ -789,7 +786,12 @@ const clearPreviewObjectUrl = () => {
const previewVersion = async (version: DocumentVersion) => {
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
clearPreviewObjectUrl();
previewType.value = detectPreviewType(version);
const detectedType = detectPreviewType(version);
if (detectedType === "office") {
await router.push(`/office-preview/version/${version.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewUrl.value = "";
previewError.value = "";
@@ -924,6 +926,10 @@ onMounted(async () => {
await loadDetail();
});
onActivated(() => {
if (detail.id) syncBreadcrumbContext();
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
+7 -30
View File
@@ -1,16 +1,9 @@
<template>
<div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
<section class="unified-action-bar bar--flush">
<section class="unified-action-bar milestone-action-bar">
<div class="card-header ctms-page-header-row">
<div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="card-subtitle">
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
<span class="dot-sep">·</span>
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
</div>
</div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="header-actions">
<el-tag size="small" effect="plain">
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
@@ -200,7 +193,6 @@ import { useStudyStore } from "../../store/study";
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
@@ -237,8 +229,6 @@ const study = useStudyStore();
const { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
const updatedAtLabel = ref<string>(TEXT.common.fallback);
const editorVisible = ref(false);
const editingRowId = ref("");
const editorForm = reactive<MilestoneLocalEdit>({
@@ -309,15 +299,8 @@ const loadMilestones = async () => {
const { data } = (await listProjectMilestones(studyId)) as any;
const list = Array.isArray(data) ? data : data?.items || [];
rows.value = list.map(normalizeRow);
const updatedAt = list
.map((item: any) => String(item?.updated_at || ""))
.filter(Boolean)
.sort()
.pop();
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
} catch (error: any) {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
@@ -452,7 +435,6 @@ watch(
() => study.currentStudy?.id,
() => {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
loadMilestones();
}
);
@@ -503,22 +485,17 @@ watch(
flex-wrap: wrap;
}
.milestone-action-bar {
min-height: 56px;
box-sizing: border-box;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--unified-title-color);
}
.card-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.dot-sep {
margin: 0 8px;
}
.header-actions {
display: flex;
align-items: center;
+24 -2
View File
@@ -53,7 +53,8 @@
</template>
刷新
</el-button>
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<span v-if="!isDesktop" class="overview-live-clock">数据 {{ overviewClockText }}</span>
<span v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
@@ -146,7 +147,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
@@ -164,6 +165,8 @@ const isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const overviewClockNow = ref(new Date());
let overviewClockTimer: number | undefined;
const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
@@ -183,6 +186,16 @@ const overviewUpdatedAtLabel = computed(() => {
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const overviewClockText = computed(() => {
const date = overviewClockNow.value;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
@@ -369,9 +382,17 @@ const reset = () => {
};
onMounted(() => {
overviewClockNow.value = new Date();
overviewClockTimer = window.setInterval(() => {
overviewClockNow.value = new Date();
}, 1000);
loadOverview();
});
onBeforeUnmount(() => {
if (overviewClockTimer) window.clearInterval(overviewClockTimer);
});
watch(
() => study.currentStudy?.id,
() => {
@@ -417,6 +438,7 @@ watch(
border-radius: 8px;
}
.overview-live-clock,
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;