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