功能(文档与桌面):完善文件预览下载与客户端构建基线
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
Cheng Zhou
2026-07-13 18:40:48 +08:00
parent ab59476d10
commit 44db5db838
40 changed files with 1817 additions and 87 deletions
+41 -28
View File
@@ -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;