功能(文档与桌面):完善文件预览下载与客户端构建基线
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
@@ -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;