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