feat(collaboration): 完善在线文档协作与通知闭环
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 14:14:54 +08:00
parent c68dddfc01
commit 1d26646a96
97 changed files with 11911 additions and 316 deletions
@@ -0,0 +1,101 @@
<template>
<el-dialog
:model-value="modelValue"
width="420px"
class="collaboration-download-dialog"
append-to-body
destroy-on-close
:show-close="!saving"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="collaboration-download-dialog__title">下载文件</div>
</template>
<div class="collaboration-download-dialog__content">
<div class="collaboration-download-dialog__icon" aria-hidden="true">
<el-icon><Download /></el-icon>
</div>
<div class="collaboration-download-dialog__body">
<div class="collaboration-download-dialog__name" :title="fileName">{{ fileName }}</div>
<p>文件已生成,请选择本机保存位置。</p>
</div>
</div>
<template #footer>
<el-button :disabled="saving" @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('confirm')">选择保存位置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Download } from "@element-plus/icons-vue";
defineProps<{
modelValue: boolean;
fileName: string;
saving?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [];
}>();
</script>
<style>
.collaboration-download-dialog {
border-radius: 12px;
}
.collaboration-download-dialog .el-dialog__header {
margin-right: 0;
padding: 18px 20px 8px;
}
.collaboration-download-dialog .el-dialog__body {
padding: 14px 20px 18px;
}
.collaboration-download-dialog .el-dialog__footer {
padding: 0 20px 18px;
}
.collaboration-download-dialog__title {
color: #172033;
font-size: 17px;
font-weight: 650;
}
.collaboration-download-dialog__content {
display: flex;
align-items: center;
gap: 14px;
}
.collaboration-download-dialog__icon {
display: grid;
flex: 0 0 42px;
width: 42px;
height: 42px;
place-items: center;
border-radius: 10px;
color: #3f6783;
background: #edf3f7;
font-size: 21px;
}
.collaboration-download-dialog__body {
min-width: 0;
}
.collaboration-download-dialog__name {
overflow: hidden;
color: #24364a;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaboration-download-dialog__body p {
margin: 5px 0 0;
color: #718096;
font-size: 13px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,237 @@
<template>
<el-dialog
:model-value="modelValue"
width="900px"
append-to-body
class="collaboration-action-dialog revision-save-as-dialog"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header><strong class="revision-save-as__title">另存为</strong></template>
<div class="revision-save-as">
<aside class="revision-save-as__sidebar">
<div>
<span class="revision-save-as__sidebar-label">保存位置</span>
<button type="button" class="is-active" @click="selectedFolderId = null">
<el-icon><FolderOpened /></el-icon>
<span>项目工作区</span>
</button>
</div>
</aside>
<div class="revision-save-as__browser">
<div class="revision-save-as__toolbar">
<div class="revision-save-as__breadcrumbs">
<button type="button" @click="selectedFolderId = null">项目工作区</button>
<template v-for="folder in breadcrumbs" :key="folder.id">
<span>/</span>
<button type="button" @click="selectedFolderId = folder.id">{{ folder.name }}</button>
</template>
</div>
<el-input v-model="query" clearable placeholder="搜索文件" class="revision-save-as__search" />
</div>
<div v-loading="loadingWorkspace" class="revision-save-as__items">
<button
v-for="folder in visibleFolders"
:key="folder.id"
type="button"
class="revision-save-as__folder"
@click="selectedFolderId = folder.id"
>
<el-icon><Folder /></el-icon>
<span>{{ folder.name }}</span>
</button>
<div v-for="file in visibleFiles" :key="file.id" class="revision-save-as__file" aria-disabled="true">
<span class="revision-save-as__file-badge" :class="`is-${file.file_type}`">{{ file.file_type === "word" ? "W" : file.file_type === "cell" ? "X" : "P" }}</span>
<span>{{ file.title }}</span>
</div>
<el-empty
v-if="!loadingWorkspace && !visibleFolders.length && !visibleFiles.length"
description="当前目录暂无内容"
:image-size="52"
/>
</div>
<div class="revision-save-as__bottom">
<label class="revision-save-as__filename">
<span>文件名称</span>
<el-input v-model="title" maxlength="240" @keyup.enter="submit" />
</label>
<div class="revision-save-as__actions">
<el-button v-if="canCreateFolder" :icon="Plus" text class="revision-save-as__new-folder" @click="createFolder">新建文件夹</el-button>
<span class="revision-save-as__actions-spacer" />
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">另存</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Folder, FolderOpened, Plus } from "@element-plus/icons-vue";
import {
createCollaborationFolder,
fetchCollaborationFiles,
fetchCollaborationFolders,
} from "../../api/collaboration";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { CollaborationFile, CollaborationFolder } from "../../types/collaboration";
const props = withDefaults(defineProps<{
modelValue: boolean;
studyId: string;
initialTitle: string;
initialFolderId?: string | null;
saving?: boolean;
canCreateFolder?: boolean;
}>(), {
initialFolderId: null,
saving: false,
canCreateFolder: false,
});
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [payload: { title: string; folderId: string | null }];
}>();
const folders = ref<CollaborationFolder[]>([]);
const files = ref<CollaborationFile[]>([]);
const selectedFolderId = ref<string | null>(null);
const title = ref("");
const query = ref("");
const loadingWorkspace = ref(false);
const breadcrumbs = computed(() => {
const result: CollaborationFolder[] = [];
const seen = new Set<string>();
let folderId = selectedFolderId.value;
while (folderId && !seen.has(folderId)) {
seen.add(folderId);
const folder = folders.value.find((item) => item.id === folderId);
if (!folder) break;
result.unshift(folder);
folderId = folder.parent_id || null;
}
return result;
});
const visibleFolders = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
if (keyword) return folders.value.filter((item) => item.name.toLocaleLowerCase("zh-CN").includes(keyword));
return folders.value.filter((item) => (item.parent_id || null) === selectedFolderId.value);
});
const visibleFiles = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
return files.value.filter((item) =>
item.status !== "DELETED" &&
(item.folder_id || null) === selectedFolderId.value &&
(!keyword || item.title.toLocaleLowerCase("zh-CN").includes(keyword)),
);
});
const loadWorkspace = async () => {
if (!props.studyId) return;
loadingWorkspace.value = true;
try {
const [folderResponse, fileResponse] = await Promise.all([
fetchCollaborationFolders(props.studyId),
fetchCollaborationFiles(props.studyId),
]);
folders.value = folderResponse.data;
files.value = fileResponse.data;
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "工作区加载失败"));
} finally {
loadingWorkspace.value = false;
}
};
const createFolder = async () => {
try {
const { value } = await ElMessageBox.prompt("请输入文件夹名称", "新建文件夹", {
inputPlaceholder: "文件夹名称",
inputValidator: (input) => Boolean(String(input || "").trim()) || "文件夹名称不能为空",
confirmButtonText: "新建",
});
const { data } = await createCollaborationFolder(props.studyId, {
name: value.trim(),
parent_id: selectedFolderId.value,
});
folders.value = [...folders.value, data];
selectedFolderId.value = data.id;
ElMessage.success(`文件夹“${data.name}”已创建`);
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "文件夹创建失败"));
}
};
const submit = () => {
const normalized = title.value.trim();
if (!normalized) {
ElMessage.warning("请输入文件名称");
return;
}
emit("confirm", { title: normalized, folderId: selectedFolderId.value });
};
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
title.value = props.initialTitle;
selectedFolderId.value = props.initialFolderId || null;
query.value = "";
void loadWorkspace();
},
);
</script>
<style scoped>
.revision-save-as { display: grid; height: min(560px, calc(100vh - 112px)); min-height: 430px; overflow: hidden; grid-template-columns: 180px minmax(0, 1fr); background: var(--ctms-bg-card); }
.revision-save-as__title { position: relative; z-index: 1; color: var(--ctms-text-main); font-size: 15px; font-weight: 650; }
.revision-save-as__sidebar { display: flex; min-height: 0; padding: 26px 14px 18px; flex-direction: column; background: color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)); }
.revision-save-as__sidebar-label { display: block; margin: 0 12px 10px; color: var(--ctms-text-disabled); font-size: 11px; letter-spacing: 0.04em; }
.revision-save-as__sidebar button { display: flex; width: 100%; height: 42px; align-items: center; gap: 9px; padding: 0 13px; border: 0; border-radius: 8px; color: var(--ctms-text-regular); background: transparent; font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
.revision-save-as__sidebar button .el-icon { font-size: 17px; }
.revision-save-as__sidebar button.is-active { color: var(--ctms-primary); background: color-mix(in srgb, var(--ctms-primary) 11%, var(--ctms-bg-card)); font-weight: 650; }
.revision-save-as__browser { display: grid; min-width: 0; min-height: 0; grid-template-rows: 66px minmax(0, 1fr) auto; }
.revision-save-as__toolbar { display: flex; min-width: 0; align-items: center; gap: 16px; padding: 12px 18px; }
.revision-save-as__breadcrumbs { display: flex; min-width: 0; align-items: center; gap: 6px; flex: 1; overflow: hidden; color: var(--ctms-text-disabled); }
.revision-save-as__breadcrumbs button { max-width: 150px; overflow: hidden; padding: 3px 2px; border: 0; color: var(--ctms-text-main); background: transparent; font: inherit; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.revision-save-as__breadcrumbs button:hover { color: var(--ctms-primary); }
.revision-save-as__search { width: 220px; }
.revision-save-as__search :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__items { min-height: 0; padding: 16px 22px; overflow: auto; }
.revision-save-as__folder,
.revision-save-as__file { display: flex; width: 100%; min-width: 0; height: 42px; align-items: center; gap: 10px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; font: inherit; font-size: 13px; text-align: left; }
.revision-save-as__folder { color: var(--ctms-text-main); cursor: pointer; }
.revision-save-as__folder:hover { background: var(--ctms-bg-muted); }
.revision-save-as__folder .el-icon { flex: 0 0 auto; color: var(--ctms-primary); font-size: 21px; }
.revision-save-as__folder span,
.revision-save-as__file > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.revision-save-as__file { color: var(--ctms-text-disabled); cursor: default; }
.revision-save-as__file-badge { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 4px; color: #fff; font-size: 9px; font-weight: 750; opacity: 0.42; }
.revision-save-as__file-badge.is-word { background: #4472c4; }
.revision-save-as__file-badge.is-cell { background: #2e8b57; }
.revision-save-as__file-badge.is-slide { background: #d05a35; }
.revision-save-as__items > :deep(.el-empty) { padding-top: 62px; }
.revision-save-as__items > :deep(.el-empty__description) { margin-top: 10px; }
.revision-save-as__items > :deep(.el-empty__description p) { color: var(--ctms-text-disabled); font-size: 12px; }
.revision-save-as__bottom { display: grid; gap: 12px; padding: 14px 18px 16px; background: var(--ctms-bg-card); }
.revision-save-as__filename { display: flex; min-width: 0; align-items: center; gap: 12px; }
.revision-save-as__filename > span { flex: 0 0 auto; color: var(--ctms-text-main); font-size: 13px; }
.revision-save-as__filename .el-input { flex: 1; }
.revision-save-as__filename :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__actions { display: flex; min-width: 0; align-items: center; gap: 10px; }
.revision-save-as__actions > .el-button { min-width: 70px; height: 34px; margin-left: 0; border-radius: 7px; }
.revision-save-as__new-folder.el-button { min-width: auto; padding-left: 0; color: var(--ctms-text-main); }
.revision-save-as__actions-spacer { flex: 1; }
:global(.revision-save-as-dialog.el-dialog) { max-width: calc(100vw - 32px); overflow: hidden; padding: 0; border-radius: 12px; box-shadow: 0 20px 54px rgb(15 23 42 / 20%); }
:global(.revision-save-as-dialog.el-dialog .el-dialog__header) { min-height: 50px; margin: 0; padding: 15px 18px; border-bottom: 0 !important; background: linear-gradient(to right, color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)) 0 180px, var(--ctms-bg-card) 180px 100%); box-shadow: none !important; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__headerbtn) { top: 6px; right: 9px; width: 38px; height: 38px; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__body) { padding: 0; }
</style>