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

新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
This commit is contained in:
Cheng Zhou
2026-09-03 16:55:08 +08:00
parent 46a6dda753
commit c1dd8def2c
41 changed files with 4155 additions and 1815 deletions
@@ -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>