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