feat: 将产品图/说明书备注字段从JSON数组中分离为独立数据库列

- 数据库新增 product_image_remark 和 manual_link_remark 两个TEXT列
- 备注文字不再与上传文件URL混存在 generalImage/generalManual JSON数组中
- 前端输入框改为textarea,绑定独立的备注字段
- 修复 handlePasteLink 忽略field参数的bug
- 新增 isInternalFile 辅助函数,用于区分上传文件与纯文本
- 同步修改 list.vue 和 buyOdoo.vue 两个页面
This commit is contained in:
yueli
2026-07-10 10:52:23 +08:00
parent 4fb5a96c4c
commit ffafa635bb
4 changed files with 61 additions and 62 deletions

View File

@ -29,6 +29,10 @@ class MaterialBase(db.Model):
manual_link = db.Column(db.Text, comment='通用说明书') manual_link = db.Column(db.Text, comment='通用说明书')
product_image = db.Column(db.Text, comment='通用产品图') product_image = db.Column(db.Text, comment='通用产品图')
# 备注字段(纯文本,与文件 URL 分离存储)
product_image_remark = db.Column(db.Text, default='', comment='产品图备注')
manual_link_remark = db.Column(db.Text, default='', comment='说明书备注')
# 启用状态 # 启用状态
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用') is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
@ -86,6 +90,8 @@ class MaterialBase(db.Model):
'visibilityLevel': self.visibility_level, 'visibilityLevel': self.visibility_level,
'generalManual': parse_list(self.manual_link), 'generalManual': parse_list(self.manual_link),
'generalImage': parse_list(self.product_image), 'generalImage': parse_list(self.product_image),
'productImageRemark': self.product_image_remark or '',
'manualLinkRemark': self.manual_link_remark or '',
# 【核心修改】:直接返回布尔值,不再转成 1 或 0 # 【核心修改】:直接返回布尔值,不再转成 1 或 0
'isEnabled': bool(self.is_enabled), 'isEnabled': bool(self.is_enabled),
# 强制质检标记 # 强制质检标记

View File

@ -580,6 +580,8 @@ class MaterialBaseService:
visibility_level=data.get('visibilityLevel'), visibility_level=data.get('visibilityLevel'),
manual_link=json.dumps(data.get('generalManual', [])), manual_link=json.dumps(data.get('generalManual', [])),
product_image=json.dumps(data.get('generalImage', [])), product_image=json.dumps(data.get('generalImage', [])),
product_image_remark=data.get('productImageRemark', ''),
manual_link_remark=data.get('manualLinkRemark', ''),
is_enabled=is_enabled_val is_enabled=is_enabled_val
) )
db.session.add(new_material) db.session.add(new_material)
@ -623,6 +625,9 @@ class MaterialBaseService:
if 'unit' in data: material.unit = data['unit'] if 'unit' in data: material.unit = data['unit']
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel'] if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
if 'productImageRemark' in data: material.product_image_remark = data['productImageRemark']
if 'manualLinkRemark' in data: material.manual_link_remark = data['manualLinkRemark']
if 'generalManual' in data: if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual']) material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data: if 'generalImage' in data:

View File

