release(main): 同步 dev 最新候选改动
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

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
@@ -0,0 +1,617 @@
<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>