Files
ctms/frontend/src/views/knowledge/CollaborationWorkspace.vue
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

618 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section
class="collaboration-workspace"
:class="{
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
}"
>
<div
v-if="webFullscreenActive"
class="collaboration-workspace__fullscreen-hotzone"
aria-hidden="true"
@pointerenter="showFullscreenToolbar"
@pointerleave="scheduleFullscreenToolbarHide"
/>
<header
class="collaboration-workspace__header"
@pointerenter="cancelFullscreenToolbarHide"
@pointerleave="scheduleFullscreenToolbarHide"
@focusin="showFullscreenToolbar"
@focusout="scheduleFullscreenToolbarHide"
>
<button class="collaboration-workspace__back" type="button" title="关闭协作文件并返回" @click="goBack">‹</button>
<div class="collaboration-workspace__title" :title="fileName">{{ fileName }}</div>
<div class="collaboration-workspace__access" :class="`is-${accessMode}`">
{{ accessMode === "edit" ? "协作编辑" : "只读查看" }}
</div>
<div class="collaboration-workspace__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button
v-if="accessMode === 'view' && file?.edit_request_status === 'PENDING'"
size="small"
disabled
>编辑权限申请处理中</el-button>
<el-button
v-else-if="accessMode === 'view' && (file?.can_request_edit || editorConfig?.can_request_edit)"
size="small"
type="primary"
plain
:loading="requestingEdit"
@click="requestEditRights"
>申请编辑权限</el-button>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadWorkspace">重试</el-button>
<el-tooltip v-if="showWorkspaceFullscreenControl" :content="webFullscreenLabel" placement="bottom">
<button
type="button"
class="collaboration-workspace__fullscreen"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
</header>
<main class="collaboration-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(editorConfig.can_download)"
:allow-save-as="editorConfig.can_save_as"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 在线协作:${fileName}`"
@ready="handleReady"
@warning="handleWarning"
@error="handleError"
@state-change="handleStateChange"
@save-as="handleSaveAs"
@save-as-error="handleSaveAsError"
@download="handleDownload"
@download-error="handleDownloadError"
@request-edit-rights="requestEditRights"
/>
<div v-else class="collaboration-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<strong>无法打开协作文件</strong>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadWorkspace">重新加载</el-button>
</template>
</div>
</main>
<CollaborationSaveAsDialog
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
:initial-folder-id="file?.folder_id || null"
:saving="savingCopy"
:can-create-folder="canCreateWorkspaceFile"
@confirm="submitSaveAs"
/>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { FullScreen, Loading, ScaleToOriginal } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import CollaborationSaveAsDialog from "../../components/collaboration/CollaborationSaveAsDialog.vue";
import { workspaceTaskControllerKey } from "../../components/layout/workspaceTaskController";
import {
fetchCollaborationEditorConfig,
fetchCollaborationFile,
createCollaborationEditRequest,
importCollaborationFile,
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
isWebFullscreenActive,
isWebFullscreenAvailable,
isTauriRuntime,
listenWebFullscreenChange,
ONLYOFFICE_HOST_PATH,
prepareSaveFile,
refreshWebFullscreenState,
toggleWebFullscreen,
} from "../../runtime";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { usePermission } from "../../utils/permission";
import type { CollaborationEditorConfig, CollaborationFile } from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type WorkspaceStatus = "loading" | "ready" | "saving" | "saved" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const file = ref<CollaborationFile | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
const loading = ref(false);
const status = ref<WorkspaceStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const savingCopy = ref(false);
const requestingEdit = ref(false);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
const saveAsDialogVisible = ref(false);
const saveAsTitle = ref("");
const pendingSaveAs = ref<OnlyOfficeSaveAsPayload | null>(null);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const fullscreenToolbarVisible = ref(false);
const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const studyId = computed(() => study.currentStudy?.id || "");
const fileId = computed(() => String(route.params.fileId || ""));
const fileName = computed(() => editorConfig.value?.file_name || file.value?.title || "在线协作");
const accessMode = computed(() => editorConfig.value?.access_mode || (file.value?.can_edit ? "edit" : "view"));
const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);
const canCreateWorkspaceFile = computed(() =>
["read", "create", "edit", "manage", "export", "delete"].every((action) =>
can(`collaboration.${action}`),
),
);
const statusLabel = computed(() => {
if (savingCopy.value) return "正在另存为";
if (downloading.value) return "正在下载";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const showWorkspaceFullscreenControl = computed(() => webFullscreenAvailable.value && !isTauriRuntime());
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const cancelFullscreenToolbarHide = () => {
if (fullscreenToolbarHideTimer !== undefined) window.clearTimeout(fullscreenToolbarHideTimer);
fullscreenToolbarHideTimer = undefined;
};
const showFullscreenToolbar = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = true;
};
const scheduleFullscreenToolbarHide = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarHideTimer = window.setTimeout(() => {
fullscreenToolbarVisible.value = false;
fullscreenToolbarHideTimer = undefined;
}, 900);
};
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
const active = isWebFullscreenActive();
if (active !== webFullscreenActive.value) {
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = false;
}
webFullscreenActive.value = active;
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
} finally {
syncWebFullscreenState();
}
};
const exitWorkspaceFullscreen = async () => {
try {
if (!await refreshWebFullscreenState()) return;
await toggleWebFullscreen();
} catch {
// 全屏状态读取或退出失败不应阻止关闭协作页。
} finally {
syncWebFullscreenState();
}
};
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 "在线协作服务尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "在线协作服务暂时不可用";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有查看此协作文件的权限";
if (statusCode === 404) return "协作文件不存在或已被移入回收站";
return getApiErrorMessage(error, "在线协作编辑器加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
savingCopy.value = false;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
};
const loadWorkspace = async () => {
if (!studyId.value || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
requestSequence.value = sequence;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const [fileResponse, configResponse] = await Promise.all([
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const eventMessage = (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 ? "" : `错误代码:${String(code)}`;
};
const handleReady = () => { status.value = "ready"; };
const handleStateChange = (changed: boolean) => { status.value = changed ? "saving" : "saved"; };
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "协作文件加载或转换失败";
status.value = "error";
editorConfig.value = null;
};
const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
if (editorConfig.value) editorConfig.value = { ...editorConfig.value, can_request_edit: false };
ElMessage.success("编辑权限申请已提交");
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "编辑权限申请失败"));
} finally {
requestingEdit.value = false;
}
};
const handleSaveAs = (payload: OnlyOfficeSaveAsPayload) => {
if (savingCopy.value || !editorConfig.value?.can_save_as) return;
const extension = payload.fileType.toLowerCase();
saveAsTitle.value = payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
pendingSaveAs.value = payload;
saveAsDialogVisible.value = true;
};
const submitSaveAs = async (selection: { title: string; folderId: string | null }) => {
const payload = pendingSaveAs.value;
if (!payload || savingCopy.value || !editorConfig.value?.can_save_as) return;
savingCopy.value = true;
try {
const extension = payload.fileType.toLowerCase();
const targetTitle = selection.title.toLowerCase().endsWith(`.${extension}`)
? selection.title
: `${selection.title}.${extension}`;
const form = new FormData();
form.append("file", new File([payload.data], targetTitle, {
type: payload.mimeType || "application/octet-stream",
lastModified: Date.now(),
}));
if (selection.folderId) form.append("folder_id", selection.folderId);
const { data } = await importCollaborationFile(studyId.value, form);
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
ElMessage.success(`已在工作区创建“${data.title}”`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件另存为失败"));
} finally {
savingCopy.value = false;
}
};
const handleSaveAsError = (message: string) => {
ElMessage.error(message || "协作文件另存为失败");
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const extension = payload.fileType.toLowerCase();
const suggestedName = suggestedDownloadName(payload);
// Web 文件选择器必须直接在按钮点击产生的用户手势中调用,不能等到下一轮异步任务。
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载协作文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
try {
await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "协作文件下载失败");
};
const handleServerChange = () => {
destroyEditor();
loading.value = false;
errorMessage.value = "服务器地址已切换,请重新加载协作文件";
status.value = "error";
};
const goBack = async () => {
await exitWorkspaceFullscreen();
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
onMounted(() => {
mounted = true;
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadWorkspace();
});
onActivated(() => {
syncWebFullscreenState();
if (mounted && !editorConfig.value && !loading.value && !errorMessage.value) void loadWorkspace();
});
onDeactivated(() => {
void exitWorkspaceFullscreen();
destroyEditor();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.collaboration-workspace {
position: relative;
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
top: 0;
left: 0;
width: 88px;
height: 14px;
}
.collaboration-workspace__fullscreen-hotzone::after {
position: absolute;
top: 3px;
left: 32px;
width: 24px;
height: 3px;
border-radius: 999px;
background: rgba(38, 56, 76, 0.34);
content: "";
}
.collaboration-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;
align-items: center;
gap: 8px;
padding: 0 12px 0 6px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header {
padding-left: 72px;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header {
position: absolute;
z-index: 21;
top: 6px;
left: 6px;
display: flex;
width: auto;
height: 34px;
gap: 2px;
padding: 0 4px;
border: 1px solid #dce3eb;
border-radius: 11px;
background: #fff;
opacity: 0;
pointer-events: none;
transform: translateY(calc(-100% - 12px));
box-shadow: 0 8px 24px rgba(20, 32, 51, 0.16);
transition: opacity 150ms ease, transform 180ms ease;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen) {
display: none;
}
.collaboration-workspace.is-fullscreen.is-fullscreen-toolbar-visible .collaboration-workspace__header,
.collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible) {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.collaboration-workspace__back {
width: 28px;
height: 28px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 26px/1 system-ui, sans-serif;
cursor: pointer;
}
.collaboration-workspace__back:hover { background: #edf2f7; }
.collaboration-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.collaboration-workspace__access,
.collaboration-workspace__status { font-size: 12px; white-space: nowrap; }
.collaboration-workspace__access { padding: 3px 8px; border-radius: 10px; color: #52667a; background: #eef2f6; }
.collaboration-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.collaboration-workspace__status { color: #738398; }
.collaboration-workspace__status.is-saving { color: #99651e; }
.collaboration-workspace__fullscreen {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
cursor: pointer;
font-size: 16px;
}
.collaboration-workspace__fullscreen:hover { color: #237451; background: #edf5f1; }
.collaboration-workspace__fullscreen:focus-visible { outline: 2px solid rgba(35, 116, 81, 0.3); outline-offset: 1px; }
.collaboration-workspace__status.is-saved,
.collaboration-workspace__status.is-ready { color: #25845b; }
.collaboration-workspace__status.is-warning,
.collaboration-workspace__status.is-error { color: #b65c29; }
.collaboration-workspace__content { min-height: 0; overflow: hidden; }
.collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }
.collaboration-workspace__state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 100%; height: 100%; color: #718096; text-align: center; }
.collaboration-workspace__state strong { color: #2f4054; font-size: 17px; }
.collaboration-workspace__state p { margin: 0; }
</style>