c1dd8def2c
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。
1106 lines
41 KiB
Vue
1106 lines
41 KiB
Vue
<template>
|
|
<section class="collaboration-library ctms-page-shell page--flush">
|
|
<aside class="collaboration-library__sidebar">
|
|
<input
|
|
ref="fileInputRef"
|
|
class="visually-hidden"
|
|
type="file"
|
|
accept=".docx,.xlsx,.pptx"
|
|
@change="handleImport"
|
|
/>
|
|
<div v-if="canCreate" class="collaboration-primary-actions">
|
|
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新建</el-button>
|
|
<el-button :icon="Upload" :loading="importing" @click="fileInputRef?.click()">导入</el-button>
|
|
</div>
|
|
<nav class="collaboration-navigation" aria-label="协作文件视图">
|
|
<button
|
|
class="folder-item"
|
|
:class="{ active: activeView === 'all' }"
|
|
type="button"
|
|
@click="selectLibraryView('all')"
|
|
><el-icon><Document /></el-icon><span>全部文件</span></button>
|
|
<button
|
|
class="folder-item"
|
|
:class="{ active: activeView === 'recent' }"
|
|
type="button"
|
|
@click="selectLibraryView('recent')"
|
|
><el-icon><Clock /></el-icon><span>最近更新</span></button>
|
|
<button
|
|
class="folder-item"
|
|
:class="{ active: activeView === 'shared' }"
|
|
type="button"
|
|
@click="selectLibraryView('shared')"
|
|
><el-icon><Share /></el-icon><span>与我共享</span></button>
|
|
</nav>
|
|
<button
|
|
v-if="canManage"
|
|
class="collaboration-library__sidebar-title"
|
|
type="button"
|
|
@click="createFolder"
|
|
><span>文件夹</span><el-icon><Plus /></el-icon></button>
|
|
<div v-else class="collaboration-library__sidebar-title"><span>文件夹</span></div>
|
|
<el-dropdown
|
|
v-for="folder in folders"
|
|
:key="folder.id"
|
|
class="folder-dropdown"
|
|
:trigger="isDesktop ? 'contextmenu' : 'click'"
|
|
:disabled="!canManage || renamingFolderId === folder.id"
|
|
placement="bottom-end"
|
|
popper-class="collaboration-folder-actions-popper"
|
|
@command="handleFolderAction($event, folder)"
|
|
>
|
|
<div class="folder-row" :class="{ 'is-desktop': isDesktop }">
|
|
<button
|
|
v-if="renamingFolderId !== folder.id"
|
|
class="folder-item"
|
|
:class="{ active: activeView === 'folder' && selectedFolderId === folder.id }"
|
|
type="button"
|
|
@click.stop="selectFolder(folder.id)"
|
|
><el-icon><Folder /></el-icon><span>{{ folder.name }}</span></button>
|
|
<div v-else class="folder-rename-row">
|
|
<el-icon><Folder /></el-icon>
|
|
<el-input
|
|
v-model="renameFolderValue"
|
|
:data-folder-rename="folder.id"
|
|
size="small"
|
|
maxlength="120"
|
|
@click.stop
|
|
@keydown.enter.stop.prevent="submitRenameFolder(folder)"
|
|
@keydown.esc.stop.prevent="cancelRenameFolder"
|
|
@blur="submitRenameFolder(folder)"
|
|
/>
|
|
</div>
|
|
<button
|
|
v-if="!isDesktop && canManage && renamingFolderId !== folder.id"
|
|
class="folder-more"
|
|
type="button"
|
|
:aria-label="`管理文件夹“${folder.name}”`"
|
|
@click.prevent
|
|
>•••</button>
|
|
</div>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item command="rename">重命名</el-dropdown-item>
|
|
<el-dropdown-item command="delete" divided class="collaboration-action--danger">删除文件夹</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
<button
|
|
class="folder-item folder-item--recycle"
|
|
:class="{ active: activeView === 'recycle' }"
|
|
type="button"
|
|
@click="openRecycleBin"
|
|
><el-icon><Delete /></el-icon><span>回收站</span></button>
|
|
</aside>
|
|
|
|
<div class="collaboration-library__main">
|
|
<header class="collaboration-toolbar">
|
|
<div class="collaboration-toolbar__heading">
|
|
<strong>{{ currentViewTitle }}</strong>
|
|
<span>{{ displayedFiles.length }} 个文件</span>
|
|
</div>
|
|
<div class="collaboration-toolbar__spacer" />
|
|
<el-input
|
|
v-model="keyword"
|
|
clearable
|
|
placeholder="搜索文件名"
|
|
class="collaboration-toolbar__search"
|
|
@keyup.enter="loadFiles"
|
|
@clear="loadFiles"
|
|
/>
|
|
<el-button @click="loadFiles">查询</el-button>
|
|
<el-select v-model="fileTypeFilter" class="collaboration-toolbar__filter" aria-label="文件类型筛选">
|
|
<el-option label="全部类型" value="all" />
|
|
<el-option label="Word" value="word" />
|
|
<el-option label="Excel" value="cell" />
|
|
<el-option label="PowerPoint" value="slide" />
|
|
</el-select>
|
|
</header>
|
|
|
|
<div class="collaboration-table-wrap">
|
|
<el-table
|
|
v-loading="loading"
|
|
:data="displayedFiles"
|
|
class="ctms-table"
|
|
size="small"
|
|
table-layout="fixed"
|
|
@row-dblclick="openFile"
|
|
@row-contextmenu="openFileContextMenu"
|
|
>
|
|
<el-table-column label="文件名" min-width="300" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<div class="file-name-cell">
|
|
<span class="file-type-badge" :class="`is-${row.file_type}`">{{ fileTypeLabel(row.file_type) }}</span>
|
|
<span>{{ row.title }}</span>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="协作者" width="150">
|
|
<template #default="{ row }">
|
|
<div v-if="row.collaborators?.length" class="collaborator-stack">
|
|
<el-tooltip
|
|
v-for="collaborator in (row.collaborators || []).slice(0, 4)"
|
|
:key="collaborator.user_id"
|
|
:content="`${collaborator.full_name} · ${collaborator.role === 'MANAGER' ? '管理者' : '编辑者'}`"
|
|
placement="top"
|
|
>
|
|
<el-avatar
|
|
:size="26"
|
|
:src="collaborator.avatar_url || undefined"
|
|
:class="`collaborator-avatar tone-${collaboratorTone(collaborator.user_id)}`"
|
|
>{{ collaboratorInitial(collaborator.full_name) }}</el-avatar>
|
|
</el-tooltip>
|
|
<span v-if="(row.collaborators?.length || 0) > 4" class="collaborator-more">+{{ row.collaborators.length - 4 }}</span>
|
|
</div>
|
|
<span v-else class="collaborator-empty">—</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="owner_name" label="所有者" min-width="120" show-overflow-tooltip />
|
|
<el-table-column label="我的权限" width="110">
|
|
<template #default="{ row }">{{ accessLabel(row) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="更新时间" width="180">
|
|
<template #default="{ row }">{{ displayDateTime(row.updated_at) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="72" fixed="right" align="center">
|
|
<template #default="{ row }">
|
|
<el-dropdown
|
|
v-if="fileActions(row).length"
|
|
trigger="click"
|
|
placement="bottom-end"
|
|
popper-class="collaboration-row-actions-popper"
|
|
@command="handleFileAction($event, row)"
|
|
>
|
|
<el-button
|
|
text
|
|
class="row-action-trigger"
|
|
:aria-label="`打开“${row.title}”的操作菜单`"
|
|
><span aria-hidden="true">•••</span></el-button>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item
|
|
v-for="action in fileActions(row)"
|
|
:key="action.command"
|
|
:command="action.command"
|
|
:divided="action.divided"
|
|
:class="{ 'collaboration-action--danger': action.danger }"
|
|
>
|
|
<el-icon v-if="action.icon"><component :is="action.icon" /></el-icon>{{ action.label }}
|
|
</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</template>
|
|
</el-table-column>
|
|
<template #empty>
|
|
<div v-if="!loading" class="collaboration-empty">{{ currentEmptyText }}</div>
|
|
</template>
|
|
</el-table>
|
|
|
|
<el-dropdown
|
|
v-if="isDesktop"
|
|
ref="fileContextMenuRef"
|
|
trigger="click"
|
|
placement="bottom-start"
|
|
popper-class="collaboration-row-actions-popper collaboration-file-context-popper"
|
|
@command="handleFileContextAction"
|
|
>
|
|
<button
|
|
class="file-context-menu-anchor"
|
|
type="button"
|
|
tabindex="-1"
|
|
aria-hidden="true"
|
|
:style="{ left: `${fileContextMenuPosition.x}px`, top: `${fileContextMenuPosition.y}px` }"
|
|
/>
|
|
<template #dropdown>
|
|
<el-dropdown-menu v-if="fileContextMenuRow">
|
|
<el-dropdown-item
|
|
v-for="action in fileActions(fileContextMenuRow)"
|
|
:key="action.command"
|
|
:command="action.command"
|
|
:divided="action.divided"
|
|
:class="{ 'collaboration-action--danger': action.danger }"
|
|
>
|
|
<el-icon v-if="action.icon"><component :is="action.icon" /></el-icon>{{ action.label }}
|
|
</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</div>
|
|
</div>
|
|
|
|
<el-dialog v-model="createDialogVisible" title="新建协作文件" width="440px" class="collaboration-action-dialog">
|
|
<el-form label-position="top">
|
|
<el-form-item label="文件类型">
|
|
<el-radio-group v-model="createForm.file_type" @change="syncCreateDefaultTitle">
|
|
<el-radio-button value="word">Word</el-radio-button>
|
|
<el-radio-button value="cell">Excel</el-radio-button>
|
|
<el-radio-button value="slide">PowerPoint</el-radio-button>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
<el-form-item label="文件名">
|
|
<el-input v-model="createForm.title" maxlength="240" autofocus @keyup.enter="submitCreate" />
|
|
</el-form-item>
|
|
<el-form-item label="所在文件夹">
|
|
<el-select v-model="createForm.folder_id" clearable placeholder="全部文件">
|
|
<el-option v-for="folder in folders" :key="folder.id" :label="folder.name" :value="folder.id" />
|
|
</el-select>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="creating" @click="submitCreate">创建并打开</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="renameDialogVisible" title="重命名" width="420px" class="collaboration-action-dialog">
|
|
<el-form label-position="top">
|
|
<el-form-item label="文件名">
|
|
<el-input v-model="renameTitle" maxlength="240" autofocus @keyup.enter="submitRenameFile">
|
|
<template #append>.{{ renameFile?.extension }}</template>
|
|
</el-input>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="renameDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="renamingFile" @click="submitRenameFile">保存</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="moveDialogVisible" title="移动到" width="440px" class="collaboration-action-dialog">
|
|
<el-form label-position="top">
|
|
<el-form-item label="目标文件夹">
|
|
<el-select v-model="moveFolderId" clearable placeholder="全部文件(根目录)">
|
|
<el-option label="全部文件(根目录)" value="" />
|
|
<el-option v-for="folder in folders" :key="folder.id" :label="folder.name" :value="folder.id" />
|
|
</el-select>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="moveDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="movingFile" @click="submitMoveFile">移动</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<CollaborationAccessDialog v-if="accessFile" :key="accessFile.id" ref="accessManagementRef"
|
|
:file="accessFile" :target="{ scope: 'project', studyId: requireStudyId() }"
|
|
@close="accessFile = null" @changed="loadFiles" />
|
|
|
|
<el-dialog v-model="infoDialogVisible" title="文件信息" width="680px" class="collaboration-action-dialog">
|
|
<el-descriptions v-if="infoFile" :column="2" border class="file-info-descriptions">
|
|
<el-descriptions-item label="文件名" :span="2">{{ infoFile.title }}</el-descriptions-item>
|
|
<el-descriptions-item label="文件类型">{{ fileTypeName(infoFile.file_type) }}</el-descriptions-item>
|
|
<el-descriptions-item label="文件大小">{{ fileSizeLabel(infoFile.current_revision_file_size) }}</el-descriptions-item>
|
|
<el-descriptions-item label="所在位置">{{ infoFile.folder_name || "全部文件" }}</el-descriptions-item>
|
|
<el-descriptions-item label="所有者">{{ infoFile.owner_name || "—" }}</el-descriptions-item>
|
|
<el-descriptions-item label="我的权限">{{ accessLabel(infoFile) }}</el-descriptions-item>
|
|
<el-descriptions-item label="协作者">{{ infoFile.collaborators.length }} 人</el-descriptions-item>
|
|
<el-descriptions-item label="创建时间">{{ displayDateTime(infoFile.created_at) }}</el-descriptions-item>
|
|
<el-descriptions-item label="更新时间">{{ displayDateTime(infoFile.updated_at) }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
</el-dialog>
|
|
|
|
<CollaborationHistoryDialog v-if="historyFile" :key="historyFile.id" :file="historyFile"
|
|
:target="{ scope: 'project', studyId: requireStudyId() }" :can-create="canCreate" :can-manage-folders="canManage"
|
|
@close="historyFile = null" @updated="updateHistoryFile" @changed="loadFiles" />
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch, type Component } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { ElMessage, ElMessageBox } from "element-plus";
|
|
import {
|
|
Clock,
|
|
CopyDocument,
|
|
Delete,
|
|
Document,
|
|
Download,
|
|
Edit,
|
|
EditPen,
|
|
Folder,
|
|
FolderOpened,
|
|
InfoFilled,
|
|
Plus,
|
|
Share,
|
|
Upload,
|
|
User,
|
|
} from "@element-plus/icons-vue";
|
|
import {
|
|
copyCollaborationFile,
|
|
createCollaborationFile,
|
|
createCollaborationFolder,
|
|
deleteCollaborationFolder,
|
|
downloadCollaborationFile,
|
|
fetchCollaborationFile,
|
|
fetchCollaborationFiles,
|
|
fetchCollaborationFolders,
|
|
importCollaborationFile,
|
|
restoreCollaborationFile,
|
|
trashCollaborationFile,
|
|
updateCollaborationFile,
|
|
updateCollaborationFolder,
|
|
} from "../../api/collaboration";
|
|
import { useStudyStore } from "../../store/study";
|
|
import { useAuthStore } from "../../store/auth";
|
|
import { usePermission } from "../../utils/permission";
|
|
import { displayDateTime } from "../../utils/display";
|
|
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
|
import { isTauriRuntime } from "../../runtime";
|
|
import { downloadFileWithFeedback } from "../../utils/fileTaskFeedback";
|
|
import { formatFileSize } from "../../components/attachments/attachmentUtils";
|
|
import CollaborationAccessDialog from "../../components/collaboration/CollaborationAccessDialog.vue";
|
|
import CollaborationHistoryDialog from "../../components/collaboration/CollaborationHistoryDialog.vue";
|
|
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
|
|
import type {
|
|
CollaborationFile,
|
|
CollaborationFileType,
|
|
CollaborationFolder,
|
|
} from "../../types/collaboration";
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const study = useStudyStore();
|
|
const auth = useAuthStore();
|
|
const isDesktop = isTauriRuntime();
|
|
const { can } = usePermission();
|
|
type CollaborationLibraryView = "all" | "recent" | "shared" | "folder" | "recycle";
|
|
const loading = ref(false);
|
|
const folders = ref<CollaborationFolder[]>([]);
|
|
const files = ref<CollaborationFile[]>([]);
|
|
const selectedFolderId = ref<string | null>(null);
|
|
const activeView = ref<CollaborationLibraryView>("all");
|
|
const showRecycleBin = computed(() => activeView.value === "recycle");
|
|
const keyword = ref("");
|
|
const fileTypeFilter = ref<"all" | CollaborationFileType>("all");
|
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
|
const importing = ref(false);
|
|
const canCreate = computed(() =>
|
|
["read", "create", "edit", "manage", "export", "delete"].every((action) =>
|
|
can(`collaboration.${action}`),
|
|
),
|
|
);
|
|
const canManage = computed(() => can("collaboration.manage"));
|
|
const selectedFolder = computed(() => folders.value.find((item) => item.id === selectedFolderId.value) || null);
|
|
const renamingFolderId = ref<string | null>(null);
|
|
const renameFolderValue = ref("");
|
|
const renameFolderSaving = ref(false);
|
|
const currentViewTitle = computed(() => {
|
|
if (activeView.value === "folder") return selectedFolder.value?.name || "文件夹";
|
|
return {
|
|
all: "全部文件",
|
|
recent: "最近更新",
|
|
shared: "与我共享",
|
|
recycle: "回收站",
|
|
}[activeView.value];
|
|
});
|
|
const currentEmptyText = computed(() => ({
|
|
all: "暂无协作文件",
|
|
recent: "暂无最近更新的文件",
|
|
shared: "暂无与我共享的文件",
|
|
folder: "此文件夹为空",
|
|
recycle: "回收站为空",
|
|
})[activeView.value]);
|
|
const displayedFiles = computed(() => {
|
|
let result = fileTypeFilter.value === "all"
|
|
? files.value
|
|
: files.value.filter((item) => item.file_type === fileTypeFilter.value);
|
|
if (activeView.value === "shared") {
|
|
result = result.filter((item) => item.owner_id !== auth.user?.id);
|
|
}
|
|
return activeView.value === "recent" ? result.slice(0, 50) : result;
|
|
});
|
|
const fileContextMenuRef = ref<{ handleOpen: () => void; handleClose: () => void } | null>(null);
|
|
const fileContextMenuRow = ref<CollaborationFile | null>(null);
|
|
const fileContextMenuPosition = reactive({ x: 0, y: 0 });
|
|
|
|
const createDialogVisible = ref(false);
|
|
const creating = ref(false);
|
|
const createForm = reactive<{ title: string; file_type: CollaborationFileType; folder_id: string | null }>({
|
|
title: "",
|
|
file_type: "word",
|
|
folder_id: null,
|
|
});
|
|
|
|
const renameDialogVisible = ref(false);
|
|
const renamingFile = ref(false);
|
|
const renameFile = ref<CollaborationFile | null>(null);
|
|
const renameTitle = ref("");
|
|
const moveDialogVisible = ref(false);
|
|
const movingFile = ref(false);
|
|
const moveFile = ref<CollaborationFile | null>(null);
|
|
const moveFolderId = ref("");
|
|
const infoDialogVisible = ref(false);
|
|
const infoFile = ref<CollaborationFile | null>(null);
|
|
|
|
const accessFile = ref<CollaborationFile | null>(null);
|
|
const accessManagementRef = ref<InstanceType<typeof CollaborationAccessDialog> | null>(null);
|
|
|
|
const historyFile = ref<CollaborationFile | null>(null);
|
|
const revisionRefreshTimers: number[] = [];
|
|
|
|
const requireStudyId = () => study.currentStudy?.id || "";
|
|
const fileTypeLabel = (value: CollaborationFileType) => ({ word: "W", cell: "X", slide: "P" })[value];
|
|
const fileTypeName = (value: CollaborationFileType) => ({ word: "Word 文档", cell: "Excel 表格", slide: "PowerPoint 演示" })[value];
|
|
const fileSizeLabel = (value?: number | null) => value == null ? "—" : formatFileSize(value);
|
|
const collaboratorInitial = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
|
const collaboratorTone = (userId: string) => {
|
|
let hash = 0;
|
|
for (const character of userId) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
|
return hash % 5;
|
|
};
|
|
const accessLabel = (item: CollaborationFile) => {
|
|
if (item.owner_id === auth.user?.id) return "所有者";
|
|
if (item.collaboration_role === "MANAGER") return "管理者";
|
|
if (item.collaboration_role === "EDITOR") return "编辑者";
|
|
return "查看者";
|
|
};
|
|
interface FileActionDefinition {
|
|
command: string;
|
|
label: string;
|
|
icon?: Component;
|
|
divided?: boolean;
|
|
danger?: boolean;
|
|
}
|
|
const fileActions = (item: CollaborationFile): FileActionDefinition[] => {
|
|
if (showRecycleBin.value) {
|
|
if (!item.can_manage) return [];
|
|
return [
|
|
{ command: "info", label: "文件信息", icon: InfoFilled },
|
|
{ command: "restore", label: "恢复文件", divided: true },
|
|
];
|
|
}
|
|
|
|
const actions: FileActionDefinition[] = [
|
|
{ command: "open", label: item.can_edit ? "协作编辑" : "查看", icon: EditPen },
|
|
];
|
|
if (item.can_manage) {
|
|
actions.push(
|
|
{ command: "permissions", label: "访问与权限", icon: User, divided: true },
|
|
{ command: "rename", label: "重命名", icon: Edit, divided: true },
|
|
);
|
|
}
|
|
if (item.can_export && canCreate.value) actions.push({ command: "copy", label: "复制", icon: CopyDocument });
|
|
if (item.can_manage) actions.push({ command: "move", label: "移动", icon: FolderOpened });
|
|
actions.push(
|
|
{ command: "info", label: "文件信息", icon: InfoFilled, divided: true },
|
|
{ command: "history", label: "历史版本", icon: Clock },
|
|
);
|
|
if (item.can_export) actions.push({ command: "download", label: "下载", icon: Download });
|
|
if (item.can_manage) {
|
|
actions.push({ command: "trash", label: "移入回收站", icon: Delete, divided: true, danger: true });
|
|
}
|
|
return actions;
|
|
};
|
|
const loadFolders = async () => {
|
|
const studyId = requireStudyId();
|
|
if (!studyId) return;
|
|
const { data } = await fetchCollaborationFolders(studyId);
|
|
folders.value = data;
|
|
};
|
|
|
|
const loadFiles = async () => {
|
|
const studyId = requireStudyId();
|
|
if (!studyId) return;
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await fetchCollaborationFiles(studyId, {
|
|
folder_id: activeView.value === "folder" ? selectedFolderId.value || undefined : undefined,
|
|
keyword: keyword.value.trim() || undefined,
|
|
deleted: showRecycleBin.value || undefined,
|
|
});
|
|
files.value = data;
|
|
await openEditRequestNotificationTarget();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "协作文件加载失败"));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const clearRevisionRefreshTimers = () => {
|
|
while (revisionRefreshTimers.length) window.clearTimeout(revisionRefreshTimers.pop());
|
|
};
|
|
|
|
const scheduleRevisionRefresh = () => {
|
|
clearRevisionRefreshTimers();
|
|
for (const delay of [2_500, 12_000]) {
|
|
revisionRefreshTimers.push(window.setTimeout(() => { void loadFiles(); }, delay));
|
|
}
|
|
};
|
|
|
|
const refresh = async () => {
|
|
try {
|
|
await loadFolders();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "协作文件夹加载失败"));
|
|
}
|
|
await loadFiles();
|
|
};
|
|
|
|
const selectLibraryView = (view: Exclude<CollaborationLibraryView, "folder">) => {
|
|
activeView.value = view;
|
|
selectedFolderId.value = null;
|
|
void loadFiles();
|
|
};
|
|
|
|
const selectFolder = (folderId: string) => {
|
|
selectedFolderId.value = folderId;
|
|
activeView.value = "folder";
|
|
void loadFiles();
|
|
};
|
|
const openRecycleBin = () => {
|
|
selectLibraryView("recycle");
|
|
};
|
|
|
|
const createFolder = async () => {
|
|
try {
|
|
const { value } = await ElMessageBox.prompt("请输入文件夹名称", "新建文件夹", { inputPattern: /\S+/, inputErrorMessage: "请输入文件夹名称" });
|
|
await createCollaborationFolder(requireStudyId(), {
|
|
name: value.trim(),
|
|
parent_id: activeView.value === "folder" ? selectedFolderId.value : null,
|
|
});
|
|
ElMessage.success("文件夹已创建");
|
|
await loadFolders();
|
|
} catch (error: any) {
|
|
if (error === "cancel" || error === "close") return;
|
|
ElMessage.error(await getApiErrorMessage(error, "文件夹创建失败"));
|
|
}
|
|
};
|
|
|
|
const focusRenameFolder = (folderId: string) => {
|
|
void nextTick(() => {
|
|
const input = document.querySelector<HTMLInputElement>(`[data-folder-rename="${folderId}"] input`);
|
|
input?.focus();
|
|
input?.select();
|
|
});
|
|
};
|
|
|
|
const startRenameFolder = (folder: CollaborationFolder) => {
|
|
renamingFolderId.value = folder.id;
|
|
renameFolderValue.value = folder.name;
|
|
focusRenameFolder(folder.id);
|
|
};
|
|
|
|
const cancelRenameFolder = () => {
|
|
renamingFolderId.value = null;
|
|
renameFolderValue.value = "";
|
|
};
|
|
|
|
const submitRenameFolder = async (folder: CollaborationFolder) => {
|
|
if (renamingFolderId.value !== folder.id || renameFolderSaving.value) return;
|
|
const name = renameFolderValue.value.trim();
|
|
if (!name) {
|
|
ElMessage.warning("文件夹名称不能为空");
|
|
focusRenameFolder(folder.id);
|
|
return;
|
|
}
|
|
if (name === folder.name) {
|
|
cancelRenameFolder();
|
|
return;
|
|
}
|
|
renameFolderSaving.value = true;
|
|
try {
|
|
await updateCollaborationFolder(requireStudyId(), folder.id, { name });
|
|
cancelRenameFolder();
|
|
await loadFolders();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "文件夹重命名失败"));
|
|
focusRenameFolder(folder.id);
|
|
} finally {
|
|
renameFolderSaving.value = false;
|
|
}
|
|
};
|
|
|
|
const removeFolder = async (folder: CollaborationFolder) => {
|
|
try {
|
|
await ElMessageBox.confirm(`确认删除空文件夹“${folder.name}”?非空文件夹无法删除。`, "删除文件夹", { type: "warning" });
|
|
await deleteCollaborationFolder(requireStudyId(), folder.id);
|
|
if (selectedFolderId.value === folder.id) {
|
|
selectedFolderId.value = null;
|
|
activeView.value = "all";
|
|
await refresh();
|
|
} else {
|
|
await loadFolders();
|
|
}
|
|
} catch (error: any) {
|
|
if (error === "cancel" || error === "close") return;
|
|
ElMessage.error(await getApiErrorMessage(error, "文件夹删除失败"));
|
|
}
|
|
};
|
|
|
|
const handleFolderAction = (command: string, folder: CollaborationFolder) => {
|
|
if (command === "rename") startRenameFolder(folder);
|
|
if (command === "delete") void removeFolder(folder);
|
|
};
|
|
|
|
const createDefaultTitles: Record<CollaborationFileType, string> = {
|
|
word: "未命名文档",
|
|
cell: "未命名表格",
|
|
slide: "未命名演示",
|
|
};
|
|
|
|
const openCreateDialog = () => {
|
|
createForm.file_type = "word";
|
|
createForm.title = createDefaultTitles.word;
|
|
createForm.folder_id = activeView.value === "folder" ? selectedFolderId.value : null;
|
|
createDialogVisible.value = true;
|
|
};
|
|
|
|
const syncCreateDefaultTitle = () => {
|
|
if (!createForm.title.trim() || Object.values(createDefaultTitles).includes(createForm.title.trim())) {
|
|
createForm.title = createDefaultTitles[createForm.file_type];
|
|
}
|
|
};
|
|
|
|
const submitCreate = async () => {
|
|
if (!createForm.title.trim()) return ElMessage.warning("请输入文件名");
|
|
creating.value = true;
|
|
try {
|
|
const { data } = await createCollaborationFile(requireStudyId(), {
|
|
title: createForm.title.trim(), file_type: createForm.file_type, folder_id: createForm.folder_id,
|
|
});
|
|
createDialogVisible.value = false;
|
|
await router.push(`/knowledge/collaboration/${data.id}`);
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "协作文件创建失败"));
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
};
|
|
|
|
const handleImport = async (event: Event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
importing.value = true;
|
|
try {
|
|
const payload = new FormData();
|
|
payload.append("file", file);
|
|
if (activeView.value === "folder" && selectedFolderId.value) payload.append("folder_id", selectedFolderId.value);
|
|
const { data } = await importCollaborationFile(requireStudyId(), payload);
|
|
await router.push(`/knowledge/collaboration/${data.id}`);
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "Office 文件导入失败"));
|
|
} finally {
|
|
importing.value = false;
|
|
input.value = "";
|
|
}
|
|
};
|
|
|
|
const openFile = (item: CollaborationFile) => {
|
|
if (showRecycleBin.value) return;
|
|
void router.push(`/knowledge/collaboration/${item.id}`);
|
|
};
|
|
|
|
const openRenameDialog = (item: CollaborationFile) => {
|
|
renameFile.value = item;
|
|
renameTitle.value = item.title.replace(new RegExp(`\\.${item.extension}$`, "i"), "");
|
|
renameDialogVisible.value = true;
|
|
};
|
|
|
|
const submitRenameFile = async () => {
|
|
if (!renameFile.value || !renameTitle.value.trim()) return ElMessage.warning("请输入文件名");
|
|
renamingFile.value = true;
|
|
try {
|
|
await updateCollaborationFile(requireStudyId(), renameFile.value.id, { title: renameTitle.value.trim() });
|
|
renameDialogVisible.value = false;
|
|
ElMessage.success("文件已重命名");
|
|
await loadFiles();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "文件重命名失败"));
|
|
} finally {
|
|
renamingFile.value = false;
|
|
}
|
|
};
|
|
|
|
const openMoveDialog = (item: CollaborationFile) => {
|
|
moveFile.value = item;
|
|
moveFolderId.value = item.folder_id || "";
|
|
moveDialogVisible.value = true;
|
|
};
|
|
|
|
const submitMoveFile = async () => {
|
|
if (!moveFile.value) return;
|
|
const targetFolderId = moveFolderId.value || null;
|
|
if ((moveFile.value.folder_id || null) === targetFolderId) {
|
|
moveDialogVisible.value = false;
|
|
return;
|
|
}
|
|
movingFile.value = true;
|
|
try {
|
|
await updateCollaborationFile(requireStudyId(), moveFile.value.id, { folder_id: targetFolderId });
|
|
moveDialogVisible.value = false;
|
|
ElMessage.success("文件已移动");
|
|
await loadFiles();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "文件移动失败"));
|
|
} finally {
|
|
movingFile.value = false;
|
|
}
|
|
};
|
|
|
|
const openFileInfo = (item: CollaborationFile) => {
|
|
infoFile.value = item;
|
|
infoDialogVisible.value = true;
|
|
};
|
|
|
|
const copyFile = async (item: CollaborationFile) => {
|
|
try {
|
|
const { data } = await copyCollaborationFile(requireStudyId(), item.id);
|
|
ElMessage.success(`已创建“${data.title}”`);
|
|
await loadFiles();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "文件复制失败"));
|
|
}
|
|
};
|
|
|
|
const downloadFile = async (item: CollaborationFile) => {
|
|
try {
|
|
const response = await downloadCollaborationFile(requireStudyId(), item.id);
|
|
await downloadFileWithFeedback(
|
|
{
|
|
suggestedName: item.title,
|
|
mimeType: item.current_revision_mime_type || response.data.type,
|
|
data: response.data,
|
|
},
|
|
{ title: `下载 ${item.title}`, completedDetail: "文件下载已开始" },
|
|
);
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "文件下载失败"));
|
|
}
|
|
};
|
|
|
|
const trashFile = async (item: CollaborationFile) => {
|
|
try {
|
|
await ElMessageBox.confirm(`确认将“${item.title}”移入回收站?`, "移入回收站", { type: "warning" });
|
|
await trashCollaborationFile(requireStudyId(), item.id);
|
|
await loadFiles();
|
|
} catch (error: any) {
|
|
if (error === "cancel" || error === "close") return;
|
|
ElMessage.error(await getApiErrorMessage(error, "移入回收站失败"));
|
|
}
|
|
};
|
|
|
|
const restoreFile = async (item: CollaborationFile) => {
|
|
try {
|
|
await restoreCollaborationFile(requireStudyId(), item.id);
|
|
await loadFiles();
|
|
} catch (error) {
|
|
ElMessage.error(await getApiErrorMessage(error, "协作文件恢复失败"));
|
|
}
|
|
};
|
|
|
|
const handleFileAction = (command: string, item: CollaborationFile) => {
|
|
switch (command) {
|
|
case "open":
|
|
openFile(item);
|
|
break;
|
|
case "history":
|
|
void openHistory(item);
|
|
break;
|
|
case "permissions":
|
|
void openAccessManagement(item);
|
|
break;
|
|
case "rename":
|
|
openRenameDialog(item);
|
|
break;
|
|
case "copy":
|
|
void copyFile(item);
|
|
break;
|
|
case "move":
|
|
openMoveDialog(item);
|
|
break;
|
|
case "info":
|
|
openFileInfo(item);
|
|
break;
|
|
case "download":
|
|
void downloadFile(item);
|
|
break;
|
|
case "trash":
|
|
void trashFile(item);
|
|
break;
|
|
case "restore":
|
|
void restoreFile(item);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const openFileContextMenu = async (item: CollaborationFile, _column: unknown, event: MouseEvent) => {
|
|
if (!isDesktop) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (fileActions(item).length === 0) return;
|
|
fileContextMenuRef.value?.handleClose();
|
|
fileContextMenuRow.value = item;
|
|
fileContextMenuPosition.x = event.clientX;
|
|
fileContextMenuPosition.y = event.clientY;
|
|
await nextTick();
|
|
fileContextMenuRef.value?.handleOpen();
|
|
};
|
|
|
|
const handleFileContextAction = (command: string) => {
|
|
if (!fileContextMenuRow.value) return;
|
|
handleFileAction(command, fileContextMenuRow.value);
|
|
fileContextMenuRef.value?.handleClose();
|
|
};
|
|
|
|
const openAccessManagement = async (item: CollaborationFile) => {
|
|
accessFile.value = item;
|
|
await nextTick();
|
|
};
|
|
|
|
const openEditRequestNotificationTarget = async () => {
|
|
const rawFileId = route.query.editRequestFile;
|
|
const fileId = typeof rawFileId === "string" ? rawFileId : "";
|
|
if (!fileId || accessFile.value?.id === fileId) return;
|
|
let target = files.value.find((item) => item.id === fileId) || null;
|
|
if (!target) {
|
|
try {
|
|
target = (await fetchCollaborationFile(requireStudyId(), fileId)).data;
|
|
} catch {
|
|
target = null;
|
|
}
|
|
}
|
|
if (!target?.can_manage) return;
|
|
const nextQuery = { ...route.query };
|
|
delete nextQuery.editRequestFile;
|
|
await router.replace({ path: route.path, query: nextQuery });
|
|
await openAccessManagement(target);
|
|
await nextTick();
|
|
await accessManagementRef.value?.focusEditRequests();
|
|
};
|
|
|
|
watch(
|
|
() => route.query.editRequestFile,
|
|
() => { void openEditRequestNotificationTarget(); },
|
|
);
|
|
|
|
const openHistory = (item: CollaborationFile) => { historyFile.value = item; };
|
|
const updateHistoryFile = (file: CollaborationHistoryFile) => {
|
|
files.value = files.value.map((item) => item.id === file.id ? { ...item, ...file } : item);
|
|
};
|
|
|
|
onMounted(() => {
|
|
void refresh();
|
|
scheduleRevisionRefresh();
|
|
});
|
|
onActivated(() => {
|
|
void loadFiles();
|
|
scheduleRevisionRefresh();
|
|
});
|
|
onDeactivated(clearRevisionRefreshTimers);
|
|
onDeactivated(() => { historyFile.value = null; });
|
|
onBeforeUnmount(clearRevisionRefreshTimers);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.collaboration-library {
|
|
display: grid;
|
|
grid-template-columns: 208px minmax(0, 1fr);
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
background: var(--ctms-bg-card);
|
|
}
|
|
.collaboration-library__sidebar {
|
|
height: 100%;
|
|
min-height: 0;
|
|
padding: 14px 10px;
|
|
overflow-y: auto;
|
|
border-right: 1px solid var(--ctms-border-color);
|
|
background: var(--ctms-bg-base);
|
|
}
|
|
.collaboration-primary-actions {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 0 2px 14px;
|
|
}
|
|
.collaboration-primary-actions :deep(.el-button) {
|
|
width: 100%;
|
|
margin: 0;
|
|
}
|
|
.collaboration-navigation {
|
|
display: grid;
|
|
gap: 2px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.collaboration-library__sidebar-title {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
margin: 0 0 4px;
|
|
padding: 7px 9px 5px;
|
|
border: 0;
|
|
color: var(--ctms-text-disabled);
|
|
background: transparent;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-align: left;
|
|
}
|
|
button.collaboration-library__sidebar-title { cursor: pointer; }
|
|
button.collaboration-library__sidebar-title:hover { color: var(--ctms-primary); }
|
|
.collaboration-library__sidebar-title .el-icon { font-size: 15px; }
|
|
.folder-dropdown { display: block; width: 100%; }
|
|
.folder-row { position: relative; width: 100%; min-width: 0; }
|
|
.folder-row:not(.is-desktop) .folder-item { padding-right: 38px; }
|
|
.folder-item .el-icon {
|
|
flex: 0 0 auto;
|
|
font-size: 16px;
|
|
}
|
|
.folder-item span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.folder-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
width: 100%;
|
|
margin: 1px 0;
|
|
padding: 8px 10px;
|
|
overflow: hidden;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
color: var(--ctms-text-regular);
|
|
background: transparent;
|
|
text-align: left;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
}
|
|
.folder-item:hover { background: var(--ctms-neutral-100); }
|
|
.folder-item.active { color: var(--ctms-primary); background: var(--ctms-primary-light); font-weight: 600; }
|
|
.folder-more {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 6px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 26px;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 5px;
|
|
color: var(--ctms-text-secondary);
|
|
background: transparent;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
letter-spacing: 1px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transform: translateY(-50%);
|
|
}
|
|
.folder-row:hover .folder-more,
|
|
.folder-row:focus-within .folder-more,
|
|
.folder-item.active + .folder-more {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
.folder-more:hover,
|
|
.folder-more:focus-visible {
|
|
color: var(--ctms-primary);
|
|
background: var(--ctms-primary-light);
|
|
outline: none;
|
|
}
|
|
.folder-rename-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 38px;
|
|
padding: 4px 7px 4px 10px;
|
|
color: var(--ctms-primary);
|
|
}
|
|
.folder-rename-row > .el-icon { flex: 0 0 auto; font-size: 16px; }
|
|
.folder-rename-row :deep(.el-input__wrapper) { padding: 1px 7px; }
|
|
.folder-item--recycle { margin-top: 14px; border-top: 1px solid var(--ctms-border-color); border-radius: 0 0 6px 6px; }
|
|
.collaboration-library__main { display: flex; min-width: 0; min-height: 0; flex-direction: column; padding: 0; background: var(--ctms-bg-card); }
|
|
.collaboration-toolbar { display: flex; align-items: center; gap: 8px; min-height: 54px; padding: 8px 14px; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-card); }
|
|
.collaboration-toolbar__heading { display: flex; align-items: baseline; gap: 9px; min-width: 140px; color: var(--ctms-text-main); }
|
|
.collaboration-toolbar__heading strong { font-size: 16px; font-weight: 650; }
|
|
.collaboration-toolbar__heading span { color: var(--ctms-text-disabled); font-size: 12px; }
|
|
.collaboration-toolbar__search { width: min(280px, 30vw); }
|
|
.collaboration-toolbar__filter { width: 132px; }
|
|
.collaboration-toolbar__spacer { flex: 1; }
|
|
.collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto; background: var(--ctms-bg-card); }
|
|
:deep(.collaboration-table-wrap .el-table__cell) { padding: 6px 0; }
|
|
:deep(.collaboration-table-wrap .el-table__header-wrapper .el-table-fixed-column--right) {
|
|
background: var(--unified-table-header-bg) !important;
|
|
}
|
|
:deep(.collaboration-table-wrap .el-table__body-wrapper .el-table-fixed-column--right) {
|
|
background: var(--el-table-tr-bg-color) !important;
|
|
}
|
|
:deep(.collaboration-table-wrap .el-table__body tr:hover > .el-table-fixed-column--right) {
|
|
background: var(--el-table-row-hover-bg-color) !important;
|
|
}
|
|
.file-name-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
.file-type-badge { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex: 0 0 auto; border-radius: 5px; color: #fff; font-size: 11px; font-weight: 700; }
|
|
.file-type-badge.is-word { background: #4472c4; }
|
|
.file-type-badge.is-cell { background: #2e8b57; }
|
|
.file-type-badge.is-slide { background: #d05a35; }
|
|
.collaborator-stack { display: flex; align-items: center; min-height: 28px; padding-left: 5px; }
|
|
.collaborator-stack :deep(.el-avatar) {
|
|
margin-left: -5px;
|
|
border: 2px solid var(--ctms-bg-card);
|
|
color: #fff;
|
|
font-size: 11px;
|
|
font-weight: 650;
|
|
}
|
|
.collaborator-avatar.tone-0 { background: #4c7aa0; }
|
|
.collaborator-avatar.tone-1 { background: #6b8e7a; }
|
|
.collaborator-avatar.tone-2 { background: #8a6f9e; }
|
|
.collaborator-avatar.tone-3 { background: #b0785e; }
|
|
.collaborator-avatar.tone-4 { background: #6e7f9f; }
|
|
.collaborator-more {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 26px;
|
|
height: 26px;
|
|
margin-left: -5px;
|
|
padding: 0 5px;
|
|
border: 2px solid var(--ctms-bg-card);
|
|
border-radius: 13px;
|
|
color: var(--ctms-text-secondary);
|
|
background: var(--ctms-bg-muted);
|
|
font-size: 10px;
|
|
font-weight: 650;
|
|
}
|
|
.collaborator-empty { color: var(--ctms-text-disabled); }
|
|
.collaboration-empty { padding: 72px 0; color: var(--ctms-text-disabled); }
|
|
.row-action-trigger { width: 30px; min-height: 28px; padding: 0; color: var(--ctms-text-secondary); font-size: 16px; letter-spacing: 1px; }
|
|
.row-action-trigger:hover { color: var(--ctms-primary); background: var(--ctms-bg-muted); }
|
|
.file-context-menu-anchor {
|
|
position: fixed;
|
|
z-index: -1;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
border: 0;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.file-info-descriptions :deep(.el-descriptions__label) { width: 96px; color: var(--ctms-text-secondary); }
|
|
.visually-hidden { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
:global(body.is-desktop-runtime .collaboration-action-dialog.el-dialog) {
|
|
--el-dialog-padding-primary: 0;
|
|
overflow: hidden !important;
|
|
padding: 0 !important;
|
|
}
|
|
:global(.collaboration-row-actions-popper .collaboration-action--danger) { color: #e05252; }
|
|
:global(.collaboration-row-actions-popper .el-dropdown-menu__item .el-icon) { margin-right: 9px; font-size: 15px; }
|
|
:global(.collaboration-folder-actions-popper .collaboration-action--danger) { color: #e05252; }
|
|
@media (max-width: 900px) {
|
|
.collaboration-library { grid-template-columns: 172px minmax(0, 1fr); }
|
|
.collaboration-toolbar { flex-wrap: wrap; }
|
|
.collaboration-toolbar__heading { width: 100%; }
|
|
.collaboration-toolbar__spacer { display: none; }
|
|
.collaboration-toolbar__search { width: min(240px, 45vw); }
|
|
}
|
|
</style>
|