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