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