Files
ctms/frontend/src/views/knowledge/CollaborationLibrary.vue
T
chengchengzhou7 6c45cef4b7
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (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
同步全局协作台账与工作台界面优化
工作台新增与项目平级的全局协作台账,复用共享库在线协作能力。两本台账使用合同台账明细表、临床运营项目编号的空白模板,支持独立账号授权、在线编辑和历史版本保留,禁止删除整个台账及历史版本。

台账管理拆分为信息维护、访问与权限、历史版本三个独立入口;后两者与项目共享组件。统一项目和台账的卡片高度、数量徽标与标题布局,缩小工作台顶部留白,优化信息维护弹窗,并修复空用途说明和版本命名的默认提示。同步现有飞线图白色残影修复,以及在线文档的字体构建支持。

已验证:前端五百五十四项测试、后端台账与在线文档七十四项测试、类型检查、界面约束、运行时边界、桌面发布静态检查、网页构建和本地桌面应用构建均通过。数据库表结构升级已完成离线脚本生成检查,存储持久化检查通过。桌面凭据测试显式隔离构建环境,并覆盖使用默认服务器地址读取凭据的场景;在注入默认服务器地址的环境中完成全部前端测试。

提交包含必需的表结构升级和只有表头的初始模板;不包含本地台账业务记录、数据库导出、账号授权运行数据或上传目录中的历史文件。字体文件沿用部署方单独提供的方式。
2026-09-04 08:42:21 +08:00

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>