功能(文档预览):集成 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
+38 -96
View File
@@ -1,9 +1,7 @@
<template>
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div v-if="!isDesktop" class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page medical-consult-page--workbench">
<section class="faq-hero">
<h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div v-else class="desktop-toolbar-meta">
<div class="workbench-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
@@ -23,13 +21,12 @@
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<PermissionAction v-if="isDesktop" action="faq.create">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
<div v-else class="spacer" />
</div>
</section>
@@ -44,15 +41,6 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
</div>
<FaqList
:items="faqs"
:categories="categories"
@@ -84,12 +72,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { Collection, Plus, Search } from "@element-plus/icons-vue";
import { Plus, Search } from "@element-plus/icons-vue";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
@@ -98,7 +85,6 @@ import { TEXT } from "../locales";
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
@@ -230,15 +216,6 @@ onMounted(async () => {
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
}
.page-bg-dots {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: radial-gradient(#1e40af 1px, transparent 1px);
background-size: 24px 24px;
}
.faq-hero {
position: relative;
padding: 28px 24px 36px;
@@ -262,30 +239,6 @@ onMounted(async () => {
pointer-events: none;
}
.faq-hero h1 {
margin: 0 0 24px;
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.hero-title-icon {
font-size: 28px;
color: #3b82f6;
opacity: 0.85;
}
.hero-desc {
margin: 8px 0 28px;
color: #64748b;
font-size: 15px;
font-weight: 500;
}
.hero-tools {
display: flex;
align-items: center;
@@ -349,13 +302,6 @@ onMounted(async () => {
padding: 24px 28px 0;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 0 16px;
}
.list-title strong {
min-width: 30px;
height: 24px;
@@ -377,11 +323,7 @@ onMounted(async () => {
justify-content: flex-end;
}
.spacer {
flex: 1;
}
.desktop-toolbar-meta {
.workbench-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
@@ -391,7 +333,7 @@ onMounted(async () => {
text-align: left;
}
.desktop-toolbar-meta::before {
.workbench-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
@@ -403,19 +345,19 @@ onMounted(async () => {
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.desktop-toolbar-meta span {
.workbench-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.desktop-toolbar-meta small {
.workbench-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--desktop {
.medical-consult-page--workbench {
display: flex;
height: 100%;
min-height: 0;
@@ -425,7 +367,7 @@ onMounted(async () => {
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--desktop .faq-hero {
.medical-consult-page--workbench .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
@@ -443,22 +385,22 @@ onMounted(async () => {
border-radius: 0 !important;
}
.medical-consult-page--desktop .faq-hero::before {
.medical-consult-page--workbench .faq-hero::before {
display: none;
}
.medical-consult-page--desktop .hero-tools {
.medical-consult-page--workbench .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--desktop .hero-search {
.medical-consult-page--workbench .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--desktop .hero-search :deep(.el-input__wrapper) {
.medical-consult-page--workbench .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
@@ -470,17 +412,17 @@ onMounted(async () => {
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__inner) {
.medical-consult-page--workbench .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__prefix) {
.medical-consult-page--workbench .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--desktop .new-consult-btn {
.medical-consult-page--workbench .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
@@ -492,14 +434,14 @@ onMounted(async () => {
transition: all 0.2s;
}
.medical-consult-page--desktop .new-consult-btn:hover,
.medical-consult-page--desktop .new-consult-btn:focus {
.medical-consult-page--workbench .new-consult-btn:hover,
.medical-consult-page--workbench .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--desktop .faq-workspace {
.medical-consult-page--workbench .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
@@ -512,14 +454,14 @@ onMounted(async () => {
overflow: hidden;
}
.medical-consult-page--desktop .faq-sidebar-col,
.medical-consult-page--desktop .faq-content-col {
.medical-consult-page--workbench .faq-sidebar-col,
.medical-consult-page--workbench .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--desktop .faq-sidebar-col {
.medical-consult-page--workbench .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
@@ -528,13 +470,13 @@ onMounted(async () => {
overflow: hidden;
}
.medical-consult-page--desktop .faq-content-col {
.medical-consult-page--workbench .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--desktop .faq-main {
.medical-consult-page--workbench .faq-main {
display: flex;
width: 100%;
height: 100%;
@@ -547,31 +489,31 @@ onMounted(async () => {
background: #ffffff;
}
.medical-consult-page--desktop .pagination-wrap {
.medical-consult-page--workbench .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--desktop :deep(.faq-category-panel) {
.medical-consult-page--workbench :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
.medical-consult-page--workbench :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu) {
.medical-consult-page--workbench :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu .el-menu-item) {
.medical-consult-page--workbench :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
@@ -579,27 +521,27 @@ onMounted(async () => {
line-height: 36px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .cat-icon) {
.medical-consult-page--workbench :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-sidebar-col) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .desktop-toolbar-meta span) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .workbench-toolbar-meta span) {
color: #e5edf7;
}
@@ -619,16 +561,16 @@ onMounted(async () => {
padding: 18px 16px 0;
}
.medical-consult-page--desktop .faq-workspace {
.medical-consult-page--workbench .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--desktop .faq-sidebar-col {
.medical-consult-page--workbench .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--desktop .faq-main {
.medical-consult-page--workbench .faq-main {
padding: 0;
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./OfficePreviewWorkspace.vue"), "utf8");
const hostSource = readFileSync(resolve(__dirname, "../components/OnlyOfficeViewer.vue"), "utf8");
describe("OfficePreviewWorkspace contract", () => {
it("uses a full-page viewer without dialogs, cards, downloads, or browser iframe fallback", () => {
expect(source).toContain("OnlyOfficeViewer");
expect(source).not.toContain("el-dialog");
expect(source).not.toContain("downloadDocumentVersion");
expect(source).not.toContain("downloadAttachment");
expect(source).not.toContain("<iframe");
});
it("destroys signed configuration on deactivation, server changes, and unmount", () => {
expect(source).toContain("onDeactivated");
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source.match(/destroyViewer\(\)/g)?.length).toBeGreaterThanOrEqual(3);
expect(hostSource).toContain('frameRef.value.src = "about:blank"');
});
it("closes a desktop transient preview task before falling back to browser history", () => {
expect(source).toContain("inject(workspaceTaskControllerKey, null)");
expect(source).toContain("workspaceTaskController?.closeTransientTask(route.path)");
expect(source).toContain('workspaceTaskController ? "关闭预览并返回" : "返回"');
expect(source).toContain("router.back()");
});
});
@@ -0,0 +1,258 @@
<template>
<section class="office-preview-page">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
type="button"
:aria-label="backActionLabel"
:title="backActionLabel"
@click="goBack"
>‹</button>
<div class="office-preview-page__title" :title="displayFileName">{{ displayFileName }}</div>
<div class="office-preview-page__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadConfig">重试</el-button>
</header>
<main class="office-preview-page__content">
<OnlyOfficeViewer
v-if="previewConfig && !errorMessage"
:key="viewerKey"
:config="previewConfig.config"
@ready="handleDocumentReady"
@warning="handleWarning"
@error="handleViewerError"
/>
<div v-else class="office-preview-page__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<div class="office-preview-page__error-title">无法预览此文件</div>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadConfig">重新加载</el-button>
</template>
</div>
</main>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Loading } from "@element-plus/icons-vue";
import OnlyOfficeViewer from "../components/OnlyOfficeViewer.vue";
import { fetchAttachmentOnlyOfficeConfig, fetchVersionOnlyOfficeConfig } from "../api/onlyoffice";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
type PreviewStatus = "loading" | "ready" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const study = useStudyStore();
const previewConfig = ref<OnlyOfficePreviewConfig | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const warningMessage = ref("");
const status = ref<PreviewStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() =>
route.name === "OfficeAttachmentPreview" ? "attachment" : "version",
);
const resourceId = computed(() => String(route.params.id || ""));
const displayFileName = computed(() => previewConfig.value?.file_name || String(route.meta.title || "Office 文件预览"));
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
const statusLabel = computed(() => {
if (status.value === "ready") return "只读";
if (status.value === "warning") return warningMessage.value || "预览警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "Office 在线预览尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "Office 预览服务暂时不可用";
if (code === "ONLYOFFICE_FORMAT_UNSUPPORTED" || statusCode === 415) return "该文件格式不支持 Office 在线预览";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有预览此文件的权限";
if (statusCode === 404) return "文件不存在或已被删除";
return getApiErrorMessage(error, "Office 预览配置加载失败");
};
const destroyViewer = () => {
requestSequence.value += 1;
previewConfig.value = null;
viewerSequence.value += 1;
};
const loadConfig = async () => {
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
previewConfig.value = null;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
if (sequence !== requestSequence.value) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
throw new Error("Office 预览宿主页配置不合法");
}
previewConfig.value = response.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (sequence === requestSequence.value) loading.value = false;
}
};
const handleDocumentReady = () => {
status.value = "ready";
};
const viewerDetailMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
const code = detail.errorCode ?? detail.warningCode ?? detail.code;
return code === undefined || code === null ? "" : `错误代码:${String(code)}`;
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = viewerDetailMessage(detail) || "预览服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = viewerDetailMessage(detail) || "文件转换失败,请稍后重试";
status.value = "error";
previewConfig.value = null;
};
const handleServerChange = () => {
destroyViewer();
loading.value = false;
status.value = "error";
errorMessage.value = "服务器地址已切换,请重新加载预览";
};
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
router.back();
};
onMounted(() => {
mounted = true;
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadConfig();
});
onActivated(() => {
if (mounted && !previewConfig.value && !loading.value && !errorMessage.value) void loadConfig();
});
onDeactivated(() => {
destroyViewer();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.office-preview-page {
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.office-preview-page__header {
z-index: 1;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 0 14px 0 8px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.office-preview-page__back {
width: 32px;
height: 32px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 30px/1 system-ui, sans-serif;
cursor: pointer;
}
.office-preview-page__back:hover { background: #edf2f7; }
.office-preview-page__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-preview-page__status {
font-size: 12px;
color: #738398;
white-space: nowrap;
}
.office-preview-page__status.is-ready { color: #25845b; }
.office-preview-page__status.is-warning,
.office-preview-page__status.is-error { color: #b65c29; }
.office-preview-page__content {
min-height: 0;
overflow: hidden;
}
.office-preview-page__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
height: 100%;
color: #718096;
text-align: center;
}
.office-preview-page__state p { margin: 0; }
.office-preview-page__error-title { color: #2f4054; font-size: 17px; font-weight: 600; }
</style>
@@ -57,12 +57,13 @@ describe("DocumentDetail permissions", () => {
expect(source).toContain("@click=\"previewVersion(row)\"");
expect(source).toContain("TEXT.common.labels.preview");
expect(source).toContain('return "image"');
expect(source).toContain('return "pdf"');
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("TEXT.common.messages.previewNotSupported");
expect(source).toContain("URL.createObjectURL(blob)");
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
expect(source).toContain("<PdfViewer");
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("/office-preview/version/${version.id}");
expect(source).not.toContain("<iframe");
});
@@ -117,6 +118,11 @@ describe("DocumentDetail breadcrumbs", () => {
expect(source).toContain("siteName: displaySite(detail.site_id)");
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined");
expect(source.indexOf("syncBreadcrumbContext();", source.indexOf("Object.assign(detail, data);") + 1)).toBeGreaterThan(
source.indexOf("Object.assign(detail, data);"),
);
expect(source).toContain("onActivated(() => {");
expect(source).toContain("if (detail.id) syncBreadcrumbContext();");
});
});
+17 -11
View File
@@ -350,8 +350,8 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { computed, defineAsyncComponent, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
import {
@@ -383,8 +383,10 @@ import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } fro
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { getContentDispositionFilename } from "../../utils/contentDisposition";
import { prepareSaveFile } from "../../runtime";
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
const route = useRoute();
const router = useRouter();
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
const auth = useAuthStore();
const study = useStudyStore();
@@ -531,7 +533,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
const triggerFileInput = async () => {
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
accept: [...ONLYOFFICE_FILE_EXTENSIONS, "pdf", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
@@ -669,6 +671,7 @@ const loadDetail = async () => {
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
syncBreadcrumbContext();
if (!users.value.length) await loadUsers();
if (!members.value.length) await loadMembers();
if (!sites.value.length) await loadSites();
@@ -771,13 +774,7 @@ const getVersionFileName = (version: DocumentVersion) => {
};
const detectPreviewType = (version: DocumentVersion) => {
const mime = (version.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getVersionFileName(version).toLowerCase();
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
return detectFilePreviewKind(getVersionFileName(version), version.mime_type);
};
const clearPreviewObjectUrl = () => {
@@ -789,7 +786,12 @@ const clearPreviewObjectUrl = () => {
const previewVersion = async (version: DocumentVersion) => {
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
clearPreviewObjectUrl();
previewType.value = detectPreviewType(version);
const detectedType = detectPreviewType(version);
if (detectedType === "office") {
await router.push(`/office-preview/version/${version.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewUrl.value = "";
previewError.value = "";
@@ -924,6 +926,10 @@ onMounted(async () => {
await loadDetail();
});
onActivated(() => {
if (detail.id) syncBreadcrumbContext();
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
+7 -30
View File
@@ -1,16 +1,9 @@
<template>
<div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
<section class="unified-action-bar bar--flush">
<section class="unified-action-bar milestone-action-bar">
<div class="card-header ctms-page-header-row">
<div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="card-subtitle">
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
<span class="dot-sep">·</span>
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
</div>
</div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="header-actions">
<el-tag size="small" effect="plain">
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
@@ -200,7 +193,6 @@ import { useStudyStore } from "../../store/study";
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
@@ -237,8 +229,6 @@ const study = useStudyStore();
const { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
const updatedAtLabel = ref<string>(TEXT.common.fallback);
const editorVisible = ref(false);
const editingRowId = ref("");
const editorForm = reactive<MilestoneLocalEdit>({
@@ -309,15 +299,8 @@ const loadMilestones = async () => {
const { data } = (await listProjectMilestones(studyId)) as any;
const list = Array.isArray(data) ? data : data?.items || [];
rows.value = list.map(normalizeRow);
const updatedAt = list
.map((item: any) => String(item?.updated_at || ""))
.filter(Boolean)
.sort()
.pop();
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
} catch (error: any) {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
@@ -452,7 +435,6 @@ watch(
() => study.currentStudy?.id,
() => {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
loadMilestones();
}
);
@@ -503,22 +485,17 @@ watch(
flex-wrap: wrap;
}
.milestone-action-bar {
min-height: 56px;
box-sizing: border-box;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--unified-title-color);
}
.card-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.dot-sep {
margin: 0 8px;
}
.header-actions {
display: flex;
align-items: center;
+24 -2
View File
@@ -53,7 +53,8 @@
</template>
刷新
</el-button>
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<span v-if="!isDesktop" class="overview-live-clock">数据 {{ overviewClockText }}</span>
<span v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
@@ -146,7 +147,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
@@ -164,6 +165,8 @@ const isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const overviewClockNow = ref(new Date());
let overviewClockTimer: number | undefined;
const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
@@ -183,6 +186,16 @@ const overviewUpdatedAtLabel = computed(() => {
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const overviewClockText = computed(() => {
const date = overviewClockNow.value;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
@@ -369,9 +382,17 @@ const reset = () => {
};
onMounted(() => {
overviewClockNow.value = new Date();
overviewClockTimer = window.setInterval(() => {
overviewClockNow.value = new Date();
}, 1000);
loadOverview();
});
onBeforeUnmount(() => {
if (overviewClockTimer) window.clearInterval(overviewClockTimer);
});
watch(
() => study.currentStudy?.id,
() => {
@@ -417,6 +438,7 @@ watch(
border-radius: 8px;
}
.overview-live-clock,
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;