新增全局协作台账并统一工作台管理界面

新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
Cheng Zhou
2026-09-03 16:55:08 +08:00
parent 46a6dda753
commit c1dd8def2c
41 changed files with 4155 additions and 1815 deletions
+9 -45
View File
@@ -53,14 +53,10 @@
<div class="project-header">
<div class="header-title-block">
<span class="section-eyebrow">Project Workspace</span>
<h2>选择目标项目</h2>
</div>
<div class="header-actions-block">
<span v-if="!loading && projects.length" class="project-pill-badge">
<span class="badge-dot"></span>
<span>{{ projects.length }} 个可进入项目</span>
</span>
<div class="entry-title-row">
<h2>选择目标项目</h2>
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
</div>
</div>
</div>
@@ -130,6 +126,7 @@
<h3>未检测到可用项目</h3>
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
</div>
<LedgerPanel />
</main>
<!-- 科技感转场遮罩 -->
@@ -157,6 +154,9 @@ import type { Study } from "../types/api";
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
import { isSystemAdmin } from "../utils/roles";
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
import EntryCountBadge from "../components/EntryCountBadge.vue";
import "../styles/entry-cards.css";
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -508,7 +508,7 @@ onMounted(() => {
}
.header-title-block h2 {
margin: 2px 0 0;
margin: 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
@@ -543,33 +543,6 @@ onMounted(() => {
content: "";
}
.header-actions-block {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.project-pill-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
.btn-refresh-work {
min-width: 76px;
height: 28px;
@@ -592,8 +565,6 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
min-height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -798,8 +769,6 @@ onMounted(() => {
/* 骨架屏 */
.skeleton-project-card {
height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -930,11 +899,6 @@ onMounted(() => {
color: #e2e8f0;
}
:global([data-ctms-theme="dark"] .project-pill-badge) {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
:global([data-ctms-theme="dark"] .modern-project-card) {
background: rgba(15, 23, 42, 0.55);
border-color: rgba(51, 65, 85, 0.5);
+14 -4
View File
@@ -1,5 +1,5 @@
<template>
<section class="office-preview-page">
<section class="office-preview-page" :class="{ 'is-ledger-preview': resourceType === 'ledger_revision' }">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
@@ -44,9 +44,10 @@ import {
fetchCollaborationRevisionOnlyOfficeConfig,
fetchVersionOnlyOfficeConfig,
} from "../api/onlyoffice";
import { fetchLedgerRevisionPreview } from "../api/ledgers";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH, isTauriRuntime } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
@@ -67,6 +68,7 @@ let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() => {
if (route.name === "OfficeAttachmentPreview") return "attachment";
if (route.name === "LedgerRevisionPreview") return "ledger_revision";
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
return "version";
});
@@ -111,6 +113,8 @@ const loadConfig = async () => {
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: resourceType.value === "ledger_revision"
? await fetchLedgerRevisionPreview(collaborationFileId.value, resourceId.value)
: resourceType.value === "collaboration_revision"
? await fetchCollaborationRevisionOnlyOfficeConfig(
study.currentStudy?.id || "",
@@ -124,7 +128,7 @@ const loadConfig = async () => {
}
previewConfig.value = response.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
if (resourceType.value !== "ledger_revision") study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
@@ -167,6 +171,10 @@ const handleServerChange = () => {
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
if (resourceType.value === "ledger_revision") {
void router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
return;
}
router.back();
};
@@ -189,7 +197,7 @@ onDeactivated(() => {
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
if (resourceType.value !== "ledger_revision") study.setViewContext(null);
});
</script>
@@ -206,6 +214,8 @@ onBeforeUnmount(() => {
background: #f3f5f8;
}
.office-preview-page.is-ledger-preview { height: 100vh; height: 100dvh; }
.office-preview-page__header {
z-index: 1;
display: grid;
+11 -47
View File
@@ -53,14 +53,10 @@
<div class="project-header">
<div class="header-title-block">
<span class="section-eyebrow">Project Workspace</span>
<h2>选择目标项目</h2>
</div>
<div class="header-actions-block">
<span v-if="!loading && projects.length" class="project-pill-badge">
<span class="badge-dot"></span>
<span>{{ projects.length }} 个可进入项目</span>
</span>
<div class="entry-title-row">
<h2>选择目标项目</h2>
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
</div>
</div>
</div>
@@ -155,6 +151,7 @@
<h3>未检测到可用项目</h3>
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
</div>
<LedgerPanel />
</main>
<!-- 科技感转场遮罩 -->
@@ -191,6 +188,9 @@ import type { Study } from "../types/api";
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
import { isSystemAdmin } from "../utils/roles";
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
import EntryCountBadge from "../components/EntryCountBadge.vue";
import "../styles/entry-cards.css";
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -327,7 +327,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 40px 28px;
padding: 24px 28px 40px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-right: 1px solid #e2e8f0;
box-shadow: 6px 0 30px rgba(15, 23, 42, 0.02);
@@ -537,7 +537,7 @@ onMounted(() => {
z-index: 5;
display: flex;
flex-direction: column;
padding: 48px 48px 48px 40px;
padding: 24px 48px 48px 40px;
overflow-y: auto;
}
@@ -550,7 +550,7 @@ onMounted(() => {
}
.header-title-block h2 {
margin: 2px 0 0;
margin: 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
@@ -577,33 +577,6 @@ onMounted(() => {
content: "";
}
.header-actions-block {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.project-pill-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
/* 网页端特有步骤条 - 已根据用户要求隐藏,保留 HTML 结构供测试匹配断言 */
.workflow-strip {
display: none !important;
@@ -663,8 +636,6 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
min-height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 12px;
background: #ffffff;
@@ -870,8 +841,6 @@ onMounted(() => {
/* 骨架屏 */
.skeleton-project-card {
height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -998,11 +967,6 @@ onMounted(() => {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .project-pill-badge) {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
:global([data-ctms-theme="dark"] .workflow-item) {
background: rgba(15, 23, 42, 0.45);
border-color: rgba(51, 65, 85, 0.4);
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
const source = ["./CollaborationLibrary.vue", "../../components/collaboration/CollaborationAccessDialog.vue", "../../components/collaboration/CollaborationHistoryDialog.vue"]
.map((file) => readFileSync(resolve(__dirname, file), "utf8")).join("\n");
const accessApiSource = readFileSync(resolve(__dirname, "../../api/collaborationAccess.ts"), "utf8");
const historyApiSource = readFileSync(resolve(__dirname, "../../api/collaborationHistory.ts"), "utf8");
const librarySource = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
describe("CollaborationLibrary UI contract", () => {
it("opens the create dialog directly from the primary sidebar action", () => {
@@ -73,16 +77,17 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain("R${");
expect(source).toContain("downloadCollaborationFile");
expect(source).toContain("downloadFileWithFeedback(");
expect(source).not.toContain("prepareSaveFile");
expect(librarySource).not.toContain("prepareSaveFile");
});
it("refreshes history immediately after restoring a revision", () => {
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
expect(source).toContain(':loading="restoringRevisionId === row.id"');
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
expect(source).toContain("const { data: restored } = await api.restore");
expect(historyApiSource).toContain("restoreCollaborationRevision(target.studyId");
expect(source).toContain("current_revision_id: restoredForDisplay.id");
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
expect(source).toContain("const [revisionResponse] = await Promise.all([");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
});
@@ -92,7 +97,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("scheduleHistoryRefresh();");
expect(source).toContain("refreshHistory({ silent: true })");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
expect(historyApiSource).toContain("fetchCollaborationFile(target.studyId, id)");
expect(source).toContain("historyFile.value = fileResponse.data");
expect(source).toContain("requestSequence !== historyRequestSequence");
});
@@ -119,11 +124,11 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("nameRevision(row)");
expect(source).toContain("previewRevision(row)");
expect(source).toContain("saveRevisionAs(row)");
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('"LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview"');
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
expect(source).not.toContain("historyDialogVisible.value = false");
expect(source).toContain("copyCollaborationRevision");
expect(source).toContain("updateCollaborationRevision");
expect(historyApiSource).toContain("updateCollaborationRevision");
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
expect(source).toContain("<CollaborationSaveAsDialog");
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
@@ -134,7 +139,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
expect(source).toContain('command="delete"');
expect(source).toContain("删除版本");
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
expect(source).toContain('v-if="target.scope === \'project\' && historyFile?.can_manage" command="delete"');
expect(source).toContain("deleteCollaborationRevision");
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
expect(source).toContain("height: min(760px, 82vh)");
@@ -153,7 +158,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("<span>管理协作者</span>");
expect(source).toContain('class="access-member-identity"');
expect(source).toContain('class="access-member-role"');
expect(source).toContain('class="access-member-owner">所有者</span>');
expect(source).toContain('class="access-member-owner">{{ member.protected_label || "所有者" }}</span>');
expect(source).not.toContain('<el-table :data="members"');
expect(source).toContain('v-model="memberInviteDialogVisible"');
expect(source).toContain('title="管理协作者"');
@@ -162,7 +167,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain('member-picker-group--recent');
expect(source).not.toContain('member-picker-recent-empty');
expect(source).not.toContain('最近选择的账号将显示在这里');
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
expect(source).toContain('role="listbox" :aria-label="`可添加的${directoryLabel}`"');
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
expect(source).toContain("selectedMemberCandidates.length");
@@ -191,7 +196,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("允许申请编辑权限");
expect(source).toContain("handleEditRequestPermissionChange");
expect(source).toContain("待处理申请");
expect(source).toContain("resolveCollaborationEditRequest");
expect(accessApiSource).toContain("resolveCollaborationEditRequest");
expect(source).toContain("notifyProjectNotificationsChanged");
expect(source).toContain("route.query.editRequestFile");
expect(source).toContain("openEditRequestNotificationTarget");
@@ -221,7 +226,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain(':disabled="!selectedTransferCandidate"');
expect(source).toContain('>确定</el-button>');
expect(source).not.toContain('accessPanel === "transfer"');
expect(source).toContain("transferCollaborationOwnership");
expect(accessApiSource).toContain("transferCollaborationOwnership");
});
it("saves permission switches silently while retaining failure feedback", () => {
@@ -266,8 +271,8 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain("同时约束账号授权和匿名访问");
expect(source).toContain('@change="persistShareSettings()"');
expect(source).not.toContain("保存链接设置");
expect(source).toContain("fetchCollaborationShareLink");
expect(source).toContain("updateCollaborationShareLink");
expect(accessApiSource).toContain("fetchCollaborationShareLink");
expect(accessApiSource).toContain("updateCollaborationShareLink");
expect(source).not.toContain("regenerateCollaborationShareLink");
expect(source).not.toContain("重新生成链接");
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
'is-ledger-workspace': isLedgerWorkspace,
}"
>
<div
@@ -83,6 +84,7 @@
</div>
</main>
<CollaborationSaveAsDialog
v-if="!isLedgerWorkspace"
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
@@ -117,6 +119,8 @@ import {
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import { checkLedgerDownload, createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
import type { Ledger } from "../../types/ledger";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
@@ -142,7 +146,10 @@ const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const file = ref<CollaborationFile | null>(null);
const isLedgerWorkspace = computed(() => route.meta.ledgerWorkspace === true);
type WorkspaceFile = Pick<CollaborationFile, "title" | "can_edit"> & Partial<Pick<CollaborationFile, "folder_id" | "edit_request_status" | "can_request_edit">>;
const file = ref<WorkspaceFile | null>(null);
const ledgerSnapshot = ref<Ledger | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
@@ -166,6 +173,8 @@ const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
let ledgerCheckTimer: number | undefined;
let checkingLedger = false;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
@@ -278,7 +287,7 @@ const destroyEditor = () => {
};
const loadWorkspace = async () => {
if (!studyId.value || !fileId.value) return;
if ((!isLedgerWorkspace.value && !studyId.value) || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
@@ -287,17 +296,19 @@ const loadWorkspace = async () => {
warningMessage.value = "";
loading.value = true;
status.value = "loading";
ledgerSnapshot.value = null;
try {
const [fileResponse, configResponse] = await Promise.all([
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
isLedgerWorkspace.value ? fetchLedger(fileId.value) : fetchCollaborationFile(studyId.value, fileId.value),
isLedgerWorkspace.value ? fetchLedgerEditorConfig(fileId.value) : fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
if (isLedgerWorkspace.value) ledgerSnapshot.value = fileResponse.data as Ledger;
else study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
@@ -307,6 +318,29 @@ const loadWorkspace = async () => {
}
};
const checkLedgerSession = async () => {
if (!isLedgerWorkspace.value || !editorConfig.value || !ledgerSnapshot.value || loading.value || checkingLedger) return;
const sequence = requestSequence.value;
checkingLedger = true;
try {
const { data } = await fetchLedger(fileId.value);
if (sequence !== requestSequence.value) return;
if (data.generation !== ledgerSnapshot.value?.generation || data.role !== ledgerSnapshot.value.role) {
destroyEditor();
errorMessage.value = "台账权限或内容版本已更新,请重新打开台账";
status.value = "error";
} else {
file.value = data;
if (editorConfig.value) editorConfig.value.can_request_edit = data.can_request_edit;
}
} catch (error) {
if (sequence !== requestSequence.value) return;
destroyEditor();
errorMessage.value = await getApiErrorMessage(error, "无法确认台账访问权限,请检查连接后重试");
status.value = "error";
} finally { checkingLedger = false; }
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
@@ -332,7 +366,8 @@ const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
await createCollaborationEditRequest(studyId.value, fileId.value);
if (isLedgerWorkspace.value) await createLedgerEditRequest(fileId.value);
else await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
@@ -409,6 +444,7 @@ const savePendingDownload = async () => {
}
return;
}
if (isLedgerWorkspace.value) await checkLedgerDownload(fileId.value, extension);
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
@@ -424,7 +460,7 @@ const savePendingDownload = async () => {
pendingDownload.value = null;
downloadTitle.value = "";
try {
await recordCollaborationDownload(studyId.value, fileId.value, extension);
if (!isLedgerWorkspace.value) await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
@@ -464,6 +500,10 @@ const handleServerChange = () => {
const goBack = async () => {
await exitWorkspaceFullscreen();
if (isLedgerWorkspace.value) {
await router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
return;
}
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
@@ -474,6 +514,8 @@ onMounted(() => {
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
ledgerCheckTimer = window.setInterval(() => { void checkLedgerSession(); }, 15000);
window.addEventListener("focus", checkLedgerSession);
void loadWorkspace();
});
onActivated(() => {
@@ -487,14 +529,17 @@ onDeactivated(() => {
errorMessage.value = "";
});
onBeforeUnmount(() => {
if (ledgerCheckTimer !== undefined) window.clearInterval(ledgerCheckTimer);
window.removeEventListener("focus", checkLedgerSession);
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
if (!isLedgerWorkspace.value) study.setViewContext(null);
});
watch(fileId, () => { if (mounted) void loadWorkspace(); });
</script>
<style scoped>
@@ -511,6 +556,7 @@ onBeforeUnmount(() => {
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace.is-ledger-workspace { height: 100dvh; }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import Workspace from "./CollaborationWorkspace.vue";
import { createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
import { createCollaborationEditRequest, fetchCollaborationFile } from "../../api/collaboration";
const state = vi.hoisted(() => ({ push: vi.fn(), setViewContext: vi.fn() }));
vi.mock("vue-router", () => ({
useRoute: () => ({ meta: { ledgerWorkspace: true }, params: { fileId: "ledger-a" }, path: "/ledgers/ledger-a" }),
useRouter: () => ({ push: state.push }),
}));
vi.mock("../../store/study", () => ({ useStudyStore: () => ({ currentStudy: null, setViewContext: state.setViewContext }) }));
vi.mock("../../utils/permission", () => ({ usePermission: () => ({ can: () => false }) }));
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ fetchLedger: vi.fn(), fetchLedgerEditorConfig: vi.fn(), checkLedgerDownload: vi.fn(), createLedgerEditRequest: vi.fn() }));
vi.mock("../../api/collaboration", () => ({
fetchCollaborationFile: vi.fn(), fetchCollaborationEditorConfig: vi.fn(), createCollaborationEditRequest: vi.fn(),
importCollaborationFile: vi.fn(), recordCollaborationDownload: vi.fn(),
}));
vi.mock("../../runtime", () => ({
DESKTOP_SERVER_URL_CHANGED_EVENT: "server-changed", getRuntimePlatform: () => "web", isTauriRuntime: () => false,
isWebFullscreenActive: () => false, isWebFullscreenAvailable: () => false, listenWebFullscreenChange: () => () => {},
ONLYOFFICE_HOST_PATH: "/onlyoffice-host.html", prepareSaveFile: vi.fn(),
refreshWebFullscreenState: async () => {}, toggleWebFullscreen: vi.fn(),
}));
vi.mock("../../components/collaboration/CollaborationSaveAsDialog.vue", () => ({ default: { template: '<div />' } }));
vi.mock("../../components/collaboration/CollaborationDownloadDialog.vue", () => ({ default: { template: '<div />' } }));
vi.mock("../../components/OnlyOfficeViewer.vue", () => ({ default: { name: "OnlyOfficeViewer", props: ["config"], template: '<div data-test="editor" />' } }));
const ledger = { id: "ledger-a", title: "医学事务部临床运营项目编号.xlsx", can_edit: true, role: "EDITOR", generation: 4 };
let wrapper: ReturnType<typeof mount> | undefined;
const render = () => {
wrapper = mount(Workspace, { global: { stubs: {
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
ElTooltip: { template: '<span><slot /></span>' },
} } });
return wrapper;
};
describe("ledger collaboration workspace", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(fetchLedger).mockResolvedValue({ data: ledger } as any);
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "edit", can_download: false,
can_save_as: false, can_request_edit: false, config: {},
} } as any);
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); });
it("uses global APIs and returns to the workbench with no project context", async () => {
const page = render();
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(true);
expect(fetchLedgerEditorConfig).toHaveBeenCalledWith("ledger-a");
expect(fetchCollaborationFile).not.toHaveBeenCalled();
expect(state.setViewContext).not.toHaveBeenCalled();
await page.get(".collaboration-workspace__back").trigger("click");
expect(state.push).toHaveBeenCalledWith("/workbench");
});
it("destroys the old editor when permissions or generation change", async () => {
const page = render();
await flushPromises();
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...ledger, generation: 5 } } as any);
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(false);
expect(page.text()).toContain("台账权限或内容版本已更新");
});
it("removes the editor if access can no longer be verified", async () => {
const page = render();
await flushPromises();
vi.mocked(fetchLedger).mockRejectedValue(new Error("denied"));
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(false);
expect(page.text()).toContain("无法打开协作文件");
});
it("submits a viewer edit request without project APIs and refreshes its pending status", async () => {
const viewer = { ...ledger, role: "VIEWER", can_edit: false, can_request_edit: true };
vi.mocked(fetchLedger).mockResolvedValue({ data: viewer } as any);
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "view", can_request_edit: true, config: {},
} } as any);
vi.mocked(createLedgerEditRequest).mockResolvedValue({ data: { status: "PENDING" } } as any);
const page = render();
await flushPromises();
await page.findAll("button").find((button) => button.text() === "申请编辑权限")!.trigger("click");
await flushPromises();
expect(createLedgerEditRequest).toHaveBeenCalledWith("ledger-a");
expect(createCollaborationEditRequest).not.toHaveBeenCalled();
expect(page.text()).toContain("编辑权限申请处理中");
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...viewer, edit_request_status: "REJECTED" } } as any);
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.text()).not.toContain("编辑权限申请处理中");
expect(page.find('[data-test="editor"]').exists()).toBe(true);
});
});