feat: 文件展示与提交逻辑优化
This commit is contained in:
@ -254,23 +254,28 @@
|
||||
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
|
||||
</div>
|
||||
|
||||
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="200">
|
||||
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="260">
|
||||
<template #reference>
|
||||
<el-button link type="primary" :icon="Document" />
|
||||
<el-button link type="primary" :icon="row.generalManual.some(l => !isExternalLink(l) && !isImageFile(l)) ? Files : Document" />
|
||||
</template>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<div v-for="(link, idx) in row.generalManual" :key="idx">
|
||||
<el-link v-if="isExternalLink(link)" :href="link" target="_blank" type="primary" :underline="false">
|
||||
说明书 {{idx+1}} <el-icon><Link /></el-icon>
|
||||
</el-link>
|
||||
<el-image v-else-if="isImageFile(link)"
|
||||
style="width: 100px; height: 100px"
|
||||
:src="getImageUrl(link)"
|
||||
:preview-src-list="[getImageUrl(link)]"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
<!-- 图片文件 -->
|
||||
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l))" :key="'img-' + idx">
|
||||
<el-image
|
||||
style="width: 80px; height: 80px; cursor: pointer;"
|
||||
:src="getImageUrl(link)"
|
||||
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
/>
|
||||
<el-link v-else :href="getImageUrl(link)" target="_blank" type="info">PDF 文件 {{idx+1}}</el-link>
|
||||
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
|
||||
</div>
|
||||
<!-- 非图片文件 -->
|
||||
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
|
||||
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
|
||||
<el-icon><Files /></el-icon>
|
||||
{{ link.split('/').pop() }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
@ -457,7 +462,32 @@
|
||||
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
<template #default>
|
||||
<div v-if="!fileListManual.length" class="upload-add-trigger">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<template #file="{ file }">
|
||||
<div class="upload-file-item">
|
||||
<template v-if="isImageFile(file.url)">
|
||||
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-thumbnail">
|
||||
<el-icon size="28"><Document /></el-icon>
|
||||
<span class="file-name">{{ truncateFileName(file.name) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</span>
|
||||
<span class="el-upload-list__item-delete" @click="() => handleRemoveImage(file, 'generalManual')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<div class="camera-card" @click="triggerCamera('generalManual')">
|
||||
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
||||
@ -563,7 +593,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
@ -673,6 +703,9 @@ const cameraDialogVisible = ref(false);
|
||||
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
|
||||
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
|
||||
|
||||
// 脏检查 - 记录编辑前的原始数据
|
||||
const originalForm = ref<any>(null);
|
||||
|
||||
// 复选框选中数据
|
||||
const selectedItems = ref<MaterialBaseVO[]>([]);
|
||||
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
|
||||
@ -1115,6 +1148,9 @@ const handleEdit = (row: MaterialBaseVO) => {
|
||||
const data = JSON.parse(JSON.stringify(row));
|
||||
Object.assign(form.value, data);
|
||||
|
||||
// 深拷贝保存原始数据用于脏检查
|
||||
originalForm.value = JSON.parse(JSON.stringify(data));
|
||||
|
||||
if (data.category) {
|
||||
const parts = data.category.split('/');
|
||||
if (parts.length > 0) {
|
||||
@ -1163,6 +1199,33 @@ const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((val, idx) => val === sortedB[idx]);
|
||||
};
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName'];
|
||||
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key];
|
||||
const originalVal = original[key];
|
||||
|
||||
// 处理数组比较(generalImage, generalManual)
|
||||
if (Array.isArray(currentVal) && Array.isArray(originalVal)) {
|
||||
if (!isArraysEqual(currentVal, originalVal)) {
|
||||
payload[key] = currentVal;
|
||||
}
|
||||
} else if (currentVal !== originalVal) {
|
||||
payload[key] = currentVal;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
@ -1188,19 +1251,44 @@ const submitForm = async () => {
|
||||
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
|
||||
else fullCategory = prefixStr || suffixStr;
|
||||
|
||||
const payload = {
|
||||
// 构建最终表单数据
|
||||
const finalForm = {
|
||||
...form.value,
|
||||
category: fullCategory,
|
||||
generalImage: finalImageList,
|
||||
generalManual: finalManualList
|
||||
};
|
||||
|
||||
// 脏检查:只提交变更的字段
|
||||
let payload: any;
|
||||
if (form.value.id && originalForm.value) {
|
||||
// 编辑模式:生成部分更新 payload
|
||||
payload = buildPartialPayload(finalForm, originalForm.value);
|
||||
// 如果分类被修改,需要确保包含在 payload 中
|
||||
if (payload.category === undefined && fullCategory !== originalForm.value.category) {
|
||||
payload.category = fullCategory;
|
||||
}
|
||||
} else {
|
||||
// 新增模式:提交完整数据
|
||||
payload = finalForm;
|
||||
}
|
||||
|
||||
// 如果没有变更,提示用户
|
||||
const changedKeys = Object.keys(payload).filter(k => k !== 'id');
|
||||
if (changedKeys.length === 0) {
|
||||
ElMessage.info('没有检测到数据变更,无需保存');
|
||||
submitLoading.value = false;
|
||||
dialog.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
|
||||
const actionText = form.value.id ? '修改' : '新增';
|
||||
await requestApi(payload);
|
||||
|
||||
ElMessage.success(`${actionText}成功`);
|
||||
dialog.visible = false;
|
||||
originalForm.value = null;
|
||||
getList();
|
||||
getOptionsList();
|
||||
} catch (error: any) {
|
||||
@ -1227,6 +1315,7 @@ const resetForm = () => {
|
||||
|
||||
imageExternalUrl.value = '';
|
||||
manualExternalUrl.value = '';
|
||||
originalForm.value = null;
|
||||
if (formRef.value) formRef.value.resetFields();
|
||||
};
|
||||
|
||||
@ -1358,8 +1447,21 @@ const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
|
||||
|
||||
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
||||
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
|
||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) }
|
||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
|
||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
|
||||
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
|
||||
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
|
||||
|
||||
const handleDownloadConfirm = (link: string) => {
|
||||
const fileName = link.split('/').pop() || '文件';
|
||||
ElMessageBox.confirm(`确认要下载/查看「${fileName}」吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}).then(() => {
|
||||
window.open(getImageUrl(link), '_blank');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const beforeAvatarUpload = (rawFile: any) => {
|
||||
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
|
||||
@ -1410,8 +1512,13 @@ const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' |
|
||||
}
|
||||
|
||||
const handlePreviewPicture = (uploadFile: any) => {
|
||||
dialogImageUrl.value = uploadFile.url!;
|
||||
dialogVisibleImage.value = true
|
||||
const fileUrl = uploadFile.url || uploadFile.response?.url || '';
|
||||
if (isImageFile(fileUrl)) {
|
||||
dialogImageUrl.value = getImageUrl(fileUrl);
|
||||
dialogVisibleImage.value = true;
|
||||
} else {
|
||||
window.open(getImageUrl(fileUrl), '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
|
||||
@ -1528,6 +1635,16 @@ onMounted(() => {
|
||||
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
|
||||
|
||||
/* 上传文件项样式 - 非图片文件显示 */
|
||||
.upload-file-item { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
|
||||
.upload-file-item .el-upload-list__item-thumbnail { width: 100%; height: 100%; object-fit: cover; }
|
||||
.upload-file-item .file-thumbnail { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #606266; }
|
||||
.upload-file-item .file-thumbnail .file-name { font-size: 10px; margin-top: 4px; text-align: center; padding: 0 4px; word-break: break-all; max-width: 90px; }
|
||||
.upload-file-item .el-upload-list__item-actions { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); opacity: 0; transition: opacity 0.3s; }
|
||||
.upload-file-item:hover .el-upload-list__item-actions { opacity: 1; }
|
||||
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
|
||||
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||
|
||||
/* 预警行样式 - 加深颜色 */
|
||||
:deep(.warning-row-red) {
|
||||
--el-table-tr-bg-color: #ffcdd2 !important;
|
||||
|
||||
Reference in New Issue
Block a user