版本变更V3.31添加识图功能
This commit is contained in:
@ -80,73 +80,81 @@ def image_search():
|
|||||||
SELECT id, name, spec_model, image_url,
|
SELECT id, name, spec_model, image_url,
|
||||||
(1 - (vec <=> :query_vector)) AS similarity
|
(1 - (vec <=> :query_vector)) AS similarity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT id,
|
-- 1. 基础物料表
|
||||||
COALESCE(name, '') AS name,
|
SELECT id, name, spec_model, product_image AS image_url, img_embedding AS vec
|
||||||
COALESCE(spec, '') AS spec_model,
|
|
||||||
COALESCE(product_image, '') AS image_url,
|
|
||||||
img_embedding AS vec
|
|
||||||
FROM material_base
|
FROM material_base
|
||||||
WHERE img_embedding IS NOT NULL
|
WHERE img_embedding IS NOT NULL
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT id,
|
-- 2. 采购入库表 (通过 base_id 关联拿真实物料)
|
||||||
'采购入库' AS name,
|
SELECT mb.id, mb.name, mb.spec_model, sb.arrival_photo AS image_url, sb.arrival_image_embedding AS vec
|
||||||
'到货照片' AS spec_model,
|
FROM stock_buy sb
|
||||||
COALESCE(arrival_photo, '') AS image_url,
|
JOIN material_base mb ON sb.base_id = mb.id
|
||||||
arrival_image_embedding AS vec
|
WHERE sb.arrival_image_embedding IS NOT NULL
|
||||||
FROM stock_buy
|
|
||||||
WHERE arrival_image_embedding IS NOT NULL
|
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT id,
|
-- 3. 半成品入库表
|
||||||
'采购入库' AS name,
|
SELECT mb.id, mb.name, mb.spec_model, ss.arrival_photo AS image_url, ss.arrival_image_embedding AS vec
|
||||||
'质检报告' AS spec_model,
|
FROM stock_semi ss
|
||||||
COALESCE(qc_report, '') AS image_url,
|
JOIN material_base mb ON ss.base_id = mb.id
|
||||||
qc_report_image_embedding AS vec
|
WHERE ss.arrival_image_embedding IS NOT NULL
|
||||||
FROM stock_buy
|
|
||||||
WHERE qc_report_image_embedding IS NOT NULL
|
UNION ALL
|
||||||
|
|
||||||
|
-- 4. 成品入库表
|
||||||
|
SELECT mb.id, mb.name, mb.spec_model, sp.product_photo AS image_url, sp.arrival_image_embedding AS vec
|
||||||
|
FROM stock_product sp
|
||||||
|
JOIN material_base mb ON sp.base_id = mb.id
|
||||||
|
WHERE sp.arrival_image_embedding IS NOT NULL
|
||||||
) AS combined
|
) AS combined
|
||||||
ORDER BY vec <=> :query_vector
|
ORDER BY vec <=> :query_vector LIMIT 10
|
||||||
LIMIT 10
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
result = db.session.execute(sql, {"query_vector": query_vector_str})
|
# 执行查询
|
||||||
rows = result.fetchall()
|
records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for row in rows:
|
seen_product_ids = set() # 【新增】用来记录已经添加过的物料 ID
|
||||||
item_id = row[0]
|
|
||||||
item_name = row[1] or ""
|
|
||||||
spec_model = row[2] or ""
|
|
||||||
raw_image = row[3]
|
|
||||||
|
|
||||||
# 解析图片 URL 列表,取第一张
|
for row in records:
|
||||||
image_url = ""
|
# 【新增】如果这个物料已经在这个列表里了,直接跳过它
|
||||||
if raw_image:
|
if row.id in seen_product_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 记录这个物料 ID,保证下次不会再重复添加
|
||||||
|
seen_product_ids.add(row.id)
|
||||||
|
|
||||||
|
# 1. 提取原始 URL
|
||||||
|
raw_url = row.image_url
|
||||||
|
clean_url = ""
|
||||||
|
|
||||||
|
if raw_url:
|
||||||
|
if raw_url.startswith('[') and raw_url.endswith(']'):
|
||||||
|
import json
|
||||||
try:
|
try:
|
||||||
image_list = json.loads(raw_image)
|
url_list = json.loads(raw_url)
|
||||||
if image_list and len(image_list) > 0:
|
clean_url = url_list[0] if url_list else ""
|
||||||
image_url = image_list[0]
|
except:
|
||||||
except Exception:
|
clean_url = raw_url
|
||||||
# 纯字符串直接使用
|
else:
|
||||||
image_url = str(raw_image)
|
clean_url = raw_url
|
||||||
|
|
||||||
|
# 2. 组装返回结果
|
||||||
results.append({
|
results.append({
|
||||||
"id": item_id,
|
"product_id": row.id,
|
||||||
"name": item_name,
|
"product_name": row.name,
|
||||||
"spec_model": spec_model,
|
"spec_model": row.spec_model,
|
||||||
"image_url": image_url,
|
"image_url": clean_url,
|
||||||
"similarity": round(float(row[4]), 4)
|
"similarity": round(float(row.similarity), 4)
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"✅ [ImageSearch] 跨表检索完成,命中 {len(results)} 条结果")
|
# 【新增】只要凑够了 10 个完全不同的物料,就立刻结束循环
|
||||||
return jsonify({
|
if len(results) >= 10:
|
||||||
"code": 200,
|
break
|
||||||
"msg": "检索成功",
|
|
||||||
"data": results
|
return jsonify({"code": 200, "data": results})
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ [ImageSearch] 数据库检索失败: {e}")
|
print(f"❌ [ImageSearch] 数据库检索失败: {e}")
|
||||||
|
|||||||
@ -1048,14 +1048,15 @@ class MaterialBaseService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_latest_specs():
|
def get_latest_specs():
|
||||||
"""
|
"""
|
||||||
获取所有规格型号的最大连号,按连续区间分组返回
|
获取所有规格型号的分组统计,按规则聚合后返回
|
||||||
- 前缀统一大写处理
|
- 前缀统一大写处理
|
||||||
- 只有数字完全连续(N, N+1, N+2...)才认定为同一组
|
- 匹配模式:(前缀)(单数字二级分类位)(纯数字部分),如 OPT12046 -> OPT, 1, 2046
|
||||||
- 数字不连续时断开,形成新组
|
- OPT 系列:使用 前缀+二级分类位 作为分组 Key,如 OPT1, OPT2
|
||||||
- 按每组数量降序排列
|
- 其他前缀:直接使用前缀作为分组 Key
|
||||||
- 返回每个连续区间的最大值
|
- 返回每个分组的数量、最大号、完整规格名
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
# 1. 查询所有不为空的规格型号
|
# 1. 查询所有不为空的规格型号
|
||||||
specs = MaterialBase.query.filter(
|
specs = MaterialBase.query.filter(
|
||||||
@ -1063,8 +1064,8 @@ class MaterialBaseService:
|
|||||||
MaterialBase.spec_model != ''
|
MaterialBase.spec_model != ''
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# 2. 解析并收集所有有效的 (prefix, num, original_spec)
|
# 2. 按分组收集所有数字
|
||||||
parsed = []
|
groups = defaultdict(list)
|
||||||
|
|
||||||
for material in specs:
|
for material in specs:
|
||||||
spec = material.spec_model
|
spec = material.spec_model
|
||||||
@ -1072,72 +1073,31 @@ class MaterialBaseService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
base_spec = spec.split('/')[0]
|
base_spec = spec.split('/')[0]
|
||||||
|
match = re.match(r'^([A-Za-z]+)(\d)(\d+)$', base_spec)
|
||||||
match = re.match(r'^([A-Za-z]+)(\d+)$', base_spec)
|
|
||||||
if not match:
|
if not match:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
prefix, num_str = match.groups()
|
prefix, sub_cat, num_str = match.groups()
|
||||||
prefix = prefix.upper()
|
prefix = prefix.upper()
|
||||||
num = int(num_str)
|
num = int(num_str)
|
||||||
|
|
||||||
parsed.append((prefix, num, spec))
|
# OPT 系列使用 前缀+单数字二级分类 作为 Key
|
||||||
|
key = f"{prefix}{sub_cat}" if prefix == 'OPT' else prefix
|
||||||
|
groups[key].append((num, spec))
|
||||||
|
|
||||||
# 3. 先按 prefix 升序,再按 num 升序排序
|
# 3. 生成展示用的统计数据
|
||||||
parsed.sort(key=lambda x: (x[0], x[1]))
|
|
||||||
|
|
||||||
# 4. 遍历切分连续区间
|
|
||||||
# 核心逻辑:当 current_num != prev_num + 1 时,断开形成新组
|
|
||||||
intervals = []
|
|
||||||
current_prefix = None
|
|
||||||
current_start = None
|
|
||||||
current_end = None
|
|
||||||
current_last_spec = None
|
|
||||||
|
|
||||||
for prefix, num, spec in parsed:
|
|
||||||
if current_prefix is None:
|
|
||||||
current_prefix = prefix
|
|
||||||
current_start = num
|
|
||||||
current_end = num
|
|
||||||
current_last_spec = spec
|
|
||||||
elif prefix == current_prefix and num == current_end + 1:
|
|
||||||
current_end = num
|
|
||||||
current_last_spec = spec
|
|
||||||
else:
|
|
||||||
intervals.append({
|
|
||||||
'prefix': current_prefix,
|
|
||||||
'start': current_start,
|
|
||||||
'end': current_end,
|
|
||||||
'count': current_end - current_start + 1,
|
|
||||||
'latest': current_last_spec
|
|
||||||
})
|
|
||||||
current_prefix = prefix
|
|
||||||
current_start = num
|
|
||||||
current_end = num
|
|
||||||
current_last_spec = spec
|
|
||||||
|
|
||||||
if current_prefix is not None:
|
|
||||||
intervals.append({
|
|
||||||
'prefix': current_prefix,
|
|
||||||
'start': current_start,
|
|
||||||
'end': current_end,
|
|
||||||
'count': current_end - current_start + 1,
|
|
||||||
'latest': current_last_spec
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. 按每组数量降序排列,再按前缀升序
|
|
||||||
intervals.sort(key=lambda x: (-x['count'], x['prefix']))
|
|
||||||
|
|
||||||
# 6. 构建返回结果
|
|
||||||
result = []
|
result = []
|
||||||
for item in intervals:
|
for key, items in groups.items():
|
||||||
prefix = item['prefix']
|
sorted_items = sorted(items, key=lambda x: x[0])
|
||||||
start = item['start']
|
max_num, max_spec = sorted_items[-1]
|
||||||
end = item['end']
|
|
||||||
result.append({
|
result.append({
|
||||||
"group": f"{prefix}({start}-{end})",
|
'group': key,
|
||||||
"count": item['count'],
|
'count': len(sorted_items),
|
||||||
"latest": item['latest']
|
'latest': max_spec,
|
||||||
|
'max_num': max_num
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 4. 按数量降序,再按分组名升序排列
|
||||||
|
result.sort(key=lambda x: (-x['count'], x['group']))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@ -26,3 +26,5 @@ openpyxl>=3.1.2
|
|||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
# [新增] 时区处理 (APScheduler 需要)
|
# [新增] 时区处理 (APScheduler 需要)
|
||||||
pytz
|
pytz
|
||||||
|
# [新增] 进度条库 (脚本和任务所需)
|
||||||
|
tqdm>=4.66.0
|
||||||
@ -26,15 +26,26 @@ from app.extensions import db
|
|||||||
from app.utils.ai_vision import get_image_embedding, load_clip_model
|
from app.utils.ai_vision import get_image_embedding, load_clip_model
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 业务配置:表 → 图片字段 → 向量字段 映射
|
# 业务配置:表 → 图片字段 → 向量字段 映射 (已全面修复)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
TARGET_TABLES = [
|
TARGET_TABLES = [
|
||||||
# 基础物料
|
# 1. 基础物料
|
||||||
{"table": "material_base", "img_col": "product_image", "vec_col": "img_embedding"},
|
{"table": "material_base", "img_col": "product_image", "vec_col": "img_embedding"},
|
||||||
|
|
||||||
# 采购入库
|
# 2. 采购入库
|
||||||
{"table": "stock_buy", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
|
{"table": "stock_buy", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
|
||||||
{"table": "stock_buy", "img_col": "qc_report", "vec_col": "qc_report_image_embedding"},
|
{"table": "stock_buy", "img_col": "inspection_report", "vec_col": "qc_report_image_embedding"}, # 已修复: qc_report -> inspection_report
|
||||||
|
|
||||||
|
# 3. 半成品入库 (新增)
|
||||||
|
{"table": "stock_semi", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
|
||||||
|
{"table": "stock_semi", "img_col": "quality_report_link", "vec_col": "qc_report_image_embedding"},
|
||||||
|
|
||||||
|
# 4. 成品入库 (新增)
|
||||||
|
{"table": "stock_product", "img_col": "product_photo", "vec_col": "arrival_image_embedding"},
|
||||||
|
{"table": "stock_product", "img_col": "quality_report_link", "vec_col": "qc_report_image_embedding"}
|
||||||
|
|
||||||
|
# 注意:成品入库表还有一个 inspection_report_link,但由于数据库中成品表目前只加了两个向量字段,
|
||||||
|
# 暂不将该字段加入遍历,以免覆盖 quality_report_link 的特征。
|
||||||
]
|
]
|
||||||
|
|
||||||
# 物理图片根目录(相对于 app 目录的相对路径 ../uploads/)
|
# 物理图片根目录(相对于 app 目录的相对路径 ../uploads/)
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.30(添加AI助手版)
|
当前版本:V3.31(识图版)
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
title="以图搜图"
|
title="以图搜图"
|
||||||
width="680px"
|
width="95%"
|
||||||
|
style="max-width: 680px;"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
@ -13,21 +14,21 @@
|
|||||||
<el-upload
|
<el-upload
|
||||||
ref="uploadRef"
|
ref="uploadRef"
|
||||||
class="image-uploader"
|
class="image-uploader"
|
||||||
drag
|
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
:on-change="handleFileChange"
|
:on-change="handleFileChange"
|
||||||
>
|
>
|
||||||
<div v-if="!previewUrl" class="upload-placeholder">
|
<div v-if="!previewUrl" class="upload-placeholder">
|
||||||
<el-icon class="upload-icon" :size="48"><UploadFilled /></el-icon>
|
<el-icon class="upload-icon" :size="48"><Camera /></el-icon>
|
||||||
<div class="upload-text">点击或拖拽图片上传</div>
|
<div class="upload-text">点击拍照或选择图片</div>
|
||||||
<div class="upload-hint">支持 jpg/png/gif 等格式</div>
|
<div class="upload-hint">支持 jpg/png 格式</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="preview-wrapper">
|
<div v-else class="preview-wrapper">
|
||||||
<img :src="previewUrl" class="preview-image" />
|
<img :src="previewUrl" class="preview-image" />
|
||||||
<div class="preview-overlay">
|
<div class="preview-overlay">
|
||||||
<el-button size="small" @click.stop="clearImage">重新选择</el-button>
|
<el-button size="small" @click.stop="clearImage">重新拍照</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
@ -79,12 +80,6 @@
|
|||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
|
||||||
@click="handleUse(item)"
|
|
||||||
>
|
|
||||||
使用此物料
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleView(item)"
|
@click="handleView(item)"
|
||||||
>
|
>
|
||||||
@ -104,7 +99,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { UploadFilled, Loading, Picture, WarningFilled } from '@element-plus/icons-vue'
|
import { Camera, Loading, Picture, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
|
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
|
||||||
|
|
||||||
@ -189,11 +184,9 @@ const clearImage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullImageUrl = (path: string) => {
|
const fullImageUrl = (path: string) => {
|
||||||
if (!path) return ''
|
if (!path) return '';
|
||||||
// 相对路径转完整 URL
|
// 直接原样返回,完全信任后端传过来的 image_url
|
||||||
if (path.startsWith('http')) return path
|
return path.startsWith('http') ? path : path;
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
|
|
||||||
return baseUrl + path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImgError = (e: Event) => {
|
const handleImgError = (e: Event) => {
|
||||||
@ -455,4 +448,33 @@ const resetState = () => {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 新增:移动端适配样式 ---- */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.image-search-body {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload), :deep(.el-upload-dragger) {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-height: 140px;
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -571,6 +571,7 @@
|
|||||||
<ImageSearchDialog
|
<ImageSearchDialog
|
||||||
v-model="imageSearchVisible"
|
v-model="imageSearchVisible"
|
||||||
@use="handleImageSearchUse"
|
@use="handleImageSearchUse"
|
||||||
|
@view="handleImageSearchView"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 预警设置弹窗 -->
|
<!-- 预警设置弹窗 -->
|
||||||
@ -1698,11 +1699,19 @@ const handleCameraConfirm = async (file: File) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 以图搜图 - 使用物料
|
// 点击"查看详情":直接在当前页面应用规格型号并搜索
|
||||||
const handleImageSearchUse = (item: ImageSearchItem) => {
|
const handleImageSearchView = (item: any) => {
|
||||||
// 跳转到该物料详情页,或填充到表单
|
// 1. 关闭以图搜图弹窗
|
||||||
router.push({ path: '/material/list', query: { keyword: item.spec_model } });
|
imageSearchVisible.value = false;
|
||||||
ElMessage.success(`已定位物料: ${item.product_name}`);
|
|
||||||
|
// 2. 将选中的规格型号填入搜索表单的 keyword 中
|
||||||
|
queryParams.keyword = item.spec_model;
|
||||||
|
|
||||||
|
// 3. 触发列表的查询函数,刷新表格数据
|
||||||
|
handleQuery();
|
||||||
|
|
||||||
|
// 4. 给出友好提示
|
||||||
|
ElMessage.success(`已应用物料规格: ${item.spec_model} 进行搜索`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCondition = () => {
|
const addCondition = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user