功能(文档与桌面):完善文件预览下载与客户端构建基线
- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
@@ -519,6 +519,18 @@ describe("desktop layout shell", () => {
|
||||
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
|
||||
});
|
||||
|
||||
it("keeps attachment upload mode flat and responsive across web and desktop", () => {
|
||||
const attachments = readAttachmentListSource();
|
||||
|
||||
expect(attachments).not.toContain('class="attachment-list-panel unified-shell"');
|
||||
expect(attachments).toContain("'unified-shell': displayMode === 'table'");
|
||||
expect(attachments).toContain("'attachment-list-panel--upload': displayMode === 'upload'");
|
||||
expect(attachments).toContain("'upload-grid--single': uploadGroups.length === 1");
|
||||
expect(attachments).toContain(".upload-grid--single {");
|
||||
expect(attachments).toContain('class="pending-file-info"');
|
||||
expect(attachments).toContain('class="pending-file-meta"');
|
||||
});
|
||||
|
||||
it("surfaces persistent desktop activity feedback without local task persistence", () => {
|
||||
const desktopLayout = readDesktopLayoutSource();
|
||||
const activityCenter = readDesktopActivityCenterSource();
|
||||
@@ -595,7 +607,8 @@ describe("desktop layout shell", () => {
|
||||
expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);");
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-overlay-dialog");
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box");
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-dialog,");
|
||||
expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog),");
|
||||
expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog__body) {");
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-message-box");
|
||||
expect(styles).toContain("border-radius: 12px;");
|
||||
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getDocumentMock = vi.hoisted(() => vi.fn());
|
||||
const renderMock = vi.hoisted(() => vi.fn());
|
||||
const destroyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pdfjs-dist", () => ({
|
||||
GlobalWorkerOptions: { workerSrc: "" },
|
||||
getDocument: getDocumentMock,
|
||||
}));
|
||||
|
||||
import PdfViewer from "./PdfViewer.vue";
|
||||
|
||||
describe("PdfViewer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
renderMock.mockReturnValue({ promise: Promise.resolve(), cancel: vi.fn() });
|
||||
destroyMock.mockResolvedValue(undefined);
|
||||
getDocumentMock.mockReturnValue({
|
||||
promise: Promise.resolve({
|
||||
numPages: 2,
|
||||
getPage: vi.fn().mockResolvedValue({
|
||||
getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
|
||||
render: renderMock,
|
||||
}),
|
||||
destroy: destroyMock,
|
||||
}),
|
||||
destroy: destroyMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders PDF pages to canvas without an iframe", async () => {
|
||||
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
|
||||
await flushPromises();
|
||||
|
||||
expect(getDocumentMock).toHaveBeenCalledWith({ url: "blob:test-pdf" });
|
||||
expect(wrapper.findAll("canvas")).toHaveLength(2);
|
||||
expect(wrapper.find("iframe").exists()).toBe(false);
|
||||
expect(renderMock).toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain("/ 2");
|
||||
|
||||
await wrapper.get("button:nth-of-type(2)").trigger("click");
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("/ 2");
|
||||
|
||||
wrapper.unmount();
|
||||
await flushPromises();
|
||||
expect(destroyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("integrates the dialog close action into the viewer toolbar", async () => {
|
||||
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf", showClose: true } });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[aria-label="关闭预览"]').trigger("click");
|
||||
|
||||
expect(wrapper.emitted("close")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the visible document center anchored while zooming", async () => {
|
||||
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
|
||||
await flushPromises();
|
||||
const viewport = wrapper.get(".pdf-viewer__viewport").element as HTMLElement;
|
||||
Object.defineProperties(viewport, {
|
||||
clientWidth: { configurable: true, value: 800 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollWidth: {
|
||||
configurable: true,
|
||||
get: () => Math.max(800, Number.parseInt(wrapper.get("canvas").element.style.width, 10) || 0),
|
||||
},
|
||||
scrollHeight: {
|
||||
configurable: true,
|
||||
get: () => Math.max(600, Number.parseInt(wrapper.get("canvas").element.style.height, 10) || 0),
|
||||
},
|
||||
});
|
||||
await wrapper.get("button.is-active").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[aria-label="放大"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(viewport.scrollLeft).toBeGreaterThan(0);
|
||||
expect(viewport.scrollTop).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the original filename in the center of the toolbar", async () => {
|
||||
const wrapper = mount(PdfViewer, {
|
||||
props: { src: "blob:test-pdf", filename: "研究方案最终版.pdf" },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get(".pdf-viewer__filename").text()).toBe("研究方案最终版.pdf");
|
||||
expect(wrapper.text()).not.toContain("页文档");
|
||||
});
|
||||
|
||||
it("updates the current page while scrolling through the continuous document", async () => {
|
||||
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
|
||||
await flushPromises();
|
||||
const viewport = wrapper.get(".pdf-viewer__viewport");
|
||||
const pages = wrapper.findAll(".pdf-viewer__page");
|
||||
Object.defineProperties(viewport.element, {
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 850 },
|
||||
});
|
||||
Object.defineProperties(pages[0].element, {
|
||||
offsetTop: { configurable: true, value: 0 },
|
||||
offsetHeight: { configurable: true, value: 800 },
|
||||
});
|
||||
Object.defineProperties(pages[1].element, {
|
||||
offsetTop: { configurable: true, value: 816 },
|
||||
offsetHeight: { configurable: true, value: 800 },
|
||||
});
|
||||
|
||||
await viewport.trigger("scroll");
|
||||
|
||||
expect((wrapper.get(".pdf-viewer__page-input").element as HTMLInputElement).value).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,835 @@
|
||||
<template>
|
||||
<section class="pdf-viewer" aria-label="PDF 预览器">
|
||||
<header class="pdf-viewer__toolbar">
|
||||
<div class="pdf-viewer__group" aria-label="翻页控制">
|
||||
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber <= 1" aria-label="上一页" title="上一页" @click="previousPage">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 15-5-5 5-5" /></svg>
|
||||
</button>
|
||||
<label class="pdf-viewer__page-control">
|
||||
<span class="sr-only">当前页</span>
|
||||
<input
|
||||
v-model.number="pageInput"
|
||||
class="pdf-viewer__page-input"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="pageCount || 1"
|
||||
:disabled="busy || !pageCount"
|
||||
@change="commitPageInput"
|
||||
@keyup.enter="commitPageInput"
|
||||
/>
|
||||
<span class="pdf-viewer__page-total">/ {{ pageCount || 0 }}</span>
|
||||
</label>
|
||||
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber >= pageCount" aria-label="下一页" title="下一页" @click="nextPage">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 5 5 5-5 5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pdf-viewer__document-meta" :title="filename || 'PDF 文档'">
|
||||
<span class="pdf-viewer__filename">{{ filename || "PDF 文档" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pdf-viewer__actions">
|
||||
<div class="pdf-viewer__group" aria-label="缩放控制">
|
||||
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent <= MIN_ZOOM" aria-label="缩小" title="缩小" @click="zoomOut">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10" /></svg>
|
||||
</button>
|
||||
<span class="pdf-viewer__zoom">{{ renderedScalePercent }}%</span>
|
||||
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent >= MAX_ZOOM" aria-label="放大" title="放大" @click="zoomIn">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10M10 5v10" /></svg>
|
||||
</button>
|
||||
<button type="button" class="pdf-viewer__button" :class="{ 'is-active': fitWidth }" :disabled="busy" @click="useFitWidth">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 6v8M16.5 6v8M6.5 10h7M8.5 8l-2 2 2 2M11.5 8l2 2-2 2" /></svg>
|
||||
适合宽度
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="showClose" class="pdf-viewer__divider" aria-hidden="true" />
|
||||
<button v-if="showClose" type="button" class="pdf-viewer__button pdf-viewer__button--icon pdf-viewer__close" aria-label="关闭预览" title="关闭预览" @click="emit('close')">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref="viewportRef" class="pdf-viewer__viewport" @scroll.passive="handleViewportScroll">
|
||||
<div v-if="errorMessage" class="pdf-viewer__state pdf-viewer__state--error" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div v-else-if="loadingDocument" class="pdf-viewer__state" aria-live="polite">
|
||||
<span class="pdf-viewer__spinner" aria-hidden="true" />
|
||||
<span>正在加载 PDF…</span>
|
||||
</div>
|
||||
<div v-else class="pdf-viewer__pages">
|
||||
<article
|
||||
v-for="pageIndex in pageCount"
|
||||
:key="pageIndex"
|
||||
:ref="(element) => setPageElement(element, pageIndex)"
|
||||
class="pdf-viewer__page"
|
||||
:class="{ 'is-rendering': renderingPages.has(pageIndex) }"
|
||||
:data-page-number="pageIndex"
|
||||
>
|
||||
<canvas
|
||||
:ref="(element) => setCanvasElement(element, pageIndex)"
|
||||
class="pdf-viewer__canvas"
|
||||
:aria-label="`PDF 第 ${pageIndex} 页`"
|
||||
/>
|
||||
<div v-if="renderingPages.has(pageIndex)" class="pdf-viewer__rendering" aria-live="polite">
|
||||
<span class="pdf-viewer__spinner pdf-viewer__spinner--small" aria-hidden="true" />
|
||||
正在渲染第 {{ pageIndex }} 页
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
type PDFDocumentLoadingTask,
|
||||
type PDFDocumentProxy,
|
||||
type PDFPageProxy,
|
||||
type RenderTask,
|
||||
} from "pdfjs-dist";
|
||||
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
const MIN_ZOOM = 25;
|
||||
const MAX_ZOOM = 300;
|
||||
const ZOOM_STEP = 25;
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
filename?: string;
|
||||
showClose?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const viewportRef = ref<HTMLElement | null>(null);
|
||||
const loadingDocument = ref(false);
|
||||
const renderingPages = ref(new Set<number>());
|
||||
const errorMessage = ref("");
|
||||
const pageNumber = ref(1);
|
||||
const pageInput = ref(1);
|
||||
const pageCount = ref(0);
|
||||
const zoomPercent = ref(100);
|
||||
const renderedScalePercent = ref(100);
|
||||
const fitWidth = ref(true);
|
||||
|
||||
let loadingTask: PDFDocumentLoadingTask | null = null;
|
||||
let pdfDocument: PDFDocumentProxy | null = null;
|
||||
const renderTasks = new Map<number, RenderTask>();
|
||||
const canvasElements = new Map<number, HTMLCanvasElement>();
|
||||
const pageElements = new Map<number, HTMLElement>();
|
||||
const pageScales = new Map<number, number>();
|
||||
const pageLayouts = new Map<number, {
|
||||
pageIndex: number;
|
||||
page: PDFPageProxy;
|
||||
canvas: HTMLCanvasElement;
|
||||
viewport: ReturnType<PDFPageProxy["getViewport"]>;
|
||||
outputScale: number;
|
||||
}>();
|
||||
const renderedPages = new Set<number>();
|
||||
let loadSequence = 0;
|
||||
let renderSequence = 0;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeFrame = 0;
|
||||
|
||||
const busy = computed(() => loadingDocument.value || !pdfDocument);
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value));
|
||||
|
||||
const describePdfError = (error: unknown) => {
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
if (name === "PasswordException") return "该 PDF 已加密,暂时无法在线预览";
|
||||
if (name === "InvalidPDFException") return "PDF 文件格式无效或已损坏";
|
||||
return "PDF 加载失败,请下载后查看";
|
||||
};
|
||||
|
||||
const setCanvasElement = (element: unknown, pageIndex: number) => {
|
||||
if (element instanceof HTMLCanvasElement) canvasElements.set(pageIndex, element);
|
||||
else canvasElements.delete(pageIndex);
|
||||
};
|
||||
|
||||
const setPageElement = (element: unknown, pageIndex: number) => {
|
||||
if (element instanceof HTMLElement) pageElements.set(pageIndex, element);
|
||||
else pageElements.delete(pageIndex);
|
||||
};
|
||||
|
||||
const cancelRenderTasks = () => {
|
||||
renderTasks.forEach((task) => task.cancel());
|
||||
renderTasks.clear();
|
||||
};
|
||||
|
||||
const updateRenderingPage = (pageIndex: number, rendering: boolean) => {
|
||||
const nextPages = new Set(renderingPages.value);
|
||||
if (rendering) nextPages.add(pageIndex);
|
||||
else nextPages.delete(pageIndex);
|
||||
renderingPages.value = nextPages;
|
||||
};
|
||||
|
||||
const updateCurrentPage = (value: number) => {
|
||||
const nextPage = clamp(value, 1, pageCount.value || 1);
|
||||
pageNumber.value = nextPage;
|
||||
pageInput.value = nextPage;
|
||||
const scale = pageScales.get(nextPage);
|
||||
if (scale) renderedScalePercent.value = Math.round(scale * 100);
|
||||
};
|
||||
|
||||
const renderPage = async (pageIndex: number, sequence: number) => {
|
||||
if (sequence !== renderSequence || renderedPages.has(pageIndex) || renderTasks.has(pageIndex)) return;
|
||||
const layout = pageLayouts.get(pageIndex);
|
||||
if (!layout) return;
|
||||
|
||||
const { page, canvas, viewport, outputScale } = layout;
|
||||
canvas.width = Math.floor(viewport.width * outputScale);
|
||||
canvas.height = Math.floor(viewport.height * outputScale);
|
||||
updateRenderingPage(pageIndex, true);
|
||||
const task = page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
background: "#ffffff",
|
||||
transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
|
||||
});
|
||||
renderTasks.set(pageIndex, task);
|
||||
try {
|
||||
await task.promise;
|
||||
if (sequence === renderSequence) renderedPages.add(pageIndex);
|
||||
} catch (error) {
|
||||
if (sequence === renderSequence && !(error instanceof Error && error.name === "RenderingCancelledException")) {
|
||||
errorMessage.value = describePdfError(error);
|
||||
}
|
||||
} finally {
|
||||
if (renderTasks.get(pageIndex) === task) {
|
||||
renderTasks.delete(pageIndex);
|
||||
if (sequence === renderSequence) updateRenderingPage(pageIndex, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderVisiblePages = async (sequence = renderSequence) => {
|
||||
const viewport = viewportRef.value;
|
||||
if (!viewport || sequence !== renderSequence) return;
|
||||
const viewportHeight = Math.max(viewport.clientHeight, 1);
|
||||
const renderTop = Math.max(0, viewport.scrollTop - viewportHeight);
|
||||
const renderBottom = viewport.scrollTop + viewportHeight * 2;
|
||||
const retainTop = Math.max(0, viewport.scrollTop - viewportHeight * 3);
|
||||
const retainBottom = viewport.scrollTop + viewportHeight * 4;
|
||||
const pagesToRender: number[] = [];
|
||||
|
||||
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
|
||||
const pageElement = pageElements.get(pageIndex);
|
||||
if (!pageElement) continue;
|
||||
const pageTop = pageElement.offsetTop;
|
||||
const pageBottom = pageTop + pageElement.offsetHeight;
|
||||
if (pageBottom >= renderTop && pageTop <= renderBottom) pagesToRender.push(pageIndex);
|
||||
if (renderedPages.has(pageIndex) && (pageBottom < retainTop || pageTop > retainBottom)) {
|
||||
const canvas = canvasElements.get(pageIndex);
|
||||
if (canvas) {
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
}
|
||||
renderedPages.delete(pageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(pagesToRender.map((pageIndex) => renderPage(pageIndex, sequence)));
|
||||
};
|
||||
|
||||
const renderAllPages = async (preservePosition = true) => {
|
||||
const sequence = ++renderSequence;
|
||||
const document = pdfDocument;
|
||||
const viewportElement = viewportRef.value;
|
||||
if (!document || !viewportElement || errorMessage.value) return;
|
||||
|
||||
cancelRenderTasks();
|
||||
renderingPages.value = new Set();
|
||||
renderedPages.clear();
|
||||
pageLayouts.clear();
|
||||
try {
|
||||
const previousScrollWidth = Math.max(viewportElement.scrollWidth, viewportElement.clientWidth);
|
||||
const previousScrollHeight = Math.max(viewportElement.scrollHeight, viewportElement.clientHeight);
|
||||
const horizontalAnchor = (viewportElement.scrollLeft + viewportElement.clientWidth / 2) / Math.max(previousScrollWidth, 1);
|
||||
const verticalAnchor = (viewportElement.scrollTop + viewportElement.clientHeight / 2) / Math.max(previousScrollHeight, 1);
|
||||
const pageNumbers = Array.from({ length: pageCount.value }, (_, index) => index + 1);
|
||||
const pages = await Promise.all(pageNumbers.map((pageIndex) => document.getPage(pageIndex)));
|
||||
if (sequence !== renderSequence) return;
|
||||
const availableWidth = Math.max(240, viewportElement.clientWidth - 2);
|
||||
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
|
||||
pages.forEach((page, index) => {
|
||||
const pageIndex = index + 1;
|
||||
const canvas = canvasElements.get(pageIndex);
|
||||
if (!canvas) return;
|
||||
const unscaledViewport = page.getViewport({ scale: 1 });
|
||||
const scale = fitWidth.value
|
||||
? clamp(availableWidth / unscaledViewport.width, MIN_ZOOM / 100, MAX_ZOOM / 100)
|
||||
: zoomPercent.value / 100;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||
pageScales.set(pageIndex, scale);
|
||||
pageLayouts.set(pageIndex, { pageIndex, page, canvas, viewport, outputScale });
|
||||
});
|
||||
|
||||
const currentScale = pageScales.get(pageNumber.value);
|
||||
if (currentScale) renderedScalePercent.value = Math.round(currentScale * 100);
|
||||
await nextTick();
|
||||
|
||||
if (preservePosition) {
|
||||
viewportElement.scrollLeft = Math.max(0, horizontalAnchor * viewportElement.scrollWidth - viewportElement.clientWidth / 2);
|
||||
viewportElement.scrollTop = Math.max(0, verticalAnchor * viewportElement.scrollHeight - viewportElement.clientHeight / 2);
|
||||
} else {
|
||||
viewportElement.scrollLeft = 0;
|
||||
viewportElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
await renderVisiblePages(sequence);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.name === "RenderingCancelledException")) {
|
||||
errorMessage.value = describePdfError(error);
|
||||
}
|
||||
} finally {
|
||||
if (sequence === renderSequence) {
|
||||
renderTasks.clear();
|
||||
renderingPages.value = new Set();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const disposeDocument = async () => {
|
||||
renderSequence += 1;
|
||||
cancelRenderTasks();
|
||||
renderingPages.value = new Set();
|
||||
canvasElements.clear();
|
||||
pageElements.clear();
|
||||
pageScales.clear();
|
||||
pageLayouts.clear();
|
||||
renderedPages.clear();
|
||||
const task = loadingTask;
|
||||
loadingTask = null;
|
||||
pdfDocument = null;
|
||||
await task?.destroy().catch(() => {});
|
||||
};
|
||||
|
||||
const loadPdf = async () => {
|
||||
const sequence = ++loadSequence;
|
||||
await disposeDocument();
|
||||
errorMessage.value = "";
|
||||
pageCount.value = 0;
|
||||
pageNumber.value = 1;
|
||||
pageInput.value = 1;
|
||||
loadingDocument.value = true;
|
||||
if (!props.src) {
|
||||
loadingDocument.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = getDocument({ url: props.src });
|
||||
loadingTask = task;
|
||||
const document = await task.promise;
|
||||
if (sequence !== loadSequence) {
|
||||
await task.destroy();
|
||||
return;
|
||||
}
|
||||
pdfDocument = document;
|
||||
pageCount.value = document.numPages;
|
||||
loadingDocument.value = false;
|
||||
await nextTick();
|
||||
await renderAllPages(false);
|
||||
} catch (error) {
|
||||
if (sequence === loadSequence) errorMessage.value = describePdfError(error);
|
||||
} finally {
|
||||
if (sequence === loadSequence) loadingDocument.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPage = (value: number, behavior: ScrollBehavior = "smooth") => {
|
||||
const nextPage = clamp(value, 1, pageCount.value || 1);
|
||||
updateCurrentPage(nextPage);
|
||||
const viewport = viewportRef.value;
|
||||
const page = pageElements.get(nextPage);
|
||||
if (!viewport || !page) return;
|
||||
if (typeof viewport.scrollTo === "function") viewport.scrollTo({ top: page.offsetTop, behavior });
|
||||
else viewport.scrollTop = page.offsetTop;
|
||||
};
|
||||
|
||||
const previousPage = () => scrollToPage(pageNumber.value - 1);
|
||||
|
||||
const nextPage = () => scrollToPage(pageNumber.value + 1);
|
||||
|
||||
const handleViewportScroll = () => {
|
||||
const viewport = viewportRef.value;
|
||||
if (!viewport || !pageCount.value) return;
|
||||
const readingLine = viewport.scrollTop + viewport.clientHeight / 2;
|
||||
let visiblePage = pageCount.value;
|
||||
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
|
||||
const page = pageElements.get(pageIndex);
|
||||
if (!page) continue;
|
||||
const pageBottom = page.offsetTop + page.offsetHeight + 8;
|
||||
if (readingLine < pageBottom) {
|
||||
visiblePage = pageIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateCurrentPage(visiblePage);
|
||||
void renderVisiblePages();
|
||||
};
|
||||
|
||||
const commitPageInput = () => {
|
||||
scrollToPage(Number(pageInput.value) || 1);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
fitWidth.value = false;
|
||||
zoomPercent.value = clamp(renderedScalePercent.value - ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
fitWidth.value = false;
|
||||
zoomPercent.value = clamp(renderedScalePercent.value + ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
|
||||
};
|
||||
|
||||
const useFitWidth = () => {
|
||||
fitWidth.value = true;
|
||||
void renderAllPages(true);
|
||||
};
|
||||
|
||||
watch(() => props.src, () => void loadPdf(), { immediate: true });
|
||||
watch(zoomPercent, () => {
|
||||
if (!fitWidth.value) void renderAllPages(true);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver === "undefined" || !viewportRef.value) return;
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!fitWidth.value) return;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => void renderAllPages(true));
|
||||
});
|
||||
resizeObserver.observe(viewportRef.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeObserver?.disconnect();
|
||||
void disposeDocument();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-viewer {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #e8edf2;
|
||||
}
|
||||
|
||||
.pdf-viewer__toolbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
min-height: 52px;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid #dce2e8;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.pdf-viewer__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pdf-viewer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pdf-viewer__divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
margin-left: 2px;
|
||||
background: #e1e6eb;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 4px;
|
||||
color: #66717e;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pdf-viewer__button {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #43505e;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.pdf-viewer__button--icon {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pdf-viewer__button svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.pdf-viewer__button:hover:not(:disabled) {
|
||||
color: #2f536d;
|
||||
background: #e8f0f5;
|
||||
}
|
||||
|
||||
.pdf-viewer__button.is-active {
|
||||
color: #365d77;
|
||||
background: #e5eef4;
|
||||
box-shadow: inset 0 0 0 1px rgba(69, 103, 127, 0.12);
|
||||
}
|
||||
|
||||
.pdf-viewer__button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.pdf-viewer__button:focus-visible,
|
||||
.pdf-viewer__page-input:focus-visible {
|
||||
outline: 2px solid rgba(64, 111, 145, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.pdf-viewer__button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-input {
|
||||
width: 42px;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d7dde4;
|
||||
border-radius: 6px;
|
||||
color: #25313d;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-input::-webkit-inner-spin-button,
|
||||
.pdf-viewer__page-input::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.pdf-viewer__page-total {
|
||||
min-width: 26px;
|
||||
}
|
||||
|
||||
.pdf-viewer__document-meta {
|
||||
min-width: 0;
|
||||
max-width: min(38vw, 460px);
|
||||
justify-self: center;
|
||||
color: #536171;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pdf-viewer__filename {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pdf-viewer__zoom {
|
||||
min-width: 52px;
|
||||
color: #42505d;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pdf-viewer__close {
|
||||
color: #7a8693;
|
||||
}
|
||||
|
||||
.pdf-viewer__close:hover:not(:disabled) {
|
||||
color: #344252;
|
||||
background: #eef2f5;
|
||||
}
|
||||
|
||||
.pdf-viewer__viewport {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
background-color: #e7ebf0;
|
||||
background-image: radial-gradient(rgba(92, 106, 121, 0.12) 0.7px, transparent 0.7px);
|
||||
background-size: 12px 12px;
|
||||
scrollbar-color: #aeb7c1 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.pdf-viewer__viewport::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.pdf-viewer__viewport::-webkit-scrollbar-thumb {
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: #aeb7c1;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.pdf-viewer__viewport::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pdf-viewer__pages {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pdf-viewer__page {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pdf-viewer__canvas {
|
||||
display: block;
|
||||
border: 1px solid rgba(116, 127, 140, 0.2);
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.08),
|
||||
0 12px 34px rgba(42, 51, 62, 0.2);
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.pdf-viewer__page.is-rendering .pdf-viewer__canvas {
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.pdf-viewer__rendering {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: rgba(41, 52, 64, 0.82);
|
||||
box-shadow: 0 5px 16px rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pdf-viewer__state {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pdf-viewer__state--error {
|
||||
color: #c45656;
|
||||
}
|
||||
|
||||
.pdf-viewer__spinner {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(69, 103, 127, 0.2);
|
||||
border-top-color: #45677f;
|
||||
border-radius: 50%;
|
||||
animation: pdf-viewer-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.pdf-viewer__spinner--small {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
border-top-color: #fff;
|
||||
}
|
||||
|
||||
@keyframes pdf-viewer-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.pdf-viewer {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pdf-viewer__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pdf-viewer__document-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.pdf-preview-dialog.el-dialog {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(205, 214, 223, 0.8);
|
||||
border-radius: 16px;
|
||||
background: #f8fafb;
|
||||
box-shadow: 0 24px 72px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 19px 24px 17px;
|
||||
border-bottom: 1px solid #e6ebef;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__title {
|
||||
color: #202c38;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__headerbtn {
|
||||
top: 7px;
|
||||
right: 8px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9px;
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__headerbtn:hover {
|
||||
background: #f1f4f7;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__close {
|
||||
color: #7a8693;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__body {
|
||||
padding: 18px 20px 22px;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog--flush.el-dialog {
|
||||
height: min(84dvh, 880px);
|
||||
margin-bottom: 0;
|
||||
background: #e7ebf0;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog--flush .el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog--flush .el-dialog__body {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog--flush .preview-body,
|
||||
.pdf-preview-dialog--flush .pdf-viewer {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.pdf-preview-dialog--flush.el-dialog {
|
||||
width: calc(100vw - 16px) !important;
|
||||
height: calc(100dvh - 16px);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__header {
|
||||
padding: 16px 18px 14px;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pdf-preview-dialog--flush .el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="attachment-list-panel unified-shell">
|
||||
<div
|
||||
class="attachment-list-panel"
|
||||
:class="{
|
||||
'unified-shell': displayMode === 'table',
|
||||
'attachment-list-panel--upload': displayMode === 'upload',
|
||||
}"
|
||||
>
|
||||
<template v-if="displayMode === 'table'">
|
||||
<div v-if="showHeader" class="header">
|
||||
<span>{{ headerTitle }}</span>
|
||||
@@ -60,7 +66,14 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
|
||||
<div
|
||||
v-else
|
||||
class="upload-grid"
|
||||
:class="{
|
||||
'upload-grid--single': uploadGroups.length === 1,
|
||||
'upload-grid--three': uploadGroups.length > 1 && uploadCardColumns === 3,
|
||||
}"
|
||||
>
|
||||
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
@@ -92,7 +105,10 @@
|
||||
'is-disabled': !canUploadGroup(group),
|
||||
'attachment-upload-card--centered': centerUploadCardContent,
|
||||
}"
|
||||
role="button"
|
||||
:tabindex="canUploadGroup(group) ? 0 : -1"
|
||||
@click="pickPendingNative(group)"
|
||||
@keydown.enter.space.prevent="pickPendingNative(group)"
|
||||
>
|
||||
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
||||
<div class="upload-trigger">
|
||||
@@ -107,11 +123,17 @@
|
||||
class="pending-file"
|
||||
:class="{ 'is-failed': item.status === 'failed' }"
|
||||
>
|
||||
<span class="pending-file-name">{{ item.file.name }}</span>
|
||||
<span v-if="item.status === 'failed'" class="pending-file-status">
|
||||
{{ item.error || TEXT.common.messages.uploadFailed }}
|
||||
<span class="pending-file-icon" aria-hidden="true">
|
||||
<el-icon><Document /></el-icon>
|
||||
</span>
|
||||
<el-button link type="danger" size="small" @click="removePendingUpload(group.key, item)">
|
||||
<span class="pending-file-info">
|
||||
<span class="pending-file-name">{{ item.file.name }}</span>
|
||||
<span class="pending-file-meta">
|
||||
<span>{{ formatFileSize(item.file.size) }}</span>
|
||||
<span class="pending-file-status">{{ pendingStatusText(item) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<el-button class="pending-file-remove" link type="danger" size="small" @click="removePendingUpload(group.key, item)">
|
||||
{{ TEXT.common.actions.remove }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -125,13 +147,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
|
||||
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" :width="previewType === 'pdf' ? 'min(86vw, 1440px)' : 'min(1180px, 94vw)'" :top="previewType === 'pdf' ? '8vh' : '3vh'" :close-on-click-modal="false" :show-close="previewType !== 'pdf'" :class="['pdf-preview-dialog', { 'pdf-preview-dialog--flush': previewType === 'pdf' }]">
|
||||
<div v-if="previewError" class="preview-error">
|
||||
{{ previewError }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
|
||||
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
|
||||
<PdfViewer v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" :filename="previewTitle" show-close @close="previewVisible = false" />
|
||||
<div v-else class="preview-error">
|
||||
{{ TEXT.common.messages.previewNotSupported }}
|
||||
</div>
|
||||
@@ -140,9 +162,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { Document, UploadFilled } from "@element-plus/icons-vue";
|
||||
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { formatFileSize } from "./attachmentUtils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
@@ -162,6 +184,8 @@ type AttachmentEntityGroup = {
|
||||
uploadText?: string;
|
||||
};
|
||||
|
||||
const PdfViewer = defineAsyncComponent(() => import("../PdfViewer.vue"));
|
||||
|
||||
type UploadGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -291,6 +315,12 @@ const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
|
||||
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
|
||||
};
|
||||
|
||||
const pendingStatusText = (item: PendingUploadItem) => {
|
||||
if (item.status === "uploading") return "正在上传";
|
||||
if (item.status === "failed") return item.error || TEXT.common.messages.uploadFailed;
|
||||
return "待上传";
|
||||
};
|
||||
|
||||
const pendingSnapshot = () =>
|
||||
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
|
||||
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
|
||||
@@ -580,6 +610,11 @@ defineExpose({
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.attachment-list-panel--upload {
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -621,6 +656,10 @@ defineExpose({
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.upload-grid--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.attachment-upload-item {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -632,6 +671,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.attachment-upload-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 72px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed #d0dced;
|
||||
@@ -641,6 +681,14 @@ defineExpose({
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-grid--single .attachment-upload-card {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.upload-grid--single .upload-trigger {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attachment-upload-card:hover,
|
||||
.attachment-upload-card:focus-within {
|
||||
border-color: var(--ctms-primary);
|
||||
@@ -709,50 +757,90 @@ defineExpose({
|
||||
.pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pending-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: var(--ctms-text-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pending-file.is-failed {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
color: var(--ctms-danger, #f56c6c);
|
||||
}
|
||||
|
||||
.pending-file-name {
|
||||
.pending-file-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: #eaf1fb;
|
||||
color: #314a6b;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.pending-file-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pending-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pending-file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pending-file-status {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ctms-danger, #f56c6c);
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pending-file.is-failed .pending-file-status {
|
||||
color: var(--ctms-danger, #f56c6c);
|
||||
}
|
||||
|
||||
.pending-file-remove {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-media {
|
||||
max-width: 100%;
|
||||
max-height: 520px;
|
||||
|
||||
Reference in New Issue
Block a user