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>
|
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
|
||||||
</div>
|
</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>
|
<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>
|
</template>
|
||||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
<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">
|
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l))" :key="'img-' + idx">
|
||||||
说明书 {{idx+1}} <el-icon><Link /></el-icon>
|
<el-image
|
||||||
</el-link>
|
style="width: 80px; height: 80px; cursor: pointer;"
|
||||||
<el-image v-else-if="isImageFile(link)"
|
:src="getImageUrl(link)"
|
||||||
style="width: 100px; height: 100px"
|
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
|
||||||
:src="getImageUrl(link)"
|
fit="cover"
|
||||||
:preview-src-list="[getImageUrl(link)]"
|
preview-teleported
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
@ -457,7 +462,32 @@
|
|||||||
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
|
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
|
||||||
:before-upload="beforeAvatarUpload"
|
: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>
|
</el-upload>
|
||||||
<div class="camera-card" @click="triggerCamera('generalManual')">
|
<div class="camera-card" @click="triggerCamera('generalManual')">
|
||||||
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
||||||
@ -563,7 +593,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
|
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 { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
|
||||||
import type { FormInstance, FormRules } from 'element-plus';
|
import type { FormInstance, FormRules } from 'element-plus';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
@ -673,6 +703,9 @@ const cameraDialogVisible = ref(false);
|
|||||||
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
|
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
|
||||||
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
|
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
|
||||||
|
|
||||||
|
// 脏检查 - 记录编辑前的原始数据
|
||||||
|
const originalForm = ref<any>(null);
|
||||||
|
|
||||||
// 复选框选中数据
|
// 复选框选中数据
|
||||||
const selectedItems = ref<MaterialBaseVO[]>([]);
|
const selectedItems = ref<MaterialBaseVO[]>([]);
|
||||||
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
|
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
|
||||||
@ -1115,6 +1148,9 @@ const handleEdit = (row: MaterialBaseVO) => {
|
|||||||
const data = JSON.parse(JSON.stringify(row));
|
const data = JSON.parse(JSON.stringify(row));
|
||||||
Object.assign(form.value, data);
|
Object.assign(form.value, data);
|
||||||
|
|
||||||
|
// 深拷贝保存原始数据用于脏检查
|
||||||
|
originalForm.value = JSON.parse(JSON.stringify(data));
|
||||||
|
|
||||||
if (data.category) {
|
if (data.category) {
|
||||||
const parts = data.category.split('/');
|
const parts = data.category.split('/');
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
@ -1163,6 +1199,33 @@ const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
|||||||
return false;
|
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 () => {
|
const submitForm = async () => {
|
||||||
if (!formRef.value) return;
|
if (!formRef.value) return;
|
||||||
|
|
||||||
@ -1188,19 +1251,44 @@ const submitForm = async () => {
|
|||||||
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
|
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
|
||||||
else fullCategory = prefixStr || suffixStr;
|
else fullCategory = prefixStr || suffixStr;
|
||||||
|
|
||||||
const payload = {
|
// 构建最终表单数据
|
||||||
|
const finalForm = {
|
||||||
...form.value,
|
...form.value,
|
||||||
category: fullCategory,
|
category: fullCategory,
|
||||||
generalImage: finalImageList,
|
generalImage: finalImageList,
|
||||||
generalManual: finalManualList
|
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 requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
|
||||||
const actionText = form.value.id ? '修改' : '新增';
|
const actionText = form.value.id ? '修改' : '新增';
|
||||||
await requestApi(payload);
|
await requestApi(payload);
|
||||||
|
|
||||||
ElMessage.success(`${actionText}成功`);
|
ElMessage.success(`${actionText}成功`);
|
||||||
dialog.visible = false;
|
dialog.visible = false;
|
||||||
|
originalForm.value = null;
|
||||||
getList();
|
getList();
|
||||||
getOptionsList();
|
getOptionsList();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -1227,6 +1315,7 @@ const resetForm = () => {
|
|||||||
|
|
||||||
imageExternalUrl.value = '';
|
imageExternalUrl.value = '';
|
||||||
manualExternalUrl.value = '';
|
manualExternalUrl.value = '';
|
||||||
|
originalForm.value = null;
|
||||||
if (formRef.value) formRef.value.resetFields();
|
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 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 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|bmp)$/i.test(url) }
|
||||||
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp)$/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 beforeAvatarUpload = (rawFile: any) => {
|
||||||
const isTypeValid = ['image/jpeg', 'image/png', 'application/pdf'].includes(rawFile.type);
|
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) => {
|
const handlePreviewPicture = (uploadFile: any) => {
|
||||||
dialogImageUrl.value = uploadFile.url!;
|
const fileUrl = uploadFile.url || uploadFile.response?.url || '';
|
||||||
dialogVisibleImage.value = true
|
if (isImageFile(fileUrl)) {
|
||||||
|
dialogImageUrl.value = getImageUrl(fileUrl);
|
||||||
|
dialogVisibleImage.value = true;
|
||||||
|
} else {
|
||||||
|
window.open(getImageUrl(fileUrl), '_blank');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
|
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
|
||||||
@ -1528,6 +1635,16 @@ onMounted(() => {
|
|||||||
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
|
.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); }
|
.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) {
|
:deep(.warning-row-red) {
|
||||||
--el-table-tr-bg-color: #ffcdd2 !important;
|
--el-table-tr-bg-color: #ffcdd2 !important;
|
||||||
|
|||||||
@ -12,7 +12,7 @@ DB_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 2. Excel 文件路径
|
# 2. Excel 文件路径
|
||||||
EXCEL_FILE = 'product.template.xlsx'
|
EXCEL_FILE = '../product.template.xlsx'
|
||||||
|
|
||||||
|
|
||||||
def process_excel_to_db():
|
def process_excel_to_db():
|
||||||
Reference in New Issue
Block a user