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

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

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

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
Cheng Zhou
2026-07-14 14:19:17 +08:00
parent 44db5db838
commit c68dddfc01
50 changed files with 2429 additions and 677 deletions
+37 -464
View File
@@ -5,9 +5,10 @@
@keydown.esc="closeMobileNav"
>
<el-aside
:width="isMobileViewport ? '280px' : (isCollapsed ? '68px' : '200px')"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
:inert="!isMobileViewport && isCollapsed"
>
<div class="aside-logo">
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
@@ -141,7 +142,7 @@
class="collapse-btn"
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
@click="toggleCollapse"
:aria-label="isMobileViewport ? '打开导航菜单' : TEXT.common.labels.collapseSidebar"
:aria-label="isMobileViewport ? '打开导航菜单' : (isCollapsed ? '显示导航栏' : TEXT.common.labels.collapseSidebar)"
>
<el-icon class="collapse-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -187,35 +188,6 @@
</div>
</div>
<!-- 顶栏中间区常驻上下文信息 -->
<div class="header-center">
<template v-if="headerProjectInfo">
<div class="header-context-rail">
<span
v-if="headerProjectInfo.statusLabel"
class="header-status-pill"
:class="`is-${headerProjectInfo.statusTone}`"
>
<span class="header-status-dot"></span>
{{ headerProjectInfo.statusLabel }}
</span>
<span v-if="headerProjectInfo.isLocked" class="header-meta-item header-locked">
<el-icon><Lock /></el-icon>
{{ TEXT.common.labels.locked }}
</span>
<span
v-for="headerMetric in headerProjectInfo.metrics"
:key="headerMetric.key"
class="header-meta-item header-metric"
:class="`is-${headerMetric.key}`"
>
<span class="header-meta-label">{{ headerMetric.label }}</span>
<span class="header-meta-value">{{ headerMetric.value }}</span>
</span>
</div>
</template>
</div>
<div class="header-right">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
@@ -357,8 +329,8 @@
</div>
</el-header>
<el-main class="layout-main">
<div class="content-wrapper">
<el-main class="layout-main" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<div class="content-wrapper" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="fade-transform" mode="out-in">
<div :key="currentRoute.fullPath" class="ctms-route-shell">
@@ -424,13 +396,12 @@ import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import { fetchProjectOverview } from "../api/overview";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
@@ -490,19 +461,11 @@ const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{
centerActual: number;
enrollmentActual: number;
enrollmentTarget: number;
updatedAt: string;
} | null>(null);
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({
overdueAes: 0,
overdueMonitoringIssues: 0,
});
const headerClockNow = ref(new Date());
let headerClockTimer: number | undefined;
let desktopMenuUnlisten: (() => void) | undefined;
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
@@ -511,7 +474,7 @@ const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdmin
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
watchEffect(() => {
const width = !hasSidebar.value || isMobileViewport.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
const width = !hasSidebar.value || isMobileViewport.value || isCollapsed.value ? '0px' : '200px';
document.body.style.setProperty('--ctms-sidebar-width', width);
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
if (hasSidebar.value) {
@@ -538,45 +501,6 @@ const toHeaderNumber = (value: unknown) => {
return Number.isFinite(num) && num > 0 ? num : 0;
};
const formatHeaderProgress = (actual: number, target?: number | null) =>
target && target > 0 ? `${actual}/${target}` : String(actual);
const formatHeaderDateTime = (date: Date) => {
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value));
const loadHeaderOverviewStats = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value) {
headerOverviewStats.value = null;
return;
}
try {
const { data } = await fetchProjectOverview(studyId);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
const centers = Array.isArray((data as any)?.centers) ? (data as any).centers : [];
headerOverviewStats.value = {
centerActual: centers.length,
enrollmentActual: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_actual), 0),
enrollmentTarget: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_target), 0),
updatedAt: typeof (data as any)?.updated_at === "string" ? (data as any).updated_at : "",
};
} catch {
if (study.currentStudy?.id === studyId) {
headerOverviewStats.value = null;
}
}
};
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
@@ -638,45 +562,6 @@ const headerReminderBadgeValue = computed(() =>
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
);
// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间)
const headerProjectInfo = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return null;
const s = study.currentStudy;
const status = (s.status || "").toUpperCase();
const statusLabel = (TEXT.enums.projectStatus as Record<string, string>)[status] || s.status || "";
const statusTone = status === "ACTIVE" ? "active" : status === "CLOSED" ? "closed" : "draft";
const plannedSiteCount = s.planned_site_count;
const plannedEnrollmentCount = s.planned_enrollment_count;
const overviewStats = headerOverviewStats.value;
const centerActual = overviewStats?.centerActual || 0;
const enrollmentActual = overviewStats?.enrollmentActual || 0;
const enrollmentTarget = overviewStats?.enrollmentTarget || toHeaderNumber(plannedEnrollmentCount);
const updatedAt = headerClockText.value;
const metrics = [
plannedSiteCount || centerActual ? {
key: "plannedSiteCount",
label: TEXT.common.labels.plannedSites,
value: formatHeaderProgress(centerActual || toHeaderNumber(plannedSiteCount), plannedSiteCount),
} : null,
plannedEnrollmentCount || enrollmentActual || enrollmentTarget ? {
key: "plannedEnrollmentCount",
label: TEXT.common.labels.plannedEnrollment,
value: formatHeaderProgress(enrollmentActual || toHeaderNumber(plannedEnrollmentCount), enrollmentTarget || plannedEnrollmentCount),
} : null,
updatedAt ? {
key: "updatedAt",
label: TEXT.common.labels.dataUpdatedAt,
value: updatedAt,
} : null,
].filter(Boolean) as { key: string; label: string; value: string | number }[];
return {
statusLabel,
statusTone,
isLocked: Boolean((s as any).is_locked),
metrics,
};
});
const accountProjectRoleLabel = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
@@ -1088,7 +973,6 @@ watch(() => route.path, () => {
watch(
() => [study.currentStudy?.id, route.path] as const,
() => {
loadHeaderOverviewStats();
loadHeaderReminders();
refreshDesktopRoutePreferences();
},
@@ -1158,10 +1042,6 @@ onMounted(async () => {
mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches);
mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler);
}
headerClockNow.value = new Date();
headerClockTimer = window.setInterval(() => {
headerClockNow.value = new Date();
}, 1000);
if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
clearLegacyDesktopRouteHistoryPreference();
@@ -1178,14 +1058,10 @@ onMounted(async () => {
}
}
loadStudies();
loadHeaderOverviewStats();
loadHeaderReminders();
});
onBeforeUnmount(() => {
if (headerClockTimer) {
window.clearInterval(headerClockTimer);
}
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
@@ -1273,6 +1149,10 @@ useDesktopShortcuts(
border-right: 1px solid rgba(15, 23, 42, 0.2);
}
.web-layout-container .layout-aside.collapsed {
border-right-width: 0;
}
.web-layout-container .aside-logo {
height: 60px;
padding: 0 16px;
@@ -1298,9 +1178,9 @@ useDesktopShortcuts(
}
.web-layout-container .content-wrapper {
min-height: calc(100vh - 52px);
min-height: calc(100dvh - 52px);
padding: 12px 14px;
height: 100%;
min-height: 0;
padding: 0;
}
.layout-aside {
@@ -1549,6 +1429,8 @@ useDesktopShortcuts(
}
.main-container {
height: 100%;
min-height: 0;
background: #f7f8fb;
min-width: 0;
}
@@ -1826,296 +1708,6 @@ useDesktopShortcuts(
flex-shrink: 0;
}
/* 顶栏中间区:常驻上下文信息 */
.header-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 0 12px;
overflow: hidden;
}
.header-context-rail {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
max-width: 100%;
height: 30px;
padding: 1px 0;
border: 1px solid transparent;
border-radius: 14px;
background: transparent;
box-shadow: none;
overflow: visible;
white-space: nowrap;
}
.header-status-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 13px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 13.5px;
font-weight: 760;
line-height: 1;
white-space: nowrap;
letter-spacing: 0;
overflow: hidden;
transition:
transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.24s ease,
border-color 0.24s ease;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
inset 0 -1px 0 rgba(15, 23, 42, 0.04);
}
/* 状态药丸高光扫光:缓慢横扫一道柔光,营造高级质感 */
.header-status-pill::after {
content: "";
position: absolute;
top: 0;
left: -60%;
width: 45%;
height: 100%;
background: linear-gradient(
100deg,
transparent 0%,
rgba(255, 255, 255, 0.55) 50%,
transparent 100%
);
transform: skewX(-18deg);
opacity: 0;
pointer-events: none;
}
.header-status-pill:hover {
transform: translateY(-1px);
}
.header-status-pill.is-active {
border-color: rgba(96, 188, 146, 0.78);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.92) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #e6fbf0 0%, #cdeedd 55%, #b9e7d2 100%);
color: #1d6e4f;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(30, 109, 80, 0.12),
0 2px 10px rgba(34, 145, 100, 0.18);
}
.header-status-pill.is-active::after {
animation: header-pill-sheen 4.4s ease-in-out 1.2s infinite;
}
.header-status-pill.is-active:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(30, 109, 80, 0.14),
0 6px 18px rgba(34, 145, 100, 0.28);
}
.header-status-pill.is-closed {
border-color: rgba(232, 175, 96, 0.78);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.94) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #fff7e9 0%, #fbe6bf 55%, #f8dca6 100%);
color: #8f520c;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(143, 82, 12, 0.1),
0 2px 10px rgba(214, 152, 56, 0.16);
}
.header-status-pill.is-closed:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95),
inset 0 -1px 0 rgba(143, 82, 12, 0.12),
0 6px 18px rgba(214, 152, 56, 0.26);
}
.header-status-pill.is-draft {
border-color: rgba(196, 209, 224, 0.96);
background:
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.95) 0, rgba(255, 255, 255, 0) 38%),
linear-gradient(135deg, #fbfdff 0%, #eef3f9 55%, #e4ecf5 100%);
color: #4f667e;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(83, 106, 130, 0.08),
0 2px 9px rgba(120, 138, 163, 0.14);
}
.header-status-pill.is-draft:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
inset 0 -1px 0 rgba(83, 106, 130, 0.1),
0 6px 16px rgba(120, 138, 163, 0.2);
}
.header-status-dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
box-shadow:
0 0 0 3px color-mix(in srgb, currentColor 16%, transparent),
0 1px 2px rgba(15, 23, 42, 0.14);
}
/* 进行中状态点呼吸光环:周期性向外扩散并淡出 */
.header-status-pill.is-active .header-status-dot::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: currentColor;
animation: header-dot-pulse 2.4s ease-out infinite;
}
@keyframes header-pill-sheen {
0%,
78% {
left: -60%;
opacity: 0;
}
82% {
opacity: 0.9;
}
100% {
left: 130%;
opacity: 0;
}
}
@keyframes header-dot-pulse {
0% {
transform: scale(1);
opacity: 0.55;
}
70%,
100% {
transform: scale(2.8);
opacity: 0;
}
}
.header-meta-item {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
height: 28px;
padding: 0 11px;
border: 1px solid rgba(213, 224, 237, 0.94);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(238, 245, 252, 0.86) 100%);
font-size: 12px;
line-height: 1;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(148, 163, 184, 0.08);
transition:
border-color 0.22s ease,
background 0.22s ease,
box-shadow 0.22s ease,
transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 指标项左侧彩色强调条:随类型变色,悬浮时点亮 */
.header-meta-item::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 3px;
height: 13px;
border-radius: 0 3px 3px 0;
transform: translateY(-50%) scaleY(0.55);
opacity: 0;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.header-meta-item.is-plannedSiteCount::before {
background: linear-gradient(180deg, #5fb4ec 0%, #3f8fd6 100%);
}
.header-meta-item.is-plannedEnrollmentCount::before {
background: linear-gradient(180deg, #6cc79c 0%, #3fa777 100%);
}
.header-meta-item.is-updatedAt::before {
background: linear-gradient(180deg, #b6a4ec 0%, #8b78d6 100%);
}
.header-meta-item:hover {
border-color: rgba(160, 182, 210, 0.98);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(244, 249, 254, 0.95) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 0 rgba(148, 163, 184, 0.1),
0 5px 16px rgba(38, 63, 102, 0.12);
transform: translateY(-1px);
}
.header-meta-item:hover::before {
opacity: 1;
transform: translateY(-50%) scaleY(1);
}
.header-meta-item.is-updatedAt {
padding: 0 12px;
font-variant-numeric: tabular-nums;
color: #52677f;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(241, 246, 252, 0.9) 100%);
}
.header-meta-item .el-icon {
font-size: 13px;
color: #94a3b8;
}
.header-meta-label {
color: #778aa3;
margin-right: 0;
font-weight: 560;
}
.header-meta-value {
color: #506179;
font-weight: 720;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
transition: color 0.22s ease;
}
.header-meta-item.is-plannedSiteCount:hover .header-meta-value {
color: #2f7fc4;
}
.header-meta-item.is-plannedEnrollmentCount:hover .header-meta-value {
color: #2f8f63;
}
.header-meta-item.is-updatedAt:hover .header-meta-value {
color: #6a5bbf;
}
.header-role {
font-weight: 600;
@@ -2308,26 +1900,6 @@ useDesktopShortcuts(
font-weight: 600;
}
.header-locked {
color: #b45309;
}
.header-locked .el-icon {
color: #d97706;
}
@media (max-width: 1280px) {
.header-metric {
display: none;
}
}
@media (max-width: 1100px) {
.header-center {
display: none;
}
}
.collapse-btn {
border: 1px solid transparent;
color: var(--ctms-text-secondary);
@@ -2411,6 +1983,7 @@ useDesktopShortcuts(
}
.layout-main {
min-height: 0;
padding: 0;
overflow-y: auto;
background: #f7f8fb;
@@ -2424,9 +1997,26 @@ useDesktopShortcuts(
background: #f7f8fb;
}
.layout-main.is-full-bleed {
overflow: hidden;
}
.content-wrapper.is-full-bleed {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
}
.content-wrapper.is-full-bleed .ctms-route-shell {
height: 100%;
min-height: 0;
}
.ctms-route-shell {
width: 100%;
min-height: 100%;
height: 100%;
min-height: 0;
transform-origin: top center;
}
@@ -2494,6 +2084,7 @@ useDesktopShortcuts(
}
.web-layout-container .content-wrapper {
height: auto;
min-height: calc(100dvh - 52px);
padding: 10px;
}
@@ -2592,8 +2183,7 @@ useDesktopShortcuts(
}
.web-layout-container .content-wrapper {
min-height: calc(100dvh - 56px);
padding: 18px 24px 24px;
padding: 0;
}
}
@@ -2603,9 +2193,6 @@ useDesktopShortcuts(
padding-left: 28px;
}
.web-layout-container .content-wrapper {
padding: 22px 32px 32px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -2631,20 +2218,6 @@ useDesktopShortcuts(
transform: none;
}
.header-status-pill,
.header-meta-item {
transition-duration: 1ms !important;
}
.header-status-pill:hover,
.header-meta-item:hover {
transform: none;
}
.header-status-pill.is-active::after,
.header-status-pill.is-active .header-status-dot::before {
animation: none;
}
}
.breadcrumb-dropdown-menu {
min-width: 216px;