新增全局协作台账并统一工作台管理界面
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user