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

新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
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
+56
View File
@@ -0,0 +1,56 @@
import {
fetchCollaborationFile, fetchCollaborationMembers, fetchCollaborationCandidates,
upsertCollaborationMember, removeCollaborationMember, fetchCollaborationShareLink,
updateCollaborationShareLink, updateCollaborationFile, fetchCollaborationEditRequests,
resolveCollaborationEditRequest, transferCollaborationOwnership,
} from "./collaboration";
import {
fetchLedger, fetchLedgerMembers, fetchLedgerCandidates, setLedgerMember, removeLedgerMember,
fetchLedgerShareLink, updateLedgerShareLink, updateLedgerAccessSettings, fetchLedgerEditRequests,
resolveLedgerEditRequest, transferLedgerOwnership,
} from "./ledgers";
import type { CollaborationCandidate, CollaborationFile } from "../types/collaboration";
export type CollaborationAccessTarget = { scope: "project"; studyId: string } | { scope: "ledger" };
export type CollaborationAccessFile = Pick<CollaborationFile,
"id" | "title" | "owner_id" | "owner_name" | "file_type" | "status" | "generation" |
"can_manage" | "can_transfer_ownership" | "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit"
>;
export type CollaborationAccessRole = "VIEWER" | "EDITOR" | "MANAGER";
export interface CollaborationAccessMember {
user_id: string;
full_name: string;
email: string;
role: CollaborationAccessRole;
is_active?: boolean;
protected_label?: string | null;
}
export type CollaborationAccessCandidate = CollaborationCandidate;
type AccessPatch = Partial<Pick<CollaborationAccessFile, "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit">>;
export const createCollaborationAccessApi = (target: CollaborationAccessTarget) => {
const project = target.scope === "project" ? target.studyId : null;
return {
file: (id: string) => project !== null ? fetchCollaborationFile(project, id) : fetchLedger(id),
members: (id: string) => project !== null ? fetchCollaborationMembers(project, id) : fetchLedgerMembers(id),
candidates: (id: string) => project !== null ? fetchCollaborationCandidates(project) : fetchLedgerCandidates(id),
setMember: (id: string, payload: { user_id: string; role: CollaborationAccessRole }) => {
if (project === null) return setLedgerMember(id, payload.user_id, payload.role);
if (payload.role === "VIEWER") return Promise.reject(new Error("项目协作不支持此授权角色"));
return upsertCollaborationMember(project, id, { ...payload, role: payload.role });
},
removeMember: (id: string, userId: string) => project !== null
? removeCollaborationMember(project, id, userId) : removeLedgerMember(id, userId),
share: (id: string) => project !== null ? fetchCollaborationShareLink(project, id) : fetchLedgerShareLink(id),
updateShare: (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) => project !== null
? updateCollaborationShareLink(project, id, payload) : updateLedgerShareLink(id, payload),
updateFile: (file: CollaborationAccessFile, payload: AccessPatch) => project !== null
? updateCollaborationFile(project, file.id, payload)
: updateLedgerAccessSettings(file.id, { ...payload, generation: file.generation }),
requests: (id: string) => project !== null ? fetchCollaborationEditRequests(project, id) : fetchLedgerEditRequests(id),
resolveRequest: (id: string, requestId: string, status: "APPROVED" | "REJECTED") => project !== null
? resolveCollaborationEditRequest(project, id, requestId, status) : resolveLedgerEditRequest(id, requestId, status),
transfer: (id: string, userId: string) => project !== null
? transferCollaborationOwnership(project, id, userId) : transferLedgerOwnership(id, userId),
};
};
+19
View File
@@ -0,0 +1,19 @@
import type { CollaborationFile } from "../types/collaboration";
import type { CollaborationAccessTarget } from "./collaborationAccess";
import { fetchCollaborationFile, fetchCollaborationRevisions, updateCollaborationRevision, restoreCollaborationRevision } from "./collaboration";
import { fetchLedger, fetchLedgerRevisions, updateLedgerRevision, restoreLedgerRevision } from "./ledgers";
export type CollaborationHistoryFile = Pick<CollaborationFile,
"id" | "title" | "owner_name" | "file_type" | "extension" | "folder_id" | "status" | "generation" |
"current_revision_id" | "current_revision_no" | "current_revision_file_size" | "current_revision_mime_type" |
"current_revision_created_at" | "can_edit" | "can_manage" | "can_export"
>;
export const createCollaborationHistoryApi = (target: CollaborationAccessTarget) => ({
file: (id: string) => target.scope === "ledger" ? fetchLedger(id) : fetchCollaborationFile(target.studyId, id),
revisions: (id: string) => target.scope === "ledger" ? fetchLedgerRevisions(id) : fetchCollaborationRevisions(target.studyId, id),
name: (id: string, revisionId: string, payload: { change_summary: string }) => target.scope === "ledger"
? updateLedgerRevision(id, revisionId, payload) : updateCollaborationRevision(target.studyId, id, revisionId, payload),
restore: (file: CollaborationHistoryFile, revisionId: string) => target.scope === "ledger"
? restoreLedgerRevision(file.id, revisionId, file.generation) : restoreCollaborationRevision(target.studyId, file.id, revisionId),
});
+49
View File
@@ -0,0 +1,49 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
import type { Ledger, LedgerCandidate, LedgerMember, LedgerRole, LedgerSettings } from "../types/ledger";
import type { CollaborationEditorConfig, CollaborationRevision, CollaborationEditRequest, CollaborationShareLink } from "../types/collaboration";
import type { updateCollaborationShareLink } from "./collaboration";
import type { OnlyOfficePreviewConfig } from "../types/onlyoffice";
const base = "/api/v1/ledgers";
const online = { cache: false, disableRequestDedupe: true, disableNetworkRetry: true, suppressErrorMessage: true } as const;
export const fetchLedgers = () => apiGet<Ledger[]>(base, online);
export const initializeLedgers = () => apiPost<Ledger[]>(`${base}/initialize`, {}, online);
export const fetchLedger = (id: string) => apiGet<Ledger>(`${base}/${id}`, online);
export const updateLedger = (id: string, payload: LedgerSettings) => apiPatch<Ledger>(`${base}/${id}`, payload, online);
export const importLedger = (id: string, file: File, generation: number) => {
const form = new FormData();
form.append("file", file);
form.append("generation", String(generation));
return apiPost<Ledger>(`${base}/${id}/import`, form, online);
};
export const fetchLedgerMembers = (id: string) => apiGet<LedgerMember[]>(`${base}/${id}/members`, online);
export const fetchLedgerCandidates = (id: string, keyword = "") =>
apiGet<LedgerCandidate[]>(`${base}/${id}/candidates`, { ...online, params: { keyword } });
export const setLedgerMember = (id: string, userId: string, role: LedgerRole) =>
apiPut(`${base}/${id}/members`, { user_id: userId, role }, online);
export const removeLedgerMember = (id: string, userId: string) => apiDelete(`${base}/${id}/members/${userId}`, online);
export const updateLedgerAccessSettings = (id: string, payload: {
generation: number; allow_export?: boolean; allow_edit_request?: boolean; allow_sheet_structure_edit?: boolean;
}) => apiPatch<Ledger>(`${base}/${id}/access-settings`, payload, online);
export const fetchLedgerShareLink = (id: string) => apiGet<CollaborationShareLink>(`${base}/${id}/share-link`, online);
export const updateLedgerShareLink = (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) =>
apiPut<CollaborationShareLink>(`${base}/${id}/share-link`, payload, online);
export const createLedgerEditRequest = (id: string) => apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests`, {}, online);
export const fetchLedgerEditRequests = (id: string) => apiGet<CollaborationEditRequest[]>(`${base}/${id}/edit-requests`, online);
export const resolveLedgerEditRequest = (id: string, requestId: string, status: "APPROVED" | "REJECTED") =>
apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests/${requestId}/resolve`, { status }, online);
export const transferLedgerOwnership = (id: string, userId: string) =>
apiPost<Ledger>(`${base}/${id}/transfer-ownership`, { new_owner_id: userId }, online);
export const fetchLedgerEditorConfig = (id: string) => apiGet<CollaborationEditorConfig>(`${base}/${id}/editor-config`, online);
export const checkLedgerDownload = (id: string, fileType: string) =>
apiPost(`${base}/${id}/downloads`, { file_type: fileType }, online);
export const fetchLedgerRevisions = (id: string) => apiGet<CollaborationRevision[]>(`${base}/${id}/revisions`, online);
export const updateLedgerRevision = (id: string, revisionId: string, payload: { change_summary: string }) =>
apiPatch<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}`, payload, online);
export const downloadLedgerRevision = (id: string, revisionId: string) =>
apiGet<Blob>(`${base}/${id}/revisions/${revisionId}/download`, { ...online, responseType: "blob" });
export const restoreLedgerRevision = (id: string, revisionId: string, generation: number) =>
apiPost<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}/restore`, { generation }, online);
export const fetchLedgerRevisionPreview = (id: string, revisionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`${base}/${id}/revisions/${revisionId}/preview-config`, online);
@@ -0,0 +1,36 @@
<template>
<span class="entry-count-badge">
<span class="entry-count-badge__dot" aria-hidden="true"></span>
<span><slot /></span>
</span>
</template>
<style scoped>
.entry-count-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
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;
white-space: nowrap;
}
.entry-count-badge__dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 50%;
background: #3b82f6;
}
:global([data-ctms-theme="dark"]) .entry-count-badge {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
</style>
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import AccessDialog from "./CollaborationAccessDialog.vue";
import { createCollaborationAccessApi, type CollaborationAccessFile, type CollaborationAccessTarget } from "../../api/collaborationAccess";
const api = vi.hoisted(() => ({
file: vi.fn(), members: vi.fn(), candidates: vi.fn(), requests: vi.fn(), share: vi.fn(),
setMember: vi.fn(), removeMember: vi.fn(), updateFile: vi.fn(), updateShare: vi.fn(), resolveRequest: vi.fn(), transfer: vi.fn(),
}));
vi.mock("../../api/collaborationAccess", () => ({ createCollaborationAccessApi: vi.fn(() => api) }));
vi.mock("../../runtime", () => ({ clientRuntime: { apiBaseUrl: () => "http://localhost:8888" } }));
vi.mock("../../composables/useProjectNotifications", () => ({ notifyProjectNotificationsChanged: vi.fn() }));
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
const file: CollaborationAccessFile = {
id: "file-a", title: "台账.xlsx", owner_id: "owner", owner_name: "Owner", file_type: "cell",
status: "ACTIVE", generation: 4, can_manage: true, can_transfer_ownership: true,
allow_export: false, allow_edit_request: true, allow_sheet_structure_edit: false,
};
const owner = { user_id: "owner", full_name: "Owner", email: "owner@example.com", role: "MANAGER", protected_label: "所有者" };
const candidate = { user_id: "candidate", full_name: "Candidate", email: "candidate@example.com", role_in_study: "系统账号", can_be_editor: true, can_be_manager: true };
const share = { enabled: false, has_password: false, access_mode: "VIEW", expiry_policy: "SEVEN_DAYS", share_path: "/collaboration/share", share_token: null };
let wrapper: ReturnType<typeof mount> | undefined;
const render = (target: CollaborationAccessTarget = { scope: "ledger" }) => {
wrapper = mount(AccessDialog, { props: { file, target }, global: {
directives: { loading: () => {} },
stubs: {
ElDialog: { props: ["modelValue", "title"], template: '<div v-if="modelValue" role="dialog"><slot name="header" />{{ title }}<slot /><slot name="footer" /></div>' },
ElButton: { template: '<button><slot /></button>' }, ElAvatar: { template: '<span><slot /></span>' },
ElTag: { template: '<span><slot /></span>' }, ElIcon: { template: '<span><slot /></span>' },
ElEmpty: true, ElInput: true,
ElSelect: { props: ["modelValue"], emits: ["update:modelValue", "change"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><slot /></select>' },
ElOption: { props: ["label", "value"], template: '<option :value="value">{{ label }}</option>' },
ElSwitch: { props: ["modelValue", "disabled"], emits: ["update:modelValue", "change"], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.checked); $emit(\'change\', $event.target.checked)" />' },
},
} });
return wrapper;
};
describe("shared collaboration access dialog", () => {
beforeEach(() => {
vi.clearAllMocks();
api.file.mockResolvedValue({ data: { ...file } });
api.members.mockResolvedValue({ data: [owner] });
api.candidates.mockResolvedValue({ data: [candidate] });
api.requests.mockResolvedValue({ data: [] });
api.share.mockResolvedValue({ data: share });
api.setMember.mockResolvedValue({});
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; });
it.each(["ledger", "project"] as const)("uses the same picker with the %s account directory and role boundary", async (scope) => {
const target: CollaborationAccessTarget = scope === "project" ? { scope, studyId: "study-a" } : { scope };
const page = render(target);
await flushPromises();
expect(createCollaborationAccessApi).toHaveBeenCalledWith(target);
expect(page.get(".access-member-row").text()).toContain("所有者");
expect(page.find(".access-member-remove").exists()).toBe(false);
expect(page.get('[aria-label="启用匿名访问"]').element).toHaveProperty("checked", false);
await page.get(".access-section__manage-button").trigger("click");
expect(page.get('[role="listbox"]').attributes("aria-label")).toBe(scope === "ledger" ? "可添加的系统账号" : "可添加的项目成员");
const select = page.get('[aria-label="文件授权角色"]');
expect(select.find('option[value="VIEWER"]').exists()).toBe(scope === "ledger");
if (scope === "ledger") await select.setValue("VIEWER");
await page.get(".member-picker-contact").trigger("click");
await page.findAll(".member-picker-selection__footer button").find((button) => button.text() === "确定")!.trigger("click");
await flushPromises();
expect(api.setMember).toHaveBeenCalledWith("file-a", { user_id: "candidate", role: scope === "ledger" ? "VIEWER" : "EDITOR" });
expect(page.emitted("changed")).toBeTruthy();
});
it("uses the refreshed generation after a share change before saving file permissions", async () => {
const page = render();
await flushPromises();
api.updateShare.mockResolvedValue({ data: { ...share, enabled: true, share_token: "test-token" } });
api.file.mockResolvedValue({ data: { ...file, generation: 5 } });
await page.get('[aria-label="启用匿名访问"]').setValue(true);
await flushPromises();
expect(api.updateShare).toHaveBeenCalledWith("file-a", expect.objectContaining({ enabled: true, password_mode: "KEEP" }));
await page.get(".access-setting-navigation").trigger("click");
api.updateFile.mockResolvedValue({ data: { ...file, generation: 6, allow_export: true } });
await page.get(".access-permission-row input").setValue(true);
await flushPromises();
expect(api.updateFile).toHaveBeenCalledWith(expect.objectContaining({ generation: 5 }), { allow_export: true });
});
it("closes access controls when a removed manager no longer has access", async () => {
api.members.mockResolvedValue({ data: [{ ...owner, user_id: "manager", protected_label: null }] });
const page = render();
await flushPromises();
api.removeMember.mockResolvedValue({});
api.members.mockRejectedValue({ response: { status: 404 } });
await page.get(".access-member-remove").trigger("click");
await flushPromises();
expect(page.find('[role="dialog"]').exists()).toBe(false);
expect(page.emitted("changed")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import HistoryDialog from "./CollaborationHistoryDialog.vue";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
import { downloadLedgerRevision } from "../../api/ledgers";
import { prepareSaveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const state = vi.hoisted(() => ({
api: { file: vi.fn(), revisions: vi.fn(), name: vi.fn(), restore: vi.fn() },
resolve: vi.fn(() => ({ href: "/ledger-preview" })), prompt: vi.fn(), confirm: vi.fn(),
}));
vi.mock("../../api/collaborationHistory", () => ({ createCollaborationHistoryApi: () => state.api }));
vi.mock("../../api/collaboration", () => ({ copyCollaborationRevision: vi.fn(), deleteCollaborationRevision: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ downloadLedgerRevision: vi.fn(), importLedger: vi.fn() }));
vi.mock("../../runtime", () => ({ prepareSaveFile: vi.fn(), pickFiles: vi.fn() }));
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
vi.mock("../../store/auth", () => ({ useAuthStore: () => ({ user: { id: "u", full_name: "Owner" } }) }));
vi.mock("vue-router", () => ({ useRouter: () => ({ resolve: state.resolve }) }));
vi.mock("element-plus", () => ({ ElMessage: { error: vi.fn(), success: vi.fn() }, ElMessageBox: { prompt: state.prompt, confirm: state.confirm } }));
vi.mock("./CollaborationSaveAsDialog.vue", () => ({ default: { template: "<div />" } }));
const file: CollaborationHistoryFile = { id: "ledger-a", title: "台账.xlsx", extension: "xlsx", file_type: "cell", status: "ACTIVE", generation: 7, can_manage: true, can_edit: true, can_export: true, current_revision_id: "r2", owner_name: "Owner" };
const revisions = [
{ id: "r2", revision_no: 2, source: "FORCE_SAVE", created_at: "2026-09-03T01:00:00Z", file_size: 200, mime_type: "application/xlsx", change_summary: "", created_by_name: "Owner" },
{ id: "r1", revision_no: 1, source: "IMPORT", created_at: "2026-09-02T01:00:00Z", file_size: 100, mime_type: "application/xlsx", change_summary: "提交版", created_by_name: "Owner" },
];
let wrapper: ReturnType<typeof mount> | undefined;
const render = (overrides = {}, scope: "project" | "ledger" = "ledger") => {
const current = { ...file, ...overrides };
state.api.file.mockResolvedValue({ data: current });
wrapper = mount(HistoryDialog, { props: { file: current, target: scope === "ledger" ? { scope } : { scope, studyId: "study-a" }, canCreate: true }, global: {
directives: { loading: () => {} }, stubs: {
ElDialog: { props: ["modelValue"], template: '<div v-if="modelValue"><slot name="header"/><slot/></div>' },
ElButton: { template: '<button><slot/></button>' }, ElAvatar: { template: '<span><slot/></span>' },
ElTag: { template: '<span><slot/></span>' }, ElIcon: { template: '<span><slot/></span>' }, ElTooltip: { template: '<span><slot/></span>' }, ElEmpty: true,
ElDropdown: { name: "ElDropdown", emits: ["command"], template: '<div><slot/><slot name="dropdown"/></div>' },
ElDropdownMenu: { template: '<div><slot/></div>' }, ElDropdownItem: { template: '<span><slot/></span>' },
ElSelect: { props: ["modelValue"], emits: ["update:modelValue"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot/></select>' },
ElOption: { props: ["value", "label"], template: '<option :value="value">{{ label }}</option>' },
ElSwitch: { props: ["modelValue"], emits: ["update:modelValue"], template: '<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)"/>' },
},
} });
return wrapper;
};
describe("shared project and ledger history", () => {
beforeEach(() => {
vi.clearAllMocks(); vi.useFakeTimers();
state.api.revisions.mockResolvedValue({ data: revisions.map(item => ({ ...item })) });
state.prompt.mockResolvedValue({ value: "复核版" }); state.confirm.mockResolvedValue("confirm");
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
it.each(["project", "ledger"] as const)("filters and names versions in the same %s history UI", async (scope) => {
const page = render({}, scope); await flushPromises();
expect(page.findAll(".history-day-group")).toHaveLength(2);
await page.get(".history-described-filter input").setValue(true);
expect(page.findAll(".history-row")).toHaveLength(1);
await page.get(".history-source-filter").setValue("FORCE_SAVE");
expect(page.findAll(".history-row")).toHaveLength(0);
await page.get(".history-described-filter input").setValue(false);
state.api.name.mockResolvedValue({ data: { ...revisions[0], change_summary: "复核版" } });
await page.findAll(".history-row button").find(button => button.text() === "命名")!.trigger("click");
await flushPromises();
expect(state.api.name).toHaveBeenCalledWith("ledger-a", "r2", { change_summary: "复核版" });
expect(page.get(".history-description").text()).toContain("复核版");
});
it("preserves ledger no-delete and manager-only restore while using the current generation", async () => {
const editor = render({ can_manage: false, can_export: false }); await flushPromises();
expect(editor.text()).toContain("命名");
expect(editor.text()).not.toContain("恢复此版本"); expect(editor.text()).not.toContain("另存为");
editor.unmount();
const manager = render(); await flushPromises();
expect(manager.text()).not.toContain("删除版本");
const restored = { ...revisions[0], id: "r3", revision_no: 3, source: "RESTORE" };
state.api.restore.mockResolvedValue({ data: restored });
state.api.revisions.mockResolvedValue({ data: [restored, ...revisions] });
state.api.file.mockResolvedValue({ data: { ...file, generation: 8, current_revision_id: "r3" } });
manager.findComponent({ name: "ElDropdown" }).vm.$emit("command", "restore");
// Emit the same command as the real row menu with its bound revision.
await flushPromises();
expect(state.api.restore).toHaveBeenCalledWith(expect.objectContaining({ generation: 7 }), "r1");
expect(manager.findAll(".history-row")).toHaveLength(3);
expect(manager.get(".history-row.is-current").text()).toContain("历史恢复");
});
it("opens a global read-only preview and saves a historical XLSX without project context", async () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const page = render(); await flushPromises();
await page.findAll(".history-row button").find(button => button.text() === "预览")!.trigger("click");
expect(state.resolve).toHaveBeenCalledWith({ name: "LedgerRevisionPreview", params: { fileId: "ledger-a", id: "r2" } });
expect(open).toHaveBeenCalledWith("/ledger-preview", "_blank", "noopener,noreferrer");
const destination = { kind: "web-download" };
vi.mocked(prepareSaveFile).mockResolvedValue(destination as any);
const blob = new Blob(["historical bytes"]);
vi.mocked(downloadLedgerRevision).mockResolvedValue({ data: blob } as any);
await page.findAll(".history-row button").find(button => button.text() === "另存为")!.trigger("click");
await flushPromises();
expect(downloadLedgerRevision).toHaveBeenCalledWith("ledger-a", "r2");
expect(saveFileWithFeedback).toHaveBeenCalledWith(expect.objectContaining({ data: blob, suggestedName: expect.stringMatching(/台账\[.*\]\.xlsx$/) }), expect.anything(), destination);
});
});
@@ -0,0 +1,552 @@
<template>
<el-dialog
v-model="historyDialogVisible"
width="840px"
class="collaboration-action-dialog history-dialog"
modal-class="history-dialog-overlay"
destroy-on-close
append-to-body
@closed="closeHistory"
>
<template #header>
<div v-if="historyFile" class="history-dialog__title">
<span class="history-file-badge" :class="`is-${historyFile.file_type}`">{{ fileTypeLabel(historyFile.file_type) }}</span>
<div>
<strong>{{ historyFile.title }}</strong>
<div class="history-dialog__meta">
<span>创建者:{{ historyFile.owner_name || "—" }}</span>
<span :title="historyFile.id">文件 ID:{{ historyFile.id.slice(0, 8) }}…</span>
<span>文件大小:{{ fileSizeLabel(historyFile.current_revision_file_size) }}</span>
</div>
</div>
</div>
</template>
<div class="history-toolbar">
<label class="history-described-filter">
<span>仅显示已命名版本</span>
<el-switch v-model="historyOnlyDescribed" />
</label>
<span class="history-toolbar__spacer" />
<el-button v-if="target.scope === 'ledger' && canRestore" :disabled="historyLoading || historyActionKey !== null || restoringRevisionId !== null" @click="importNewRevision">导入新版本</el-button>
<el-select v-model="historySourceFilter" class="history-source-filter" aria-label="版本来源筛选">
<el-option label="全部来源" value="ALL" />
<el-option v-for="option in historySourceOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-tooltip content="刷新历史版本" placement="top">
<el-button circle :loading="historyLoading" aria-label="刷新历史版本" @click="refreshHistory()">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
<div v-loading="historyLoading" class="history-panel">
<div class="history-list-header">
<span>时间</span>
<span>更新者</span>
<span>大小</span>
<span>版本来源</span>
<span>版本名称</span>
<span>操作</span>
</div>
<div v-if="historyRevisionGroups.length" class="history-groups">
<section v-for="group in historyRevisionGroups" :key="group.key" class="history-day-group">
<h3>{{ group.label }}</h3>
<div v-for="row in group.items" :key="row.id" class="history-row" :class="{ 'is-current': row.id === historyFile?.current_revision_id }">
<span class="history-time">{{ revisionTimeLabel(row.created_at) }}</span>
<span class="history-author">
<el-avatar :size="26" :src="row.created_by_avatar_url || undefined">{{ revisionAuthorInitial(row) }}</el-avatar>
<span>{{ revisionAuthorName(row) }}</span>
</span>
<span>{{ formatFileSize(row.file_size) }}</span>
<span class="history-source">{{ sourceLabel(row.source) }}</span>
<span class="history-description">
<span>{{ row.change_summary || "未命名" }}</span>
<el-tag v-if="row.id === historyFile?.current_revision_id" size="small" type="info" effect="plain">当前版本</el-tag>
</span>
<span class="history-row__action">
<el-button
v-if="historyFile?.can_edit"
link
type="primary"
:loading="historyActionKey === `${row.id}:name`"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="nameRevision(row)"
>命名</el-button>
<el-button
link
type="primary"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="previewRevision(row)"
>预览</el-button>
<el-button
v-if="canSaveCopy"
link
type="primary"
:loading="historyActionKey === `${row.id}:copy`"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="saveRevisionAs(row)"
>另存为</el-button>
<el-dropdown
v-if="canRestore && row.id !== historyFile?.current_revision_id"
trigger="click"
@command="handleRevisionMoreAction($event, row)"
>
<el-button
link
class="history-row__more"
aria-label="更多版本操作"
:loading="restoringRevisionId === row.id"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
>•••</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="restore">恢复此版本</el-dropdown-item>
<el-dropdown-item v-if="target.scope === 'project' && historyFile?.can_manage" command="delete" divided>删除版本</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
</section>
</div>
<el-empty v-else description="没有符合条件的历史版本" :image-size="72" />
</div>
</el-dialog>
<CollaborationSaveAsDialog
v-if="target.scope === 'project'"
v-model="revisionSaveAsDialogVisible"
:study-id="target.scope === 'project' ? target.studyId : ''"
:initial-title="revisionSaveAsTitle"
:initial-folder-id="revisionSaveAsFolderId"
:saving="revisionSaveAsSaving"
:can-create-folder="canManageFolders"
@confirm="submitRevisionSaveAs"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, onDeactivated, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { formatFileSize } from "../attachments/attachmentUtils";
import { copyCollaborationRevision, deleteCollaborationRevision } from "../../api/collaboration";
import { downloadLedgerRevision, importLedger } from "../../api/ledgers";
import { createCollaborationHistoryApi, type CollaborationHistoryFile } from "../../api/collaborationHistory";
import type { CollaborationAccessTarget } from "../../api/collaborationAccess";
import type { CollaborationRevision, CollaborationFileType } from "../../types/collaboration";
import { prepareSaveFile, pickFiles } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import CollaborationSaveAsDialog from "./CollaborationSaveAsDialog.vue";
const props = withDefaults(defineProps<{ file: CollaborationHistoryFile; target: CollaborationAccessTarget; canCreate?: boolean; canManageFolders?: boolean }>(), { canCreate: false, canManageFolders: false });
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
const auth = useAuthStore();
const router = useRouter();
const api = createCollaborationHistoryApi(props.target);
const canRestore = computed(() => props.target.scope === "ledger"
? Boolean(historyFile.value?.can_manage && historyFile.value.status === "ACTIVE") : Boolean(historyFile.value?.can_edit));
const canSaveCopy = computed(() => Boolean(historyFile.value?.can_export && (props.target.scope === "ledger" || props.canCreate)));
const fileTypeLabel = (value: CollaborationFileType) => ({ word: "W", cell: "X", slide: "P" })[value];
const fileSizeLabel = (value?: number | null) => value == null ? "—" : formatFileSize(value);
const historyDialogVisible = ref(false);
const historyLoading = ref(false);
const restoringRevisionId = ref<string | null>(null);
const historyActionKey = ref<string | null>(null);
const historyOnlyDescribed = ref(false);
const historySourceFilter = ref("ALL");
const historyFile = ref<CollaborationHistoryFile | null>(null);
const revisions = ref<CollaborationRevision[]>([]);
const revisionSaveAsDialogVisible = ref(false);
const revisionSaveAsTarget = ref<CollaborationRevision | null>(null);
const revisionSaveAsFolderId = ref<string | null>(null);
const revisionSaveAsTitle = ref("");
const revisionSaveAsSaving = ref(false);
const historyRefreshTimers: number[] = [];
let historyRequestSequence = 0;
const historySourceOptions = computed(() =>
[...new Set(revisions.value.map((item) => item.source))]
.map((value) => ({ value, label: sourceLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN")),
);
const historyFilteredRevisions = computed(() => revisions.value.filter((item) =>
(historySourceFilter.value === "ALL" || item.source === historySourceFilter.value) &&
(!historyOnlyDescribed.value || Boolean(item.change_summary?.trim())),
));
const revisionDateKey = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};
const revisionDateLabel = (value: string) => {
const date = new Date(value);
const weekday = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][date.getDay()];
return `${revisionDateKey(value)} ${weekday}`;
};
const revisionTimeLabel = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const historyRevisionGroups = computed(() => {
const groups = new Map<string, CollaborationRevision[]>();
for (const revision of historyFilteredRevisions.value) {
const key = revisionDateKey(revision.created_at);
const items = groups.get(key) || [];
items.push(revision);
groups.set(key, items);
}
return [...groups.entries()].map(([key, items]) => ({
key,
label: revisionDateLabel(items[0].created_at),
items,
}));
});
const revisionAuthorName = (revision: CollaborationRevision) => {
if (revision.created_by_name) return revision.created_by_name;
if (revision.created_by && revision.created_by === auth.user?.id) {
return auth.user.full_name || auth.user.email || "当前用户";
}
return revision.created_by ? (props.target.scope === "ledger" ? "系统账号" : "项目成员") : revision.source.startsWith("SHARE_") ? "匿名协作者" : "系统";
};
const revisionAuthorInitial = (revision: CollaborationRevision) =>
revisionAuthorName(revision).trim().slice(0, 1).toUpperCase() || "?";
function sourceLabel(source: string) {
return ({
CREATE: "新建",
IMPORT: "导入",
FORCE_SAVE: "手动保存",
SESSION_CLOSE: "自动保存",
RESTORE: "历史恢复",
COPY: "复制创建",
SHARE_FORCE_SAVE: "链接协作保存",
SHARE_SESSION_CLOSE: "链接协作关闭",
} as Record<string, string>)[source] || source;
}
const clearHistoryRefreshTimers = () => {
while (historyRefreshTimers.length) window.clearTimeout(historyRefreshTimers.pop());
};
const scheduleHistoryRefresh = () => {
clearHistoryRefreshTimers();
// ONLYOFFICE 的会话关闭回调可能晚于用户返回文件库;弹窗打开后继续
// 追踪最终自动保存版本,避免用户必须手动点击刷新。
for (const delay of [2_500, 12_000]) {
historyRefreshTimers.push(window.setTimeout(() => {
if (historyDialogVisible.value) void refreshHistory({ silent: true });
}, delay));
}
};
const refreshHistory = async (options: { silent?: boolean } = {}) => {
if (!historyFile.value) return;
const fileId = historyFile.value.id;
const requestSequence = historyRequestSequence + 1;
historyRequestSequence = requestSequence;
historyLoading.value = true;
try {
const [revisionResponse, fileResponse] = await Promise.all([
api.revisions(fileId),
api.file(fileId),
]);
if (
requestSequence !== historyRequestSequence ||
!historyDialogVisible.value ||
historyFile.value?.id !== fileId
) return;
revisions.value = revisionResponse.data;
historyFile.value = fileResponse.data;
emit("updated", fileResponse.data);
} catch (error) {
if ([403, 404].includes(Number((error as { response?: { status?: number } })?.response?.status))) {
closeHistory();
}
if (requestSequence === historyRequestSequence && !options.silent) {
ElMessage.error(await getApiErrorMessage(error, "修订历史加载失败"));
}
} finally {
if (requestSequence === historyRequestSequence) historyLoading.value = false;
}
};
const openHistory = async (item: CollaborationHistoryFile) => {
clearHistoryRefreshTimers();
historyFile.value = item;
revisions.value = [];
historyOnlyDescribed.value = false;
historySourceFilter.value = "ALL";
historyDialogVisible.value = true;
scheduleHistoryRefresh();
await refreshHistory();
};
const closeHistory = () => {
clearHistoryRefreshTimers();
historyRequestSequence += 1;
historyLoading.value = false;
historyFile.value = null;
revisions.value = [];
emit("close");
};
const revisionCopyTitle = (revision: CollaborationRevision) => {
if (!historyFile.value) return `历史版本[${revisionSaveAsTimeLabel(revision.created_at)}]`;
const suffix = `.${historyFile.value.extension}`;
const title = historyFile.value.title;
const stem = title.toLowerCase().endsWith(suffix) ? title.slice(0, -suffix.length) : title;
return `${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`;
};
const revisionSaveAsTimeLabel = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}︰${pad(date.getMinutes())}`;
};
const nameRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value) return;
const fileId = historyFile.value.id;
try {
const { value } = await ElMessageBox.prompt("请输入版本名称", "命名版本", {
inputValue: revision.change_summary || "",
inputValidator: (input) => Boolean(String(input || "").trim()) || "版本名称不能为空",
confirmButtonText: "保存",
});
historyActionKey.value = `${revision.id}:name`;
const { data } = await api.name(fileId, revision.id, {
change_summary: value.trim(),
});
revisions.value = revisions.value.map((item) => item.id === revision.id ? {
...item,
...data,
created_by_name: item.created_by_name,
created_by_avatar_url: item.created_by_avatar_url,
} : item);
ElMessage.success("版本名称已保存");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "版本命名失败"));
} finally {
historyActionKey.value = null;
}
};
const previewRevision = (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value || restoringRevisionId.value) return;
const fileId = historyFile.value.id;
const previewRoute = router.resolve({
name: props.target.scope === "ledger" ? "LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview",
params: { fileId, id: revision.id },
});
window.open(previewRoute.href, "_blank", "noopener,noreferrer");
};
const saveRevisionAs = (revision: CollaborationRevision) => {
if (!canSaveCopy.value) return;
if (props.target.scope === "ledger") { void saveLedgerRevisionAs(revision); return; }
if (!historyFile.value || historyActionKey.value) return;
revisionSaveAsTarget.value = revision;
revisionSaveAsFolderId.value = historyFile.value.folder_id || null;
revisionSaveAsTitle.value = revisionCopyTitle(revision);
revisionSaveAsDialogVisible.value = true;
};
const submitRevisionSaveAs = async (payload: { title: string; folderId: string | null }) => {
const revision = revisionSaveAsTarget.value;
const file = historyFile.value;
if (!revision || !file || revisionSaveAsSaving.value || props.target.scope !== "project") return;
revisionSaveAsSaving.value = true;
historyActionKey.value = `${revision.id}:copy`;
try {
const { data } = await copyCollaborationRevision(props.target.studyId, file.id, revision.id, {
title: payload.title,
folder_id: payload.folderId,
});
revisionSaveAsDialogVisible.value = false;
revisionSaveAsTarget.value = null;
emit("changed");
ElMessage.success(`已在工作区创建“${data.title}”`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
} finally {
revisionSaveAsSaving.value = false;
historyActionKey.value = null;
}
};
const handleRevisionMoreAction = (command: string, revision: CollaborationRevision) => {
if (command === "restore") void restoreRevision(revision);
if (command === "delete") void deleteRevision(revision);
};
const deleteRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value || revision.id === historyFile.value.current_revision_id || props.target.scope !== "project" || !historyFile.value.can_manage) return;
const fileId = historyFile.value.id;
try {
await ElMessageBox.confirm(
"删除此历史版本后将不再显示且无法恢复,是否继续?",
"删除历史版本",
{ type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger" },
);
historyActionKey.value = `${revision.id}:delete`;
await deleteCollaborationRevision(props.target.studyId, fileId, revision.id);
revisions.value = revisions.value.filter((item) => item.id !== revision.id);
ElMessage.success("历史版本已删除");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "历史版本删除失败"));
} finally {
historyActionKey.value = null;
}
};
const restoreRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || restoringRevisionId.value || !canRestore.value) return;
const file = historyFile.value;
const fileId = file.id;
try {
await ElMessageBox.confirm(props.target.scope === "ledger" ? "恢复将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。" : "恢复此历史版本将生成一个新版本,是否继续?", "恢复历史版本", { type: "warning" });
restoringRevisionId.value = revision.id;
const { data: restored } = await api.restore(file, revision.id);
const restoredForDisplay: CollaborationRevision = {
...restored,
created_by_name: restored.created_by_name || auth.user?.full_name || auth.user?.email || null,
created_by_avatar_url: restored.created_by_avatar_url || auth.user?.avatar_url || null,
};
if (historyFile.value?.id === fileId) {
historyFile.value = {
...historyFile.value,
current_revision_id: restoredForDisplay.id,
current_revision_no: restoredForDisplay.revision_no,
current_revision_file_size: restoredForDisplay.file_size,
current_revision_mime_type: restoredForDisplay.mime_type,
current_revision_created_at: restoredForDisplay.created_at,
};
revisions.value = [
restoredForDisplay,
...revisions.value.filter((item) => item.id !== restoredForDisplay.id),
].sort((left, right) => right.revision_no - left.revision_no);
}
const [revisionResponse, fileResponse] = await Promise.all([
api.revisions(fileId),
api.file(fileId),
]);
if (historyFile.value?.id === fileId) {
revisions.value = revisionResponse.data;
const refreshedFile = fileResponse.data;
emit("updated", refreshedFile);
if (refreshedFile) historyFile.value = refreshedFile;
}
emit("changed");
ElMessage.success("已恢复并生成新版本");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "版本恢复失败"));
} finally {
restoringRevisionId.value = null;
}
};
const saveLedgerRevisionAs = async (revision: CollaborationRevision) => {
const file = historyFile.value;
if (!file || historyActionKey.value || restoringRevisionId.value || !canSaveCopy.value) return;
const suggestedName = revisionCopyTitle(revision);
const destinationPromise = prepareSaveFile(suggestedName);
historyActionKey.value = `${revision.id}:copy`;
try {
const destination = await destinationPromise;
if (!destination) return;
const { data } = await downloadLedgerRevision(file.id, revision.id);
await saveFileWithFeedback({ suggestedName, mimeType: revision.mime_type, data }, { title: "另存台账历史版本" }, destination);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
} finally { historyActionKey.value = null; }
};
const importNewRevision = async () => {
const file = historyFile.value;
if (!file || props.target.scope !== "ledger" || !canRestore.value || historyActionKey.value || restoringRevisionId.value) return;
historyActionKey.value = "import";
try {
const [upload] = await pickFiles({ accept: [".xlsx"], multiple: false, title: "选择台账 Excel 文件" });
if (!upload) return;
await ElMessageBox.confirm("导入将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。", "导入新版本", { type: "warning" });
await importLedger(file.id, upload, file.generation);
await refreshHistory();
emit("changed");
ElMessage.success("已导入为新版本");
} catch (error) {
if (error !== "cancel" && error !== "close") ElMessage.error(await getApiErrorMessage(error, "台账导入失败"));
} finally { historyActionKey.value = null; }
};
onMounted(() => { void openHistory(props.file); });
onDeactivated(closeHistory);
onBeforeUnmount(() => { clearHistoryRefreshTimers(); historyRequestSequence += 1; });
</script>
<style scoped>
.history-dialog__title { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding-right: 44px; }
.history-dialog__title > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 5px; }
.history-dialog__title strong { overflow: hidden; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.history-dialog__meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; color: var(--ctms-text-secondary); font-size: 12px; line-height: 1.35; }
.history-dialog__meta span { display: inline-flex; align-items: center; white-space: nowrap; }
.history-dialog__meta span + span::before { width: 1px; height: 13px; margin: 0 13px; background: var(--ctms-border-color); content: ""; }
.history-file-badge { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: 0 0 auto; border-radius: 6px; color: #fff; font-size: 13px; font-weight: 750; }
.history-file-badge.is-word { background: #4472c4; }
.history-file-badge.is-cell { background: #2e8b57; }
.history-file-badge.is-slide { background: #d05a35; }
.history-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 7px 18px; border-bottom: 1px solid var(--ctms-border-color); }
.history-described-filter { display: inline-flex; align-items: center; gap: 8px; color: var(--ctms-text-main); font-size: 13px; }
.history-toolbar__spacer { flex: 1; }
.history-source-filter { width: 132px; }
.history-panel { display: flex; min-height: 0; margin: 14px 18px 18px; overflow: hidden; flex: 1; flex-direction: column; border: 1px solid var(--ctms-border-color); border-radius: 9px; background: var(--ctms-bg-card); }
.history-list-header,
.history-row { display: grid; grid-template-columns: 70px 130px 78px 96px minmax(140px, 1fr) 202px; min-width: 760px; align-items: center; column-gap: 8px; }
.history-list-header { min-height: 44px; padding: 0 18px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); color: var(--ctms-text-regular); font-size: 13px; font-weight: 650; }
.history-groups { min-height: 0; max-height: none; overflow: auto; flex: 1; }
.history-day-group { min-width: 760px; padding: 0 18px; }
.history-day-group + .history-day-group { border-top: 1px solid var(--ctms-border-color); }
.history-day-group h3 { margin: 0; padding: 15px 0 5px; color: var(--ctms-text-main); font-size: 15px; font-weight: 700; line-height: 1.25; }
.history-row { min-height: 48px; padding: 0; border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 42%, transparent); color: var(--ctms-text-regular); font-size: 13px; transition: background-color 0.16s ease; }
.history-row:last-child { border-bottom: 0; }
.history-row:hover { background: var(--ctms-bg-muted); }
.history-row.is-current { background: color-mix(in srgb, var(--ctms-primary) 5%, var(--ctms-bg-card)); }
.history-time { padding-left: 5px; color: var(--ctms-text-main); font-variant-numeric: tabular-nums; }
.history-author { display: inline-flex; min-width: 0; align-items: center; gap: 7px; }
.history-author :deep(.el-avatar) { flex: 0 0 auto; background: var(--ctms-primary); color: #fff; font-size: 11px; font-weight: 700; }
.history-author > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-source { color: var(--ctms-text-regular); }
.history-description { display: flex; min-width: 0; align-items: center; gap: 8px; }
.history-description > span { overflow: hidden; color: var(--ctms-text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.history-row__action { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 1px; opacity: 0.72; transition: opacity 0.16s ease; }
.history-row__action :deep(.el-button) { margin-left: 0; padding: 4px 5px; font-size: 12px; }
.history-row__more { letter-spacing: 1px; }
.history-row:hover .history-row__action,
.history-row:focus-within .history-row__action { opacity: 1; }
:global(.history-dialog.el-dialog) { display: flex; width: 840px; height: min(760px, 82vh); max-width: calc(100vw - 40px); max-height: calc(100vh - 64px); margin: 8vh auto 0 !important; overflow: hidden; flex-direction: column; padding: 0; border-radius: 14px; }
:global(.history-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 16px 18px 14px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); }
:global(.history-dialog.el-dialog .el-dialog__headerbtn) { top: 13px; right: 13px; width: 36px; height: 36px; }
:global(.history-dialog.el-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; flex-direction: column; padding: 0; }
:global(.history-dialog-overlay.el-overlay),
:global(.history-dialog-overlay .el-overlay-dialog) { overflow: hidden; }
:global(body.is-desktop-runtime .history-dialog.el-dialog) { margin: 0 auto !important; --el-dialog-padding-primary: 0; padding: 0 !important; overflow: hidden !important; }
@media (max-width: 900px) {
.history-dialog__meta span + span::before { margin: 0 8px; }
.history-toolbar { flex-wrap: wrap; }
.history-toolbar__spacer { display: none; }
.history-source-filter { margin-left: auto; }
.history-panel { margin-right: 12px; margin-left: 12px; overflow-x: auto; }
}
</style>
@@ -0,0 +1,13 @@
<template>
<CollaborationHistoryDialog :file="ledger" :target="{ scope: 'ledger' }"
@close="emit('close')" @changed="emit('changed')" @updated="emit('updated', $event)" />
</template>
<script setup lang="ts">
import CollaborationHistoryDialog from "../collaboration/CollaborationHistoryDialog.vue";
import type { Ledger } from "../../types/ledger";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
const { ledger } = defineProps<{ ledger: Ledger }>();
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
</script>
@@ -0,0 +1,132 @@
<template>
<el-dialog :model-value="true" title="信息维护" width="560px" class="ledger-info-dialog"
append-to-body align-center :close-on-click-modal="false" @close="emit('close')">
<template #header="{ titleId }">
<div class="ledger-info-header">
<span class="ledger-info-header__icon" aria-hidden="true"><el-icon><EditPen /></el-icon></span>
<h2 :id="titleId">信息维护</h2>
</div>
</template>
<div v-if="error" class="ledger-error" role="alert">{{ error }} <el-button link @click="refresh">重试</el-button></div>
<el-form id="ledger-info-form" v-loading="loading" class="ledger-info-form" label-position="top"
:disabled="busy || loading || !current.can_manage" @submit.prevent="save">
<el-form-item label="台账名称" required>
<el-input v-model="form.title" maxlength="240" aria-label="台账名称" />
</el-form-item>
<el-form-item label="用途说明">
<template #label>用途说明<span class="ledger-field-optional">选填</span></template>
<el-input v-model="form.description" type="textarea" maxlength="500" :rows="3"
show-word-limit resize="vertical" aria-label="用途说明" />
</el-form-item>
<div class="ledger-info-details">
<el-form-item label="所有者" class="ledger-info-owner">
<div class="ledger-owner-identity">
<el-avatar :size="32" class="ledger-owner-avatar" aria-hidden="true">{{ ownerInitial }}</el-avatar>
<span class="ledger-owner-name" :title="current.owner_name">{{ current.owner_name || '—' }}</span>
</div>
</el-form-item>
<el-form-item label="台账状态" class="ledger-info-status">
<el-radio-group v-model="form.status" class="ledger-status-options" aria-label="台账状态">
<el-radio-button value="ACTIVE">使用中</el-radio-button>
<el-radio-button value="ARCHIVED">已归档(只读)</el-radio-button>
</el-radio-group>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button :disabled="busy" @click="emit('close')">取消</el-button>
<el-button type="primary" :loading="busy" :disabled="loading || !current.can_manage"
native-type="submit" form="ledger-info-form">保存信息</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { EditPen } from "@element-plus/icons-vue";
import { fetchLedger, updateLedger } from "../../api/ledgers";
import type { Ledger, LedgerSettings } from "../../types/ledger";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
const props = defineProps<{ ledger: Ledger }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const current = ref(props.ledger);
const ownerInitial = computed(() => current.value.owner_name?.trim().slice(0, 1).toUpperCase() || "?");
const toSettings = (ledger: Ledger): LedgerSettings => ({
title: ledger.title.replace(/\.xlsx$/i, ""), description: ledger.description, status: ledger.status,
owner_id: ledger.owner_id, allow_export: ledger.allow_export,
allow_sheet_structure_edit: ledger.allow_sheet_structure_edit, generation: ledger.generation,
});
const form = ref(toSettings(props.ledger));
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const refresh = async () => {
loading.value = true;
error.value = "";
try {
current.value = (await fetchLedger(current.value.id)).data;
form.value = toSettings(current.value);
if (!current.value.can_manage) {
emit("changed");
emit("close");
}
} catch (cause) { error.value = await getApiErrorMessage(cause, "台账信息加载失败"); }
finally { loading.value = false; }
};
const save = async () => {
if (busy.value || loading.value || !current.value.can_manage) return;
if (!form.value.title.trim()) { ElMessage.warning("请输入台账名称"); return; }
busy.value = true;
try {
await ElMessageBox.confirm("此操作会使已打开的台账重新加载。请确认协作者已保存内容后继续。", "更新台账信息", {
confirmButtonText: "继续", cancelButtonText: "取消", type: "warning",
});
await updateLedger(current.value.id, form.value);
ElMessage.success("台账信息已保存");
emit("changed");
emit("close");
} catch (cause) {
if (cause !== "cancel" && cause !== "close") error.value = await getApiErrorMessage(cause, "信息保存失败,请刷新后重试");
} finally { busy.value = false; }
};
onMounted(refresh);
</script>
<style scoped>
.ledger-info-header { display: flex; align-items: center; gap: 10px; padding-right: 30px; }
.ledger-info-header__icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 9px; background: var(--ctms-primary-light); color: var(--ctms-primary); font-size: 17px; }
.ledger-info-header h2 { margin: 0; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 26px; }
.ledger-info-form :deep(.el-form-item) { margin-bottom: 18px; }
.ledger-info-form :deep(.el-form-item__label) { height: auto; margin-bottom: 7px; color: var(--ctms-text-main); font-size: 13px; font-weight: 600; line-height: 20px; }
.ledger-field-optional { margin-left: 7px; color: var(--ctms-text-secondary); font-size: 12px; font-weight: 400; }
.ledger-info-form :deep(.el-input__wrapper) { min-height: 36px; padding: 0 11px; border-radius: 8px; }
.ledger-info-form :deep(.el-input__inner) { height: 36px; color: var(--ctms-text-main); font-size: 14px; }
.ledger-info-form :deep(.el-textarea__inner) { min-height: 88px; padding: 9px 11px 23px; border-radius: 8px; background: var(--ctms-bg-card); color: var(--ctms-text-main); font-size: 13px; line-height: 20px; }
.ledger-info-form :deep(.el-input__count) { right: 11px; bottom: 5px; background: var(--ctms-bg-card); color: var(--ctms-text-disabled); font-size: 11px; line-height: 16px; }
.ledger-info-details { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: 20px; padding-top: 16px; border-top: 1px solid var(--ctms-border-color); }
.ledger-info-details :deep(.el-form-item) { min-width: 0; margin-bottom: 0; }
.ledger-owner-identity { display: flex; align-items: center; gap: 9px; min-width: 0; width: 100%; min-height: 36px; }
.ledger-owner-avatar { flex-shrink: 0; background: var(--ctms-bg-muted); color: var(--ctms-primary); font-size: 13px; font-weight: 700; }
.ledger-owner-name { overflow: hidden; color: var(--ctms-text-regular); font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.ledger-status-options { display: flex; flex-wrap: nowrap; width: 100%; }
.ledger-status-options :deep(.el-radio-button) { flex: 1; }
.ledger-status-options :deep(.el-radio-button__inner) { display: flex; align-items: center; justify-content: center; width: 100%; height: 36px; padding: 0 10px; font-size: 12px; line-height: 18px; }
.ledger-status-options :deep(.el-radio-button:first-child .el-radio-button__inner) { border-radius: 8px 0 0 8px; }
.ledger-status-options :deep(.el-radio-button:last-child .el-radio-button__inner) { border-radius: 0 8px 8px 0; }
.ledger-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 16px; color: var(--ctms-danger); background: color-mix(in srgb, var(--ctms-danger) 8%, var(--ctms-bg-card)); border-radius: 8px; font-size: 13px; }
:global(.ledger-info-dialog.el-dialog) { display: flex; flex-direction: column; max-width: calc(100vw - 32px); max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); padding: 0; overflow: hidden; border: 1px solid var(--ctms-border-color); border-radius: 14px; background: var(--ctms-bg-card); }
:global(.ledger-info-dialog.el-dialog .el-dialog__header) { flex-shrink: 0; margin: 0; padding: 16px 22px; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-card); }
:global(.ledger-info-dialog.el-dialog .el-dialog__headerbtn) { top: 16px; right: 16px; width: 32px; height: 32px; }
:global(.ledger-info-dialog.el-dialog .el-dialog__body) { min-height: 0; padding: 20px 22px; overflow-y: auto; }
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { display: flex; flex-shrink: 0; align-items: center; justify-content: flex-end; gap: 10px; padding: 13px 22px; border-top: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); }
:global(.ledger-info-dialog.el-dialog .el-dialog__footer .el-button) { height: 34px; min-width: 80px; margin-left: 0; padding: 0 16px; border-radius: 8px; font-size: 13px; }
:global(body.is-desktop-runtime .ledger-info-dialog.el-dialog) { max-height: calc(100dvh - 96px); }
@media (max-width: 540px) {
.ledger-info-details { grid-template-columns: minmax(0, 1fr); gap: 16px; }
:global(.ledger-info-dialog.el-dialog .el-dialog__header),
:global(.ledger-info-dialog.el-dialog .el-dialog__body),
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { padding-left: 16px; padding-right: 16px; }
}
</style>
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import LedgerPanel from "./LedgerPanel.vue";
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
const state = vi.hoisted(() => ({ user: { is_admin: false }, push: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ fetchLedgers: vi.fn(), initializeLedgers: vi.fn() }));
vi.mock("../../store/auth", () => ({ useAuthStore: () => state }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: state.push }) }));
vi.mock("./LedgerInfoDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("./LedgerHistoryDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("../collaboration/CollaborationAccessDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
const ledger = {
id: "ledger-a", title: "医学事务部合同台账明细表.xlsx", description: "合同台账", owner_name: "负责人",
status: "ACTIVE", can_edit: true, can_manage: false, current_revision_created_at: "2026-09-03T00:00:00Z",
};
const render = () => mount(LedgerPanel, { global: { stubs: {
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
} } });
describe("global ledger entries", () => {
beforeEach(() => { vi.clearAllMocks(); state.user.is_admin = false; });
it("opens the granted ledger without needing a selected project", async () => {
vi.mocked(fetchLedgers).mockResolvedValue({ data: [ledger] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.text()).toContain("医学事务部合同台账明细表");
expect(wrapper.text()).not.toContain("台账管理");
await wrapper.get(".ledger-open").trigger("click");
expect(state.push).toHaveBeenCalledWith({ name: "LedgerWorkspace", params: { fileId: "ledger-a" } });
wrapper.unmount();
});
it("offers initialization only to administrators and shows an authorization empty state", async () => {
vi.mocked(fetchLedgers).mockResolvedValue({ data: [] } as any);
const viewer = render();
await flushPromises();
expect(viewer.text()).toContain("暂未获得台账访问权限");
expect(viewer.text()).not.toContain("初始化两本台账");
viewer.unmount();
state.user.is_admin = true;
vi.mocked(initializeLedgers).mockResolvedValue({ data: [ledger] } as any);
const admin = render();
await flushPromises();
await admin.get("button").trigger("click");
await flushPromises();
expect(initializeLedgers).toHaveBeenCalledOnce();
expect(admin.findAll(".ledger-card")).toHaveLength(1);
admin.unmount();
});
it("shows a recoverable error instead of misreporting failed authorization loading as no ledgers", async () => {
vi.mocked(fetchLedgers).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [ledger] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain("台账加载失败");
await wrapper.get("button").trigger("click");
await flushPromises();
expect(wrapper.findAll(".ledger-card")).toHaveLength(1);
wrapper.unmount();
});
});
@@ -0,0 +1,128 @@
<template>
<section class="ledger-panel" aria-labelledby="ledger-heading">
<header class="ledger-heading">
<span class="ledger-eyebrow">Collaborative Ledgers</span>
<div class="entry-title-row">
<h2 id="ledger-heading">协作台账</h2>
<EntryCountBadge v-if="ledgers.length">{{ ledgers.length }} 本可访问台账</EntryCountBadge>
</div>
</header>
<div v-if="loading" class="ledger-state" role="status">正在加载台账…</div>
<div v-else-if="error" class="ledger-state" role="alert">
<p>{{ error }}</p><el-button @click="load">重新加载</el-button>
</div>
<div v-else-if="!ledgers.length" class="ledger-state">
<p>{{ isAdmin ? '启用两本医学事务部台账,开始在线协作。' : '暂未获得台账访问权限,请联系管理员授权。' }}</p>
<el-button v-if="isAdmin" type="primary" :loading="initializing" @click="initialize">初始化两本台账</el-button>
</div>
<div v-else class="ledger-grid">
<article v-for="ledger in ledgers" :key="ledger.id" class="ledger-card">
<div class="ledger-card-top">
<div class="ledger-title-stack">
<h3 :title="displayTitle(ledger.title)">{{ displayTitle(ledger.title) }}</h3>
<p v-if="ledger.description?.trim()" class="ledger-description" :title="ledger.description">{{ ledger.description }}</p>
</div>
<span class="ledger-access" :class="{ 'is-readonly': !ledger.can_edit }">
{{ ledger.status === 'ARCHIVED' ? '已归档 · 只读' : ledger.can_edit ? '可编辑' : '只读' }}
</span>
</div>
<dl><div><dt>负责人</dt><dd>{{ ledger.owner_name }}</dd></div>
<div><dt>最近保存</dt><dd>{{ formatTime(ledger.current_revision_created_at) }}</dd></div></dl>
<footer>
<button class="ledger-open" type="button" @click="open(ledger)">打开台账 <el-icon><ArrowRight /></el-icon></button>
<el-dropdown v-if="ledger.can_manage" trigger="click" @command="handleManagementCommand($event, ledger)">
<el-button link>台账管理 <el-icon><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="info">信息维护</el-dropdown-item>
<el-dropdown-item command="access">访问与权限</el-dropdown-item>
<el-dropdown-item command="history">历史版本</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<el-button v-else link @click="openManagement('history', ledger)">历史版本</el-button>
</footer>
</article>
</div>
<LedgerInfoDialog v-if="management?.section === 'info'" :ledger="management.ledger" @close="management = null" @changed="load" />
<CollaborationAccessDialog v-if="management?.section === 'access'" :file="management.ledger" :target="{ scope: 'ledger' }"
@close="management = null" @changed="load" />
<LedgerHistoryDialog v-if="management?.section === 'history'" :ledger="management.ledger" @close="management = null" @changed="load" @updated="updateLedgerSummary" />
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { Ledger } from "../../types/ledger";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
import LedgerInfoDialog from "./LedgerInfoDialog.vue";
import LedgerHistoryDialog from "./LedgerHistoryDialog.vue";
import CollaborationAccessDialog from "../collaboration/CollaborationAccessDialog.vue";
import EntryCountBadge from "../EntryCountBadge.vue";
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const ledgers = ref<Ledger[]>([]);
const updateLedgerSummary = (file: CollaborationHistoryFile) => {
ledgers.value = ledgers.value.map((ledger) => ledger.id === file.id ? { ...ledger, ...file } as Ledger : ledger);
};
const loading = ref(true);
const initializing = ref(false);
const error = ref("");
type ManagementSection = "info" | "access" | "history";
const management = ref<{ section: ManagementSection; ledger: Ledger } | null>(null);
const openManagement = (section: ManagementSection, ledger: Ledger) => { management.value = { section, ledger }; };
const handleManagementCommand = (command: string, ledger: Ledger) => {
if (command === "info" || command === "access" || command === "history") openManagement(command, ledger);
};
const displayTitle = (title: string) => title.replace(/\.xlsx$/i, "");
const formatTime = (value: string | null) => value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "尚未保存";
const open = (ledger: Ledger) => router.push({ name: "LedgerWorkspace", params: { fileId: ledger.id } });
const load = async () => {
loading.value = true;
error.value = "";
try { ledgers.value = (await fetchLedgers()).data; }
catch (cause) { ledgers.value = []; error.value = await getApiErrorMessage(cause, "台账加载失败"); }
finally { loading.value = false; }
};
const initialize = async () => {
if (initializing.value) return;
initializing.value = true;
try { ledgers.value = (await initializeLedgers()).data; }
catch (cause) { error.value = await getApiErrorMessage(cause, "台账初始化失败"); }
finally { initializing.value = false; }
};
onMounted(load);
</script>
<style scoped>
.ledger-panel { margin-top: 44px; padding-bottom: 28px; }
.ledger-heading { margin-bottom: 26px; }
.ledger-eyebrow { color: #387cf4; font-size: 11px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; }
.ledger-heading h2 { margin: 0; color: #162033; font-size: 24px; }
.ledger-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.ledger-card { display: flex; flex-direction: column; min-width: 0; background: #fff; border: 1px solid rgba(226, 232, 240, 0.85); border-radius: 12px; box-shadow: 0 4px 15px rgba(15, 23, 42, 0.01), 0 1px 2px rgba(15, 23, 42, 0.02); }
.ledger-card-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-bottom: 16px; }
.ledger-title-stack { display: flex; flex-direction: column; min-width: 0; }
.ledger-access { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 4px; color: #218266; background: #edfaf5; font-size: 10px; font-weight: 700; white-space: nowrap; }
.ledger-access.is-readonly { color: #667289; background: #f1f4f8; }
.ledger-card h3 { margin: 0; font-size: 14.5px; line-height: 18px; font-weight: 800; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-description { color: #64748b; font-size: 10.5px; line-height: 13px; font-weight: 700; letter-spacing: 0.01em; margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-card dl { display: flex; flex-direction: column; gap: 5px; font-size: 11.5px; margin: 0 0 14px; }
.ledger-card dl > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.ledger-card dt { color: #64748b; font-weight: 600; flex-shrink: 0; }
.ledger-card dd { margin: 0; color: #0f172a; font-weight: 550; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid rgba(241, 245, 249, 0.85); margin-top: auto; padding-top: 10px; }
.ledger-open { display: flex; align-items: center; gap: 10px; border: 0; padding: 0; background: transparent; color: #1e3a8a; font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; }
.ledger-open .el-icon { width: 20px; height: 20px; border-radius: 50%; background: rgba(59, 130, 246, 0.06); color: #2563eb; }
.ledger-card footer .el-button { height: 20px; padding: 0; font-size: 11px; }
.ledger-open:focus-visible { outline: 2px solid #387cf4; outline-offset: 4px; }
.ledger-state { padding: 28px; background: #fff; border: 1px dashed #d6e1ef; border-radius: 14px; color: #738199; font-size: 14px; }
.ledger-state p { margin: 0 0 14px; }
:global(.desktop-entry .ledger-card) { border-radius: 8px; }
</style>
+5 -2
View File
@@ -14,7 +14,10 @@ describe("admin project route permissions", () => {
expect(source).toContain('name: "OfficeVersionPreview"');
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(3);
expect(source).toContain('path: "/ledgers/:fileId/revisions/:id/preview"');
expect(source).toContain('name: "LedgerRevisionPreview"');
expect(source).toContain('meta: { title: "台账历史版本预览", ledgerWorkspace: true }');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(4);
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
@@ -139,7 +142,7 @@ describe("admin project route permissions", () => {
expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\"");
expect(source).toContain("if (!canAccessProjectManagement)");
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)");
expect(source).toContain("if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy)");
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
expect(source).not.toContain("ensureDefaultActiveStudy();");
expect(source).not.toContain("ensureDefaultStudy();");
+13 -1
View File
@@ -121,6 +121,18 @@ const routes: RouteRecordRaw[] = [
component: WebWorkbenchEntry,
meta: { title: "工作台入口" },
},
{
path: "/ledgers/:fileId",
name: "LedgerWorkspace",
component: CollaborationWorkspace,
meta: { title: "协作台账", ledgerWorkspace: true },
},
{
path: "/ledgers/:fileId/revisions/:id/preview",
name: "LedgerRevisionPreview",
component: OfficePreviewWorkspace,
meta: { title: "台账历史版本预览", ledgerWorkspace: true },
},
{
path: DESKTOP_PROJECT_ENTRY_PATH,
name: "DesktopProjectEntry",
@@ -641,7 +653,7 @@ router.beforeEach(async (to, _from, next) => {
studyStore.rememberCurrentStudyForUser(auth.user.email);
}
if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy) {
if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
+45
View File
@@ -0,0 +1,45 @@
/* 工作台的项目和台账共用尺寸,说明文字是否存在不影响卡片高度。 */
:is(.web-entry, .desktop-entry) {
--entry-card-height: 172px;
}
:is(.web-entry, .desktop-entry) .entry-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 4px;
}
/* 项目显示第三行资料时,所有卡片一起增加一行的空间。 */
:is(.web-entry, .desktop-entry):has(.card-meta-list > .meta-item:nth-child(3)) {
--entry-card-height: 193px;
}
:is(.web-entry, .desktop-entry) :is(.modern-project-card, .ledger-card, .skeleton-project-card) {
box-sizing: border-box;
height: var(--entry-card-height);
min-height: var(--entry-card-height);
padding: 20px;
font-family: Arial, sans-serif;
}
:is(.web-entry, .desktop-entry) :is(.card-top-info, .ledger-card-top) {
min-height: 32px;
flex-shrink: 0;
}
:is(.web-entry, .desktop-entry) .project-title-stack small {
overflow: hidden;
line-height: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
:is(.web-entry, .desktop-entry) .project-title-stack strong {
line-height: 18px;
}
:is(.web-entry, .desktop-entry) :is(.card-meta-list, .ledger-card dl) {
line-height: 16px;
}
+48
View File
@@ -0,0 +1,48 @@
export type LedgerRole = "VIEWER" | "EDITOR" | "MANAGER";
export interface Ledger {
id: string;
title: string;
description: string;
status: "ACTIVE" | "ARCHIVED";
owner_id: string;
owner_name: string;
generation: number;
updated_at: string;
current_revision_id: string | null;
current_revision_no: number | null;
current_revision_file_size: number | null;
current_revision_mime_type: string | null;
current_revision_created_at: string | null;
role: LedgerRole;
can_edit: boolean;
can_manage: boolean;
can_export: boolean;
allow_export: boolean;
allow_edit_request: boolean;
allow_sheet_structure_edit: boolean;
file_type: "cell";
extension: "xlsx";
can_request_edit: boolean;
edit_request_status?: "PENDING" | "APPROVED" | "REJECTED" | null;
can_transfer_ownership: boolean;
}
export type LedgerSettings = Pick<Ledger,
"title" | "description" | "status" | "owner_id" | "allow_export" | "allow_sheet_structure_edit" | "generation"
>;
export interface LedgerCandidate {
user_id: string;
full_name: string;
email: string;
role_in_study: string;
can_be_editor: boolean;
can_be_manager: boolean;
}
export interface LedgerMember extends Pick<LedgerCandidate, "user_id" | "full_name" | "email"> {
role: LedgerRole;
is_active: boolean;
protected_label?: string | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision";
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision" | "ledger_revision";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
+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);
});
});