功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。 打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。 补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user