Merge remote-tracking branch 'origin/3.0AI添加' into 3.0AI添加
This commit is contained in:
@ -20,6 +20,10 @@ onMounted(() => {
|
||||
if (userStore.token) {
|
||||
userStore.refreshUserPermissions()
|
||||
}
|
||||
// 当 Vue 根组件挂载完毕,确保 Dify 图标一定会被加载
|
||||
if (typeof (window as any).initDifyChatbot === 'function') {
|
||||
(window as any).initDifyChatbot()
|
||||
}
|
||||
})
|
||||
|
||||
// ================================================================
|
||||
@ -235,7 +239,7 @@ const handleLogout = () => {
|
||||
<footer v-if="!isLoginPage" class="app-footer">
|
||||
<span class="version-tag">
|
||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||
当前版本:V3.29(添加AI助手版)
|
||||
当前版本:V3.30(添加AI助手版)
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import request from '@/utils/request'
|
||||
/**
|
||||
* 上传文件通用接口
|
||||
* @param data File 对象 或 FormData 对象
|
||||
* 适配说明:list.vue 中 customUpload 已经封装了 FormData,所以这里支持直接传 FormData
|
||||
*/
|
||||
export function uploadFile(data: File | FormData) {
|
||||
let formData: FormData
|
||||
@ -11,14 +10,12 @@ export function uploadFile(data: File | FormData) {
|
||||
if (data instanceof FormData) {
|
||||
formData = data
|
||||
} else {
|
||||
// 如果传入的是原始 File 对象,则手动封装
|
||||
formData = new FormData()
|
||||
// @ts-ignore
|
||||
formData.append('file', data)
|
||||
}
|
||||
|
||||
return request({
|
||||
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
|
||||
url: '/v1/common/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
@ -29,13 +26,50 @@ export function uploadFile(data: File | FormData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件通用接口 (新增)
|
||||
* 删除文件通用接口
|
||||
* @param filename 文件名 (例如: a1b2c3d4.jpg)
|
||||
*/
|
||||
export function deleteFile(filename: string) {
|
||||
return request({
|
||||
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
|
||||
url: `/v1/common/files/${filename}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 以图搜图 API
|
||||
// ============================================================================
|
||||
|
||||
/** 以图搜图返回的物料项 */
|
||||
export interface ImageSearchItem {
|
||||
product_id: number
|
||||
product_name: string
|
||||
spec_model: string
|
||||
image_url: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
/** 以图搜图响应结构 */
|
||||
export interface ImageSearchResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: ImageSearchItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 以图搜图
|
||||
* @param file 图片文件 (File 对象或 Blob)
|
||||
*/
|
||||
export function imageSearch(file: File | Blob) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request<ImageSearchResponse>({
|
||||
url: '/v1/common/image-search',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
458
inventory-web/src/components/ImageSearchDialog.vue
Normal file
458
inventory-web/src/components/ImageSearchDialog.vue
Normal file
@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="以图搜图"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="image-search-body">
|
||||
<!-- 左侧:图片上传 -->
|
||||
<div class="upload-section">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="image-uploader"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:on-change="handleFileChange"
|
||||
>
|
||||
<div v-if="!previewUrl" class="upload-placeholder">
|
||||
<el-icon class="upload-icon" :size="48"><UploadFilled /></el-icon>
|
||||
<div class="upload-text">点击或拖拽图片上传</div>
|
||||
<div class="upload-hint">支持 jpg/png/gif 等格式</div>
|
||||
</div>
|
||||
<div v-else class="preview-wrapper">
|
||||
<img :src="previewUrl" class="preview-image" />
|
||||
<div class="preview-overlay">
|
||||
<el-button size="small" @click.stop="clearImage">重新选择</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="searching" class="loading-tip">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>正在识别图片并检索...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:搜索结果 -->
|
||||
<div class="result-section">
|
||||
<div v-if="!searched && !searching" class="result-empty">
|
||||
<el-icon :size="40" color="#c0c4cc"><Picture /></el-icon>
|
||||
<p>上传图片后自动检索</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searched && results.length === 0" class="result-empty">
|
||||
<el-icon :size="40" color="#c0c4cc"><WarningFilled /></el-icon>
|
||||
<p>未找到相似物料</p>
|
||||
<p class="result-hint">请尝试更换图片</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="result-list">
|
||||
<div
|
||||
v-for="(item, index) in results"
|
||||
:key="item.product_id"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="item-rank">{{ index + 1 }}</div>
|
||||
<div class="item-image">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="fullImageUrl(item.image_url)"
|
||||
@error="handleImgError($event)"
|
||||
/>
|
||||
<div v-else class="image-placeholder">
|
||||
<el-icon :size="24" color="#c0c4cc"><Picture /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ item.product_name || '未命名物料' }}</div>
|
||||
<div class="item-spec">{{ item.spec_model || '无规格' }}</div>
|
||||
<div class="item-similarity">
|
||||
<span class="similarity-label">相似度</span>
|
||||
<span class="similarity-value">{{ (item.similarity * 100).toFixed(2) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleUse(item)"
|
||||
>
|
||||
使用此物料
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleView(item)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { UploadFilled, Loading, Picture, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'use', item: ImageSearchItem): void
|
||||
(e: 'view', item: ImageSearchItem): void
|
||||
}>()
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const uploadRef = ref()
|
||||
const previewUrl = ref('')
|
||||
const currentFile = ref<File | null>(null)
|
||||
const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const results = ref<ImageSearchItem[]>([])
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (!val) {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleFileChange = (uploadFile: any) => {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
|
||||
// 校验格式
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
ElMessage.warning('仅支持 jpg/png/gif/webp/bmp 格式')
|
||||
return
|
||||
}
|
||||
|
||||
currentFile.value = file
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
|
||||
// 自动触发搜索
|
||||
doSearch(file)
|
||||
}
|
||||
|
||||
const doSearch = async (file: File) => {
|
||||
if (searching.value) return
|
||||
|
||||
searching.value = true
|
||||
searched.value = false
|
||||
results.value = []
|
||||
|
||||
try {
|
||||
const res = await imageSearch(file)
|
||||
if (res.code === 200) {
|
||||
results.value = res.data || []
|
||||
} else {
|
||||
ElMessage.error(res.msg || '检索失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('image search error:', err)
|
||||
ElMessage.error(err.message || '网络错误,请重试')
|
||||
} finally {
|
||||
searching.value = false
|
||||
searched.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
previewUrl.value = ''
|
||||
currentFile.value = null
|
||||
results.value = []
|
||||
searched.value = false
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
const fullImageUrl = (path: string) => {
|
||||
if (!path) return ''
|
||||
// 相对路径转完整 URL
|
||||
if (path.startsWith('http')) return path
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
|
||||
return baseUrl + path
|
||||
}
|
||||
|
||||
const handleImgError = (e: Event) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
const handleUse = (item: ImageSearchItem) => {
|
||||
emit('use', item)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleView = (item: ImageSearchItem) => {
|
||||
emit('view', item)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
previewUrl.value = ''
|
||||
currentFile.value = null
|
||||
searching.value = false
|
||||
searched.value = false
|
||||
results.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-search-body {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
/* ── 左侧上传区 ── */
|
||||
.upload-section {
|
||||
flex: 0 0 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
border: 2px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger:hover) {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-overlay .el-button {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.loading-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 右侧结果区 ── */
|
||||
.result-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-empty p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
font-size: 12px !important;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.item-rank {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
flex: 0 0 60px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.item-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-spec {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-similarity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.similarity-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.similarity-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@ -84,6 +84,9 @@
|
||||
|
||||
<el-button type="primary" plain @click="handleQuery">搜索</el-button>
|
||||
<el-button plain @click="resetQuery">重置</el-button>
|
||||
<el-button type="primary" plain @click="imageSearchVisible = true">
|
||||
<el-icon style="margin-right: 5px"><Picture /></el-icon>拍照识图
|
||||
</el-button>
|
||||
<el-popover
|
||||
v-model:visible="advancedFilterVisible"
|
||||
placement="bottom"
|
||||
@ -564,6 +567,12 @@
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 拍照识图弹窗 -->
|
||||
<ImageSearchDialog
|
||||
v-model="imageSearchVisible"
|
||||
@use="handleImageSearchUse"
|
||||
/>
|
||||
|
||||
<!-- 预警设置弹窗 -->
|
||||
<el-dialog v-model="warningDialog.visible" :title="warningDialog.title" width="500px" append-to-body destroy-on-close>
|
||||
<el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
|
||||
@ -633,7 +642,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
|
||||
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
@ -655,6 +664,8 @@ import {
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload';
|
||||
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
|
||||
import ImageSearchDialog from '@/components/ImageSearchDialog.vue';
|
||||
import { imageSearch as imageSearchApi, type ImageSearchItem } from '@/api/common/upload';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@ -716,6 +727,7 @@ const isUploading = ref(false);
|
||||
|
||||
const tableSize = ref<'large' | 'default' | 'small'>('large');
|
||||
const advancedFilterVisible = ref(false);
|
||||
const imageSearchVisible = ref(false);
|
||||
const advancedConditions = ref([{ field: '', operator: '', value: '' }]);
|
||||
const fieldOptions = computed(() => {
|
||||
const allFields = [
|
||||
@ -1585,15 +1597,8 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
|
||||
if (res.code === 200) {
|
||||
const newUrl = res.data.url
|
||||
form.value[targetField].push(newUrl)
|
||||
// 同步更新 fileList,触发 el-upload UI 刷新
|
||||
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
|
||||
if (targetField === 'generalImage') {
|
||||
fileListImage.value.push(fileObj)
|
||||
} else {
|
||||
fileListManual.value.push(fileObj)
|
||||
}
|
||||
ElMessage.success('上传成功')
|
||||
onSuccess(res)
|
||||
onSuccess(res) // el-upload v-model 自动更新 fileList,无需手动 push
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败');
|
||||
onError(new Error(res.msg))
|
||||
@ -1693,6 +1698,13 @@ const handleCameraConfirm = async (file: File) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 以图搜图 - 使用物料
|
||||
const handleImageSearchUse = (item: ImageSearchItem) => {
|
||||
// 跳转到该物料详情页,或填充到表单
|
||||
router.push({ path: '/material/list', query: { keyword: item.spec_model } });
|
||||
ElMessage.success(`已定位物料: ${item.product_name}`);
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
advancedConditions.value.push({ field: '', operator: '', value: '' });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user