feat: 以图搜图返回 business_data 包含 name/spec_model/url,支持详情页跳转
This commit is contained in:
@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索
|
以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索
|
||||||
|
数据源:image_embeddings 表(统一向量存储)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -10,6 +11,10 @@ from flask import Blueprint, request, jsonify
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.utils.ai_vision import load_clip_model, get_image_embedding
|
from app.utils.ai_vision import load_clip_model, get_image_embedding
|
||||||
|
from app.models.inbound.buy import StockBuy
|
||||||
|
from app.models.inbound.semi import StockSemi
|
||||||
|
from app.models.inbound.product import StockProduct
|
||||||
|
from app.models.base import MaterialBase
|
||||||
|
|
||||||
# 注册蓝图
|
# 注册蓝图
|
||||||
image_search_bp = Blueprint('image_search', __name__)
|
image_search_bp = Blueprint('image_search', __name__)
|
||||||
@ -71,88 +76,158 @@ def image_search():
|
|||||||
print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}")
|
print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}")
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# 5. pgvector 余弦相似度检索(跨表联合检索)
|
# 5. pgvector 余弦相似度检索(统一查 image_embeddings 表)
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
try:
|
try:
|
||||||
query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']'
|
query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']'
|
||||||
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
SELECT id, name, spec_model, image_url,
|
SELECT
|
||||||
(1 - (vec <=> :query_vector)) AS similarity
|
ie.id AS embedding_id,
|
||||||
FROM (
|
ie.module_name,
|
||||||
-- 1. 基础物料表
|
ie.target_id,
|
||||||
SELECT id, name, spec_model, product_image AS image_url, img_embedding AS vec
|
ie.image_url,
|
||||||
FROM material_base
|
(1 - (ie.embedding <=> :query_vector)) AS similarity
|
||||||
WHERE img_embedding IS NOT NULL
|
FROM image_embeddings ie
|
||||||
|
WHERE ie.embedding IS NOT NULL
|
||||||
UNION ALL
|
ORDER BY ie.embedding <=> :query_vector
|
||||||
|
LIMIT 200
|
||||||
-- 2. 采购入库表 (通过 base_id 关联拿真实物料信息)
|
|
||||||
SELECT mb.id, mb.name, mb.spec_model, sb.arrival_photo AS image_url, sb.arrival_image_embedding AS vec
|
|
||||||
FROM stock_buy sb
|
|
||||||
JOIN material_base mb ON sb.base_id = mb.id
|
|
||||||
WHERE sb.arrival_image_embedding IS NOT NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
-- 3. 半成品入库表 (通过 base_id 关联拿真实物料信息)
|
|
||||||
SELECT mb.id, mb.name, mb.spec_model, ss.arrival_photo AS image_url, ss.arrival_image_embedding AS vec
|
|
||||||
FROM stock_semi ss
|
|
||||||
JOIN material_base mb ON ss.base_id = mb.id
|
|
||||||
WHERE ss.arrival_image_embedding IS NOT NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
-- 4. 成品入库表 (通过 base_id 关联拿真实物料信息)
|
|
||||||
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
|
|
||||||
|
|
||||||
-- 核心:计算余弦距离并排序,取最接近的前 50 个!
|
|
||||||
ORDER BY vec <=> :query_vector LIMIT 50
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# 执行查询
|
raw_records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall()
|
||||||
records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall()
|
if not raw_records:
|
||||||
|
return jsonify({"code": 200, "data": []})
|
||||||
|
|
||||||
|
# 按 (module_name, target_id) 去重,每业务记录只保留最相似的那张图
|
||||||
|
seen = {}
|
||||||
|
for row in raw_records:
|
||||||
|
key = (row.module_name, row.target_id)
|
||||||
|
if key not in seen:
|
||||||
|
seen[key] = row
|
||||||
|
|
||||||
|
# 批量回填业务数据
|
||||||
|
target_ids_by_module = {}
|
||||||
|
for row in seen.values():
|
||||||
|
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
|
||||||
|
|
||||||
|
business_map = {}
|
||||||
|
|
||||||
|
# 回填 StockBuy
|
||||||
|
if 'stock_buy' in target_ids_by_module:
|
||||||
|
ids = target_ids_by_module['stock_buy']
|
||||||
|
records = (
|
||||||
|
db.session.query(StockBuy)
|
||||||
|
.filter(StockBuy.id.in_(ids))
|
||||||
|
.outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for r in records:
|
||||||
|
business_map[('stock_buy', r.id)] = {
|
||||||
|
'record_id': r.id,
|
||||||
|
'name': r.base.name if r.base else None,
|
||||||
|
'spec_model': r.base.spec_model if r.base else None,
|
||||||
|
'sku': r.sku,
|
||||||
|
'barcode': r.barcode,
|
||||||
|
'serial_number': r.serial_number,
|
||||||
|
'batch_number': r.batch_number,
|
||||||
|
'status': r.status,
|
||||||
|
'warehouse_location': r.warehouse_location,
|
||||||
|
'stock_quantity': r.stock_quantity,
|
||||||
|
'module_name': 'stock_buy',
|
||||||
|
'url': '/inventory/buy',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 回填 StockSemi
|
||||||
|
if 'stock_semi' in target_ids_by_module:
|
||||||
|
ids = target_ids_by_module['stock_semi']
|
||||||
|
records = (
|
||||||
|
db.session.query(StockSemi)
|
||||||
|
.filter(StockSemi.id.in_(ids))
|
||||||
|
.outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for r in records:
|
||||||
|
business_map[('stock_semi', r.id)] = {
|
||||||
|
'record_id': r.id,
|
||||||
|
'name': r.base.name if r.base else None,
|
||||||
|
'spec_model': r.base.spec_model if r.base else None,
|
||||||
|
'sku': r.sku,
|
||||||
|
'barcode': r.barcode,
|
||||||
|
'serial_number': r.serial_number,
|
||||||
|
'batch_number': r.batch_number,
|
||||||
|
'status': r.status,
|
||||||
|
'warehouse_location': r.warehouse_location,
|
||||||
|
'stock_quantity': r.stock_quantity,
|
||||||
|
'module_name': 'stock_semi',
|
||||||
|
'url': '/inventory/semi',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 回填 StockProduct
|
||||||
|
if 'stock_product' in target_ids_by_module:
|
||||||
|
ids = target_ids_by_module['stock_product']
|
||||||
|
records = (
|
||||||
|
db.session.query(StockProduct)
|
||||||
|
.filter(StockProduct.id.in_(ids))
|
||||||
|
.outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for r in records:
|
||||||
|
business_map[('stock_product', r.id)] = {
|
||||||
|
'record_id': r.id,
|
||||||
|
'name': r.base.name if r.base else None,
|
||||||
|
'spec_model': r.base.spec_model if r.base else None,
|
||||||
|
'sku': r.sku,
|
||||||
|
'barcode': r.barcode,
|
||||||
|
'serial_number': r.serial_number,
|
||||||
|
'batch_number': r.batch_number,
|
||||||
|
'status': r.status,
|
||||||
|
'warehouse_location': r.warehouse_location,
|
||||||
|
'stock_quantity': r.stock_quantity,
|
||||||
|
'sale_price': r.sale_price,
|
||||||
|
'module_name': 'stock_product',
|
||||||
|
'url': '/inventory/product',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 回填 MaterialBase
|
||||||
|
if 'material_base' in target_ids_by_module:
|
||||||
|
ids = target_ids_by_module['material_base']
|
||||||
|
records = MaterialBase.query.filter(MaterialBase.id.in_(ids)).all()
|
||||||
|
for r in records:
|
||||||
|
business_map[('material_base', r.id)] = {
|
||||||
|
'record_id': r.id,
|
||||||
|
'name': r.name,
|
||||||
|
'spec_model': r.spec_model,
|
||||||
|
'common_name': r.common_name,
|
||||||
|
'category': r.category,
|
||||||
|
'material_type': r.material_type,
|
||||||
|
'unit': r.unit,
|
||||||
|
'module_name': 'material_base',
|
||||||
|
'url': '/material/index',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 组装最终返回
|
||||||
results = []
|
results = []
|
||||||
seen_product_ids = set() # 【新增】用来记录已经添加过的物料 ID
|
for row in seen.values():
|
||||||
|
key = (row.module_name, row.target_id)
|
||||||
for row in records:
|
biz = business_map.get(key, {})
|
||||||
# 【新增】如果这个物料已经在这个列表里了,直接跳过它
|
raw_url = row.image_url or ''
|
||||||
if row.id in seen_product_ids:
|
clean_url = raw_url
|
||||||
continue
|
if raw_url.startswith('['):
|
||||||
|
try:
|
||||||
# 记录这个物料 ID,保证下次不会再重复添加
|
url_list = json.loads(raw_url)
|
||||||
seen_product_ids.add(row.id)
|
clean_url = url_list[0] if url_list else ''
|
||||||
|
except:
|
||||||
# 1. 提取原始 URL
|
pass
|
||||||
raw_url = row.image_url
|
|
||||||
clean_url = ""
|
|
||||||
|
|
||||||
if raw_url:
|
|
||||||
if raw_url.startswith('[') and raw_url.endswith(']'):
|
|
||||||
import json
|
|
||||||
try:
|
|
||||||
url_list = json.loads(raw_url)
|
|
||||||
clean_url = url_list[0] if url_list else ""
|
|
||||||
except:
|
|
||||||
clean_url = raw_url
|
|
||||||
else:
|
|
||||||
clean_url = raw_url
|
|
||||||
|
|
||||||
# 2. 组装返回结果
|
|
||||||
results.append({
|
results.append({
|
||||||
"product_id": row.id,
|
"module_name": row.module_name,
|
||||||
"product_name": row.name,
|
"target_id": row.target_id,
|
||||||
"spec_model": row.spec_model,
|
|
||||||
"image_url": clean_url,
|
"image_url": clean_url,
|
||||||
"similarity": round(float(row.similarity), 4)
|
"similarity": round(float(row.similarity), 4),
|
||||||
|
"product_name": biz.get('name') or biz.get('material_name') or '未命名物料',
|
||||||
|
"product_id": row.target_id,
|
||||||
|
"spec_model": biz.get('spec_model') or '',
|
||||||
|
"business_data": biz,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 修改后:只要凑够了 10 个完全不同的物料,就立刻结束循环
|
|
||||||
if len(results) >= 10:
|
if len(results) >= 10:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,27 @@ export interface ImageSearchItem {
|
|||||||
spec_model: string
|
spec_model: string
|
||||||
image_url: string
|
image_url: string
|
||||||
similarity: number
|
similarity: number
|
||||||
|
module_name: string
|
||||||
|
target_id: number
|
||||||
|
business_data: {
|
||||||
|
record_id: number
|
||||||
|
name?: string
|
||||||
|
spec_model?: string
|
||||||
|
sku?: string
|
||||||
|
barcode?: string
|
||||||
|
serial_number?: string
|
||||||
|
batch_number?: string
|
||||||
|
status?: string
|
||||||
|
warehouse_location?: string
|
||||||
|
stock_quantity?: number
|
||||||
|
sale_price?: number
|
||||||
|
common_name?: string
|
||||||
|
category?: string
|
||||||
|
material_type?: string
|
||||||
|
unit?: string
|
||||||
|
module_name: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以图搜图响应结构 */
|
/** 以图搜图响应结构 */
|
||||||
|
|||||||
@ -99,10 +99,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { Camera, 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'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}
|
}
|
||||||
@ -200,7 +203,16 @@ const handleUse = (item: ImageSearchItem) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleView = (item: ImageSearchItem) => {
|
const handleView = (item: ImageSearchItem) => {
|
||||||
emit('view', item)
|
// 从 business_data 获取跳转 URL
|
||||||
|
const url = item.business_data?.url
|
||||||
|
if (url) {
|
||||||
|
router.push(url)
|
||||||
|
} else {
|
||||||
|
// 兜底:使用 product_name 作为 keyword 搜索
|
||||||
|
ElMessage.info(`未找到详情页链接,已将 "${item.product_name}" 作为关键词搜索`)
|
||||||
|
emit('view', item)
|
||||||
|
}
|
||||||
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user