@ -510,13 +510,13 @@
</div> </div>
</div> </div>
<el-input <el-input
v-model="imageExternalUrl" v-model="form.productImageRemark"
placeholder="如有外部图片链接请在此输入" type="textarea"
:rows="1"
placeholder="请输入产品图备注信息"
style="margin-top: 8px;" style="margin-top: 8px;"
clearable clearable
> />
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div> <div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
@ -564,13 +564,13 @@
</div> </div>
</div> </div>
<el-input <el-input
v-model="manualExternalUrl" v-model="form.manualLinkRemark"
placeholder="如有外部说明书链接请在此输入" type="textarea"
:rows="1"
placeholder="请输入说明书备注信息"
style="margin-top: 8px;" style="margin-top: 8px;"
clearable clearable
> />
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div> <div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
@ -674,7 +674,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'; import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened } from '@element-plus/icons-vue'; import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened } 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';
@ -786,8 +786,6 @@ const operatorOptions = ref([
const fileListImage = ref<any[]>([]); const fileListImage = ref<any[]>([]);
const fileListManual = ref<any[]>([]); const fileListManual = ref<any[]>([]);
const imageExternalUrl = ref('');
const manualExternalUrl = ref('');
const dialogVisibleImage = ref(false); const dialogVisibleImage = ref(false);
const dialogImageUrl = ref(''); const dialogImageUrl = ref('');
const cameraDialogVisible = ref(false); const cameraDialogVisible = ref(false);
@ -1025,7 +1023,8 @@ const dialog = reactive({ visible: false, title: '' });
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const initForm = { const initForm = {
id: undefined, companyName: '', name: '', commonName: '', category: '', type: '', spec: '', unit: '', id: undefined, companyName: '', name: '', commonName: '', category: '', type: '', spec: '', unit: '',
visibilityLevel: 0, generalManual: [] as string[], generalImage: [] as string[], isEnabled: true visibilityLevel: 0, generalManual: [] as string[], generalImage: [] as string[], isEnabled: true,
productImageRemark: '', manualLinkRemark: ''
}; };
const form = ref({...initForm}); const form = ref({...initForm});
@ -1176,13 +1175,12 @@ const handleEdit = (row: MaterialBaseVO) => {
} else { tempCategoryPrefix.value = []; tempCategorySuffix.value = ''; } } else { tempCategoryPrefix.value = []; tempCategorySuffix.value = ''; }
const images = row.generalImage || []; const manuals = row.generalManual || []; const images = row.generalImage || []; const manuals = row.generalManual || [];
const imgFiles = images.filter(u => !isExternalLink(u)); const imgLinks = images.filter(u => isExternalLink(u)); // 只显示真正的上传文件,备注字段已由 Object.assign 自动回显
const manualFiles = manuals.filter(u => !isExternalLink(u)); const manualLinks = manuals.filter(u => isExternalLink(u)); const imgFiles = images.filter(u => isInternalFile(u));
const manualFiles = manuals.filter(u => isInternalFile(u));
fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })); fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
imageExternalUrl.value = imgLinks.length > 0 ? imgLinks[0] : '';
fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })); fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
manualExternalUrl.value = manualLinks.length > 0 ? manualLinks[0] : '';
}); });
}; };
@ -1210,7 +1208,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
const buildPartialPayload = (current: any, original: any): any => { const buildPartialPayload = (current: any, original: any): any => {
const payload: any = { id: current.id }; const payload: any = { id: current.id };
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName']; const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
for (const key of compareFields) { for (const key of compareFields) {
const currentVal = current[key]; const originalVal = original[key]; const currentVal = current[key]; const originalVal = original[key];
if (Array.isArray(currentVal) && Array.isArray(originalVal)) { if (!isArraysEqual(currentVal, originalVal)) payload[key] = currentVal; } if (Array.isArray(currentVal) && Array.isArray(originalVal)) { if (!isArraysEqual(currentVal, originalVal)) payload[key] = currentVal; }
@ -1228,10 +1226,9 @@ const submitForm = async () => {
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec); const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) { submitLoading.value = false; return; } if (isDuplicate) { submitLoading.value = false; return; }
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item)); // 只保留上传文件,过滤掉旧数据中混入的备注文字和外部链接
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value); const finalImageList = form.value.generalImage.filter(item => isInternalFile(item));
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item)); const finalManualList = form.value.generalManual.filter(item => isInternalFile(item));
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value);
const prefixStr = tempCategoryPrefix.value.join('/'); const suffixStr = tempCategorySuffix.value.trim(); const prefixStr = tempCategoryPrefix.value.join('/'); const suffixStr = tempCategorySuffix.value.trim();
let fullCategory = ''; let fullCategory = '';
@ -1272,7 +1269,7 @@ const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm)); form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = []; fileListManual.value = []; fileListImage.value = []; fileListManual.value = [];
tempCategoryPrefix.value = []; tempCategorySuffix.value = ''; tempCategoryPrefix.value = []; tempCategorySuffix.value = '';
imageExternalUrl.value = ''; manualExternalUrl.value = ''; originalForm.value = null; originalForm.value = null;
if (formRef.value) formRef.value.resetFields(); if (formRef.value) formRef.value.resetFields();
}; };
@ -1337,6 +1334,7 @@ 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 isInternalFile = (str: string) => { return str && (str.includes('/api/v1/common/files') || /\.(jpg|jpeg|png|gif|webp|bmp|pdf|zip|rar|7z)$/i.test(str)) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) } const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) } const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) } const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
@ -1378,9 +1376,9 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
} catch (e) { ElMessage.error('网络错误'); onError(e) } finally { isUploading.value = false } } catch (e) { ElMessage.error('网络错误'); onError(e) } finally { isUploading.value = false }
} }
const handlePasteLink = (link: string, field: string) => { imageExternalUrl.value = link } // 粘贴上传处理器(仅处理实际图片文件,不再将链接自动填入备注框)
usePasteUpload(customUpload, 'generalImage', '#upload-generalImage', handlePasteLink) usePasteUpload(customUpload, 'generalImage', '#upload-generalImage')
usePasteUpload(customUpload, 'generalManual', '#upload-generalManual', handlePasteLink) usePasteUpload(customUpload, 'generalManual', '#upload-generalManual')
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => { const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件' const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件'

View File

@ -505,13 +505,13 @@
</div> </div>
</div> </div>
<el-input <el-input
v-model="imageExternalUrl" v-model="form.productImageRemark"
placeholder="如有外部图片链接请在此输入" type="textarea"
:rows="1"
placeholder="请输入产品图备注信息"
style="margin-top: 8px;" style="margin-top: 8px;"
clearable clearable
> />
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div> <div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
@ -559,13 +559,13 @@
</div> </div>
</div> </div>
<el-input <el-input
v-model="manualExternalUrl" v-model="form.manualLinkRemark"
placeholder="如有外部说明书链接请在此输入" type="textarea"
:rows="1"
placeholder="请输入说明书备注信息"
style="margin-top: 8px;" style="margin-top: 8px;"
clearable clearable
> />
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div> <div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
@ -672,7 +672,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'; import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } from '@element-plus/icons-vue'; import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } 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';
@ -787,8 +787,6 @@ const operatorOptions = ref([
// 文件上传相关 // 文件上传相关
const fileListImage = ref<any[]>([]); const fileListImage = ref<any[]>([]);
const fileListManual = ref<any[]>([]); const fileListManual = ref<any[]>([]);
const imageExternalUrl = ref('');
const manualExternalUrl = ref('');
const dialogVisibleImage = ref(false); const dialogVisibleImage = ref(false);
const dialogImageUrl = ref(''); const dialogImageUrl = ref('');
const cameraDialogVisible = ref(false); const cameraDialogVisible = ref(false);
@ -1101,7 +1099,9 @@ const initForm = {
visibilityLevel: 0, visibilityLevel: 0,
generalManual: [] as string[], generalManual: [] as string[],
generalImage: [] as string[], generalImage: [] as string[],
isEnabled: true // 已修改为默认 true isEnabled: true, // 已修改为默认 true
productImageRemark: '',
manualLinkRemark: '',
}; };
const form = ref({...initForm}); const form = ref({...initForm});
@ -1357,16 +1357,12 @@ const handleEdit = (row: MaterialBaseVO) => {
const images = row.generalImage || []; const images = row.generalImage || [];
const manuals = row.generalManual || []; const manuals = row.generalManual || [];
const imgFiles = images.filter(u => !isExternalLink(u)); // 只显示真正的上传文件,备注字段已由 Object.assign 自动回显到 form.productImageRemark / form.manualLinkRemark
const imgLinks = images.filter(u => isExternalLink(u)); const imgFiles = images.filter(u => isInternalFile(u));
const manualFiles = manuals.filter(u => !isExternalLink(u)); const manualFiles = manuals.filter(u => isInternalFile(u));
const manualLinks = manuals.filter(u => isExternalLink(u));
fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })); fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
imageExternalUrl.value = imgLinks.length > 0 ? imgLinks[0] : '';
fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })); fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
manualExternalUrl.value = manualLinks.length > 0 ? manualLinks[0] : '';
}); });
}; };
@ -1414,7 +1410,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
const buildPartialPayload = (current: any, original: any): any => { const buildPartialPayload = (current: any, original: any): any => {
const payload: any = { id: current.id }; const payload: any = { id: current.id };
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName']; const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
for (const key of compareFields) { for (const key of compareFields) {
const currentVal = current[key]; const currentVal = current[key];
@ -1445,11 +1441,9 @@ const submitForm = async () => {
return; return;
} }
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item)); // 只保留上传文件,过滤掉旧数据中混入的备注文字和外部链接
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value); const finalImageList = form.value.generalImage.filter(item => isInternalFile(item));
const finalManualList = form.value.generalManual.filter(item => isInternalFile(item));
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item));
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value);
const prefixStr = tempCategoryPrefix.value.join('/'); const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim(); const suffixStr = tempCategorySuffix.value.trim();
@ -1535,8 +1529,6 @@ const resetForm = () => {
tempCategoryPrefix.value = []; tempCategoryPrefix.value = [];
tempCategorySuffix.value = ''; tempCategorySuffix.value = '';
imageExternalUrl.value = '';
manualExternalUrl.value = '';
originalForm.value = null; originalForm.value = null;
if (formRef.value) formRef.value.resetFields(); if (formRef.value) formRef.value.resetFields();
}; };
@ -1690,6 +1682,7 @@ 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 isInternalFile = (str: string) => { return str && (str.includes('/api/v1/common/files') || /\.(jpg|jpeg|png|gif|webp|bmp|pdf|zip|rar|7z)$/i.test(str)) }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) } const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) } const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) } const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
@ -1766,12 +1759,9 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
finally { isUploading.value = false } finally { isUploading.value = false }
} }
// 粘贴上传处理器(PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片 // 粘贴上传处理器(仅处理实际图片文件,不再将链接自动填入备注框
const handlePasteLink = (link: string, field: string) => { usePasteUpload(customUpload, 'generalImage', '#upload-generalImage')
imageExternalUrl.value = link usePasteUpload(customUpload, 'generalManual', '#upload-generalManual')
}
usePasteUpload(customUpload, 'generalImage', '#upload-generalImage', handlePasteLink)
usePasteUpload(customUpload, 'generalManual', '#upload-generalManual', handlePasteLink)
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => { const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件' const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件'