功能(文档与桌面):完善文件预览下载与客户端构建基线
- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:22.13-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Generated
+281
@@ -18,6 +18,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-echarts": "^8.0.1",
|
||||
@@ -34,6 +35,9 @@
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -707,6 +711,271 @@
|
||||
"version": "1.5.5",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz",
|
||||
"integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.2",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.2",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.2",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.2",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.2",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.2",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.2",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.2",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.2",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz",
|
||||
"integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
@@ -3186,6 +3455,18 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.1.200",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz",
|
||||
"integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"license": "ISC"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -36,6 +39,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-echarts": "^8.0.1",
|
||||
|
||||
@@ -114,6 +114,12 @@ const verifyCapabilities = async () => {
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
];
|
||||
const requiredTemporaryFilePermissions = [
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const capability = await readJson(resolve(capabilitiesDir, file));
|
||||
@@ -141,6 +147,9 @@ const verifyCapabilities = async () => {
|
||||
for (const identifier of requiredNotificationPermissions) {
|
||||
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
|
||||
}
|
||||
for (const identifier of requiredTemporaryFilePermissions) {
|
||||
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
|
||||
}
|
||||
|
||||
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
||||
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
||||
@@ -320,6 +329,7 @@ const verifyUpdaterBoundary = async () => {
|
||||
|
||||
const verifyWorkflowGates = async () => {
|
||||
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
|
||||
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
|
||||
const requiredScripts = [
|
||||
"desktop:build:macos-release",
|
||||
"desktop:update-feed:create",
|
||||
@@ -350,6 +360,7 @@ const verifyWorkflowGates = async () => {
|
||||
}
|
||||
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
|
||||
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
|
||||
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
|
||||
|
||||
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
|
||||
const requiredReleaseWorkflowTokens = [
|
||||
@@ -368,6 +379,7 @@ const verifyWorkflowGates = async () => {
|
||||
for (const token of requiredReleaseWorkflowTokens) {
|
||||
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
|
||||
}
|
||||
assert(releaseWorkflow.includes('node-version: "22.13"'), "Desktop release candidates must use Node.js 22.13.");
|
||||
|
||||
const windowsInternalWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-windows-internal.yml"), "utf8");
|
||||
const requiredWindowsInternalWorkflowTokens = [
|
||||
@@ -407,6 +419,22 @@ const verifyWorkflowGates = async () => {
|
||||
for (const token of forbiddenWindowsInternalWorkflowTokens) {
|
||||
assert(!windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must not include ${token}.`);
|
||||
}
|
||||
assert(windowsInternalWorkflow.includes('node-version: "22.13"'), "Desktop Windows internal builds must use Node.js 22.13.");
|
||||
|
||||
for (const dockerfile of [resolve(frontendDir, "Dockerfile"), resolve(rootDir, "nginx/Dockerfile")]) {
|
||||
const dockerSource = await readFile(dockerfile, "utf8");
|
||||
assert(dockerSource.startsWith("FROM node:22.13-alpine"), `${relative(rootDir, dockerfile)} must build with Node.js 22.13.`);
|
||||
}
|
||||
|
||||
const developmentCompose = await readFile(resolve(rootDir, "docker-compose.dev.yaml"), "utf8");
|
||||
assert(developmentCompose.includes("image: node:22.13-alpine"), "Development frontend container must use Node.js 22.13.");
|
||||
assert(developmentCompose.includes('dependency_hash="$$lock_hash:$$(node --version)"'), "Development dependency cache must include the Node.js version.");
|
||||
assert(developmentCompose.includes("pdfjs-dist"), "Development dependency checks must include PDF.js.");
|
||||
assert(
|
||||
developmentCompose.includes("frontend_node_modules_node22:/app/node_modules") &&
|
||||
developmentCompose.includes("frontend_node_modules_node22:"),
|
||||
"Development dependencies must use the Node.js 22-specific volume.",
|
||||
);
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
|
||||
export const downloadDocumentVersion = (versionId: string) =>
|
||||
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
|
||||
responseType: "blob",
|
||||
suppressErrorMessage: true,
|
||||
});
|
||||
|
||||
export const deleteDocumentVersion = (versionId: string) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@ export const TEXT = {
|
||||
uploadSuccess: "上传成功",
|
||||
uploadFailed: "上传失败",
|
||||
downloadFailed: "下载失败",
|
||||
openFailed: "文件打开失败",
|
||||
actionFailed: "操作失败",
|
||||
noPermission: "无权限执行该操作",
|
||||
networkError: "网络错误,请稍后重试",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./platform", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
describe("web file saving", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).showSaveFilePicker;
|
||||
delete (URL as any).createObjectURL;
|
||||
delete (URL as any).revokeObjectURL;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("selects a browser save path before writing the file", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const createWritable = vi.fn().mockResolvedValue({ write, close });
|
||||
(window as any).showSaveFilePicker = vi.fn().mockResolvedValue({ createWritable });
|
||||
const { prepareSaveFile, saveFile } = await import("./files");
|
||||
|
||||
const destination = await prepareSaveFile("研究方案.xlsx");
|
||||
const output = { suggestedName: "研究方案.xlsx", data: new Blob(["content"]) };
|
||||
|
||||
await expect(saveFile(output, destination)).resolves.toBe("saved");
|
||||
expect((window as any).showSaveFilePicker).toHaveBeenCalledWith({ suggestedName: "研究方案.xlsx" });
|
||||
expect(write).toHaveBeenCalledWith(output.data);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not download when the save picker is cancelled", async () => {
|
||||
(window as any).showSaveFilePicker = vi.fn().mockRejectedValue(new DOMException("cancelled", "AbortError"));
|
||||
const { prepareSaveFile } = await import("./files");
|
||||
|
||||
await expect(prepareSaveFile("protocol.pdf")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a browser download when the file picker is unavailable", async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
(URL as any).createObjectURL = vi.fn().mockReturnValue("blob:test");
|
||||
(URL as any).revokeObjectURL = vi.fn();
|
||||
const { prepareSaveFile, saveFile } = await import("./files");
|
||||
|
||||
const destination = await prepareSaveFile("protocol.pdf");
|
||||
await expect(saveFile({ suggestedName: "protocol.pdf", data: "content" }, destination)).resolves.toBe("saved");
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,25 @@ export interface FileOutput {
|
||||
|
||||
export type SaveFileResult = "saved" | "cancelled";
|
||||
|
||||
type WebWritableFile = {
|
||||
write(data: Blob): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
abort?(): Promise<void>;
|
||||
};
|
||||
|
||||
type WebSaveFileHandle = {
|
||||
createWritable(): Promise<WebWritableFile>;
|
||||
};
|
||||
|
||||
export type SaveFileDestination =
|
||||
| { kind: "desktop-path"; path: string }
|
||||
| { kind: "web-file-handle"; handle: WebSaveFileHandle }
|
||||
| { kind: "web-download" };
|
||||
|
||||
type WebSaveFilePickerWindow = Window & {
|
||||
showSaveFilePicker?: (options: { suggestedName: string }) => Promise<WebSaveFileHandle>;
|
||||
};
|
||||
|
||||
const sanitizeFileName = (value: string): string => {
|
||||
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
|
||||
return normalized.slice(0, 180) || "download";
|
||||
@@ -83,9 +102,46 @@ export const pickFiles = async (options: FilePickerOptions = {}): Promise<File[]
|
||||
);
|
||||
};
|
||||
|
||||
export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
|
||||
const suggestedName = sanitizeFileName(output.suggestedName);
|
||||
export const prepareSaveFile = async (suggestedName: string): Promise<SaveFileDestination | null> => {
|
||||
const safeName = sanitizeFileName(suggestedName);
|
||||
if (!isTauriRuntime()) {
|
||||
const picker = (window as WebSaveFilePickerWindow).showSaveFilePicker;
|
||||
if (!picker) return { kind: "web-download" };
|
||||
try {
|
||||
const handle = await picker.call(window, { suggestedName: safeName });
|
||||
return { kind: "web-file-handle", handle };
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return null;
|
||||
return { kind: "web-download" };
|
||||
}
|
||||
}
|
||||
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: safeName });
|
||||
return path ? { kind: "desktop-path", path } : null;
|
||||
};
|
||||
|
||||
export const saveFile = async (
|
||||
output: FileOutput,
|
||||
preparedDestination?: SaveFileDestination | null,
|
||||
): Promise<SaveFileResult> => {
|
||||
const suggestedName = sanitizeFileName(output.suggestedName);
|
||||
const destination = preparedDestination === undefined ? await prepareSaveFile(suggestedName) : preparedDestination;
|
||||
if (!destination) return "cancelled";
|
||||
|
||||
if (destination.kind === "web-file-handle") {
|
||||
const writable = await destination.handle.createWritable();
|
||||
try {
|
||||
await writable.write(toBlob(output));
|
||||
await writable.close();
|
||||
} catch (error) {
|
||||
await writable.abort?.().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return "saved";
|
||||
}
|
||||
|
||||
if (destination.kind === "web-download") {
|
||||
const url = URL.createObjectURL(toBlob(output));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -97,13 +153,8 @@ export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
|
||||
return "saved";
|
||||
}
|
||||
|
||||
const [{ save }, { writeFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const path = await save({ defaultPath: suggestedName });
|
||||
if (!path) return "cancelled";
|
||||
await writeFile(path, await toBytes(output));
|
||||
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
await writeFile(destination.path, await toBytes(output));
|
||||
return "saved";
|
||||
};
|
||||
|
||||
|
||||
@@ -64,9 +64,11 @@ export {
|
||||
cleanupTemporaryFiles,
|
||||
openFile,
|
||||
pickFiles,
|
||||
prepareSaveFile,
|
||||
saveFile,
|
||||
type FileOutput,
|
||||
type FilePickerOptions,
|
||||
type SaveFileDestination,
|
||||
type SaveFileResult,
|
||||
} from "./files";
|
||||
export {
|
||||
|
||||
@@ -1038,7 +1038,7 @@ body.is-desktop-runtime .el-overlay-message-box {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .el-dialog,
|
||||
body.is-desktop-runtime :where(.el-dialog),
|
||||
body.is-desktop-runtime .el-message-box {
|
||||
overflow: hidden;
|
||||
margin: 0 auto !important;
|
||||
@@ -1052,7 +1052,7 @@ body.is-desktop-runtime .el-message-box {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .el-dialog {
|
||||
body.is-desktop-runtime :where(.el-dialog) {
|
||||
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
|
||||
}
|
||||
|
||||
@@ -1061,7 +1061,8 @@ body.is-desktop-runtime .profile-settings-dialog,
|
||||
body.is-desktop-runtime .desktop-preferences-dialog,
|
||||
body.is-desktop-runtime .user-form-dialog,
|
||||
body.is-desktop-runtime .reset-password-dialog,
|
||||
body.is-desktop-runtime .project-form-dialog {
|
||||
body.is-desktop-runtime .project-form-dialog,
|
||||
body.is-desktop-runtime .pdf-preview-dialog--flush {
|
||||
overflow: visible !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -1070,6 +1071,26 @@ body.is-desktop-runtime .project-form-dialog {
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .pdf-preview-dialog--flush {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .pdf-preview-dialog--flush .el-dialog__body {
|
||||
height: 100%;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .pdf-preview-dialog--flush .pdf-viewer {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.42);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 28px 84px rgba(15, 23, 42, 0.26),
|
||||
0 1px 0 rgba(255, 255, 255, 0.82) inset;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .el-message-box {
|
||||
width: min(420px, calc(100vw - 72px));
|
||||
padding: 0;
|
||||
@@ -1105,7 +1126,8 @@ body.is-desktop-runtime .el-message-box__headerbtn .el-message-box__close {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
body.is-desktop-runtime .el-dialog__body {
|
||||
/* 低优先级桌面基线:普通弹窗保留间距,带专属布局的弹窗可直接覆盖。 */
|
||||
body.is-desktop-runtime :where(.el-dialog__body) {
|
||||
padding: 14px;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface DocumentVersion {
|
||||
effective_at?: string | null;
|
||||
superseded_at?: string | null;
|
||||
file_uri: string;
|
||||
original_filename?: string | null;
|
||||
file_hash: string;
|
||||
file_size: number;
|
||||
mime_type?: string | null;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getApiErrorMessage } from "./apiErrorMessage";
|
||||
|
||||
describe("getApiErrorMessage", () => {
|
||||
it("extracts FastAPI detail from a blob error response", async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: new Blob([JSON.stringify({ detail: "文件不存在" })], { type: "application/json" }),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(getApiErrorMessage(error, "下载失败")).resolves.toBe("文件不存在");
|
||||
});
|
||||
|
||||
it("extracts nested detail messages", async () => {
|
||||
const error = { response: { data: { detail: { message: "权限不足" } } } };
|
||||
|
||||
await expect(getApiErrorMessage(error, "请求失败")).resolves.toBe("权限不足");
|
||||
});
|
||||
|
||||
it("uses the fallback when no readable message exists", async () => {
|
||||
await expect(getApiErrorMessage({}, "下载失败")).resolves.toBe("下载失败");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const payloadMessage = (payload: unknown): string | null => {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.message === "string" && record.message.trim()) return record.message;
|
||||
if (typeof record.detail === "string" && record.detail.trim()) return record.detail;
|
||||
if (record.detail && typeof record.detail === "object") {
|
||||
const detail = record.detail as Record<string, unknown>;
|
||||
if (typeof detail.message === "string" && detail.message.trim()) return detail.message;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const textPayloadMessage = (value: string): string | null => {
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return payloadMessage(JSON.parse(text)) || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const readBlobText = async (blob: Blob): Promise<string> => {
|
||||
if (typeof blob.text === "function") return blob.text();
|
||||
if (typeof FileReader !== "undefined") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(typeof reader.result === "string" ? reader.result : ""), { once: true });
|
||||
reader.addEventListener("error", () => reject(reader.error), { once: true });
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Axios 的 blob 请求在失败时也会把 JSON 错误体包装成 Blob。
|
||||
* 统一解析后端 message/detail,避免下载、打开等操作只能显示笼统的“下载失败”。
|
||||
*/
|
||||
export const getApiErrorMessage = async (error: unknown, fallback: string): Promise<string> => {
|
||||
const data = (error as any)?.response?.data;
|
||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
try {
|
||||
return textPayloadMessage(await readBlobText(data)) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
if (typeof data === "string") return textPayloadMessage(data) || fallback;
|
||||
return payloadMessage(data) || fallback;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getContentDispositionFilename } from "./contentDisposition";
|
||||
|
||||
describe("getContentDispositionFilename", () => {
|
||||
it("prefers the UTF-8 filename even when the ASCII fallback appears first", () => {
|
||||
const header = 'attachment; filename="____ V1.0.xlsx"; filename*=UTF-8\'\'%E7%A0%94%E7%A9%B6%E6%96%B9%E6%A1%88%20V1.0.xlsx';
|
||||
|
||||
expect(getContentDispositionFilename(header)).toBe("研究方案 V1.0.xlsx");
|
||||
});
|
||||
|
||||
it("falls back to a quoted filename", () => {
|
||||
expect(getContentDispositionFilename('attachment; filename="protocol.pdf"')).toBe("protocol.pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export const getContentDispositionFilename = (header?: string | null): string | null => {
|
||||
if (!header) return null;
|
||||
const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded.trim().replace(/^"|"$/g, ""));
|
||||
} catch {
|
||||
// Fall through to the ASCII filename when the RFC 5987 value is malformed.
|
||||
}
|
||||
}
|
||||
return /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header)?.slice(1).find(Boolean)?.trim() || null;
|
||||
};
|
||||
@@ -80,6 +80,16 @@ describe("file task feedback", () => {
|
||||
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
|
||||
});
|
||||
|
||||
it("writes to a destination that was selected before the download", async () => {
|
||||
const destination = { kind: "web-file-handle", handle: { createWritable: vi.fn() } } as const;
|
||||
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
|
||||
|
||||
await expect(saveFileWithFeedback(output, {}, destination)).resolves.toBe("saved");
|
||||
|
||||
expect(saveFileMock).toHaveBeenCalledWith(output, destination);
|
||||
expect(messageSuccessMock).toHaveBeenCalledWith("文件已保存");
|
||||
});
|
||||
|
||||
it("uses persistent desktop activity for native save flows", async () => {
|
||||
capabilitiesMock.mockReturnValue({ nativeFiles: true });
|
||||
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
saveFile,
|
||||
type FileOutput,
|
||||
type FilePickerOptions,
|
||||
type SaveFileDestination,
|
||||
type SaveFileResult,
|
||||
} from "../runtime";
|
||||
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../session/desktopActivityCenter";
|
||||
@@ -38,15 +39,16 @@ export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Pr
|
||||
export const saveFileWithFeedback = async (
|
||||
output: FileOutput,
|
||||
options: FileTaskFeedbackOptions = {},
|
||||
destination?: SaveFileDestination,
|
||||
): Promise<SaveFileResult> => {
|
||||
const kind = options.kind || "download";
|
||||
if (!desktopFileActivitiesEnabled()) {
|
||||
const result = await saveFile(output);
|
||||
const result = destination ? await saveFile(output, destination) : await saveFile(output);
|
||||
if (result === "cancelled") {
|
||||
ElMessage.info("已取消保存文件");
|
||||
return result;
|
||||
}
|
||||
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
|
||||
ElMessage.success(destination?.kind === "web-file-handle" ? "文件已保存" : "文件下载已开始");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ export const saveFileWithFeedback = async (
|
||||
});
|
||||
updateDesktopActivity(activityId, { detail: options.pendingDetail || "等待选择保存位置", progress: 70 });
|
||||
try {
|
||||
const result = await saveFile(output);
|
||||
const result = destination ? await saveFile(output, destination) : await saveFile(output);
|
||||
if (result === "cancelled") {
|
||||
finishDesktopActivity(activityId, "cancelled", { detail: options.cancelledDetail || "已取消保存" });
|
||||
return result;
|
||||
|
||||
@@ -62,6 +62,29 @@ describe("DocumentDetail permissions", () => {
|
||||
expect(source).toContain("TEXT.common.messages.previewNotSupported");
|
||||
expect(source).toContain("URL.createObjectURL(blob)");
|
||||
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
|
||||
expect(source).toContain("<PdfViewer");
|
||||
expect(source).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("keeps all four version actions visible and reports blob download errors", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('columns.actions" width="220"');
|
||||
expect(source).toContain("getApiErrorMessage");
|
||||
expect(source).toContain("await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)");
|
||||
expect(source).toContain("ElMessage.error(TEXT.common.messages.openFailed)");
|
||||
});
|
||||
|
||||
it("preserves upload filenames and selects a web save destination before downloading", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("version.original_filename?.trim()");
|
||||
expect(source).toContain("uuidStorageName.test(name)");
|
||||
expect(source).toContain("detail.title?.trim()");
|
||||
expect(source).toContain("await prepareSaveFile(suggestedName)");
|
||||
expect(source.indexOf("await prepareSaveFile(suggestedName)")).toBeLessThan(source.indexOf("const response = await downloadDocumentVersion(version.id)", source.indexOf("const downloadVersion")));
|
||||
expect(source).toContain("getContentDispositionFilename");
|
||||
expect(source).not.toContain("const getFilename =");
|
||||
});
|
||||
|
||||
it("prefers backend creator display data instead of exposing creator ids", () => {
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="160" fixed="right">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button link type="primary" size="small" @click="previewVersion(row)" v-if="canReadDocument">
|
||||
@@ -172,14 +172,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<div v-loading="previewLoading" class="preview-body">
|
||||
<img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" />
|
||||
<iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" class="preview-frame" />
|
||||
<PdfViewer v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" :filename="previewTitle" show-close @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -350,7 +350,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
||||
@@ -380,8 +380,12 @@ import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
||||
import { getContentDispositionFilename } from "../../utils/contentDisposition";
|
||||
import { prepareSaveFile } from "../../runtime";
|
||||
|
||||
const route = useRoute();
|
||||
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
@@ -753,24 +757,24 @@ const submitUpload = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getFilename = (header?: string | null) => {
|
||||
if (!header) return null;
|
||||
const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(header);
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match[1] || match[2] || "");
|
||||
};
|
||||
|
||||
const getVersionFileName = (version: DocumentVersion) => {
|
||||
if (version.original_filename?.trim()) return version.original_filename.trim();
|
||||
const uri = version.file_uri || "";
|
||||
const name = uri.split(/[\\/]/).filter(Boolean).pop() || "";
|
||||
return name.toLowerCase();
|
||||
const uuidStorageName = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(\.[^.]*)?$/i;
|
||||
if (uuidStorageName.test(name)) {
|
||||
const extension = name.match(/(\.[^.]*)$/)?.[1] || "";
|
||||
const title = detail.title?.trim() || `document-${version.version_no || version.id}`;
|
||||
return extension && title.toLowerCase().endsWith(extension.toLowerCase()) ? title : `${title}${extension}`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const detectPreviewType = (version: DocumentVersion) => {
|
||||
const mime = (version.mime_type || "").toLowerCase();
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
const name = getVersionFileName(version);
|
||||
const name = getVersionFileName(version).toLowerCase();
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
|
||||
if (name.endsWith(".pdf")) return "pdf";
|
||||
return "other";
|
||||
@@ -786,7 +790,7 @@ const previewVersion = async (version: DocumentVersion) => {
|
||||
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
clearPreviewObjectUrl();
|
||||
previewType.value = detectPreviewType(version);
|
||||
previewTitle.value = `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
|
||||
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
|
||||
previewUrl.value = "";
|
||||
previewError.value = "";
|
||||
previewVisible.value = true;
|
||||
@@ -802,34 +806,49 @@ const previewVersion = async (version: DocumentVersion) => {
|
||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||
previewUrl.value = previewObjectUrl.value;
|
||||
} catch (e: any) {
|
||||
previewError.value = e?.response?.data?.message || TEXT.common.messages.previewNotSupported;
|
||||
previewError.value = await getApiErrorMessage(e, TEXT.common.messages.previewNotSupported);
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadVersion = async (version: DocumentVersion) => {
|
||||
const suggestedName = getVersionFileName(version) || `document-${version.version_no || version.id}.bin`;
|
||||
try {
|
||||
const destination = await prepareSaveFile(suggestedName);
|
||||
if (!destination) {
|
||||
ElMessage.info("已取消保存文件");
|
||||
return;
|
||||
}
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
const filename = getContentDispositionFilename(response.headers?.["content-disposition"]) || suggestedName;
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
|
||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }, {}, destination);
|
||||
} catch (e: any) { ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)); }
|
||||
};
|
||||
|
||||
const openVersion = async (version: DocumentVersion) => {
|
||||
let response;
|
||||
try {
|
||||
response = await downloadDocumentVersion(version.id);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
const filename = getContentDispositionFilename(response.headers?.["content-disposition"])
|
||||
|| getVersionFileName(version)
|
||||
|| `document-${version.version_no || version.id}.bin`;
|
||||
await openFileWithFeedback({
|
||||
suggestedName: filename,
|
||||
mimeType: contentType,
|
||||
data: new Blob([response.data], { type: contentType }),
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.openFailed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1119,12 +1138,6 @@ onBeforeUnmount(() => {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-media {
|
||||
max-width: 100%;
|
||||
max-height: 520px;
|
||||
|
||||
Reference in New Issue
Block a user