feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user