Merge remote-tracking branch 'origin/3.0AI添加' into 3.0AI添加

This commit is contained in:
dxc
2026-05-21 18:29:48 +08:00
13 changed files with 1072 additions and 34 deletions

View File

@ -2,7 +2,7 @@ version: '3.8'
services: services:
db: db:
image: postgres:15-alpine image: pgvector/pgvector:pg15 # 换成这个
container_name: inventory_db container_name: inventory_db
restart: always restart: always
environment: environment:
@ -10,7 +10,7 @@ services:
POSTGRES_PASSWORD: 1234 POSTGRES_PASSWORD: 1234
POSTGRES_DB: inventory_system POSTGRES_DB: inventory_system
volumes: volumes:
- ./pgdata_docker:/var/lib/postgresql/data - ./pgdata_docker:/var/lib/postgresql/data # 这里保持不变Docker会自动创建这个新文件夹
ports: ports:
- "5435:5432" - "5435:5432"
@ -41,4 +41,4 @@ services:
ports: ports:
- "5175:5173" - "5175:5173"
depends_on: depends_on:
- backend - backend

View File

@ -90,6 +90,17 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: Upload 模块导入失败: {e}") print(f"❌ 错误: Upload 模块导入失败: {e}")
# -----------------------------------------------------
# 2.4 注册以图搜图模块 (Image Search)
# -----------------------------------------------------
try:
from app.api.v1.common.image_search import image_search_bp
app.register_blueprint(image_search_bp, url_prefix='/api/v1/common')
app.register_blueprint(image_search_bp, url_prefix='/api/common', name='image_search_legacy')
print("✅ Image Search 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Image Search 模块导入失败: {e}")
# ----------------------------------------------------- # -----------------------------------------------------
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废) # 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
# ----------------------------------------------------- # -----------------------------------------------------

View File

@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""
以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索
"""
import os
import uuid
import json
from flask import Blueprint, request, jsonify
from sqlalchemy import text
from app.extensions import db
from app.utils.ai_vision import load_clip_model, get_image_embedding
# 注册蓝图
image_search_bp = Blueprint('image_search', __name__)
# ============================================================================
# POST /api/v1/common/image-search
# 以图搜图:上传图片 → CLIP embedding → pgvector 余弦相似度检索
# ============================================================================
@image_search_bp.route('/image-search', methods=['POST'])
def image_search():
# ---------------------------------------------------------
# 1. 检查文件
# ---------------------------------------------------------
if 'file' not in request.files:
return jsonify({"code": 400, "msg": "未找到图片文件"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"code": 400, "msg": "未选择文件"}), 400
# ---------------------------------------------------------
# 2. 安全保存临时文件
# ---------------------------------------------------------
ext = file.filename.rsplit('.', 1)[-1].lower()
if ext not in {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}:
return jsonify({"code": 400, "msg": "不支持的图片格式"}), 400
tmp_filename = f"{uuid.uuid4().hex}.{ext}"
tmp_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'uploads')
os.makedirs(tmp_dir, exist_ok=True)
tmp_path = os.path.join(tmp_dir, tmp_filename)
try:
file.save(tmp_path)
print(f"💾 [ImageSearch] 临时文件已保存: {tmp_path}")
# ---------------------------------------------------------
# 3. 提取 CLIP embedding
# ---------------------------------------------------------
load_clip_model()
embedding = get_image_embedding(tmp_path)
print(f"✅ [ImageSearch] Embedding 提取成功,维度: {len(embedding)}")
except Exception as e:
print(f"❌ [ImageSearch] 图像处理失败: {e}")
return jsonify({"code": 500, "msg": f"图像处理失败: {str(e)}"}), 500
finally:
# ---------------------------------------------------------
# 4. 无论成功与否,都删除临时文件
# ---------------------------------------------------------
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
print(f"🗑️ [ImageSearch] 临时文件已清理: {tmp_path}")
except Exception as e:
print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}")
# ---------------------------------------------------------
# 5. pgvector 余弦相似度检索(跨表联合检索)
# ---------------------------------------------------------
try:
query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']'
sql = text("""
SELECT id, name, spec_model, image_url,
(1 - (vec <=> :query_vector)) AS similarity
FROM (
SELECT id,
COALESCE(name, '') AS name,
COALESCE(spec, '') AS spec_model,
COALESCE(product_image, '') AS image_url,
img_embedding AS vec
FROM material_base
WHERE img_embedding IS NOT NULL
UNION ALL
SELECT id,
'采购入库' AS name,
'到货照片' AS spec_model,
COALESCE(arrival_photo, '') AS image_url,
arrival_image_embedding AS vec
FROM stock_buy
WHERE arrival_image_embedding IS NOT NULL
UNION ALL
SELECT id,
'采购入库' AS name,
'质检报告' AS spec_model,
COALESCE(qc_report, '') AS image_url,
qc_report_image_embedding AS vec
FROM stock_buy
WHERE qc_report_image_embedding IS NOT NULL
) AS combined
ORDER BY vec <=> :query_vector
LIMIT 10
""")
result = db.session.execute(sql, {"query_vector": query_vector_str})
rows = result.fetchall()
results = []
for row in rows:
item_id = row[0]
item_name = row[1] or ""
spec_model = row[2] or ""
raw_image = row[3]
# 解析图片 URL 列表,取第一张
image_url = ""
if raw_image:
try:
image_list = json.loads(raw_image)
if image_list and len(image_list) > 0:
image_url = image_list[0]
except Exception:
# 纯字符串直接使用
image_url = str(raw_image)
results.append({
"id": item_id,
"name": item_name,
"spec_model": spec_model,
"image_url": image_url,
"similarity": round(float(row[4]), 4)
})
print(f"✅ [ImageSearch] 跨表检索完成,命中 {len(results)} 条结果")
return jsonify({
"code": 200,
"msg": "检索成功",
"data": results
})
except Exception as e:
print(f"❌ [ImageSearch] 数据库检索失败: {e}")
return jsonify({"code": 500, "msg": f"检索失败: {str(e)}"}), 500

View File

@ -11,6 +11,8 @@ Dify 智能客服权限服务层
- 跨模块越权查询:直接阻断,返回角色专属的错误信息给大模型 - 跨模块越权查询:直接阻断,返回角色专属的错误信息给大模型
""" """
from typing import Optional
from flask import g, current_app from flask import g, current_app
from flask_jwt_extended import decode_token from flask_jwt_extended import decode_token
from app.models.system import SysRolePermission from app.models.system import SysRolePermission
@ -185,7 +187,7 @@ class DifyPermissionService:
返回: 返回:
{ {
'blocked': bool, # 是否被拦截 'blocked': bool, # 是否被拦截
'message': str | None, # AI 应返回给用户的错误信息(如果有) 'message': Optional[str], # AI 应返回给用户的错误信息(如果有)
} }
""" """
if DifyPermissionService.is_super_admin(role): if DifyPermissionService.is_super_admin(role):

View File

@ -20,6 +20,8 @@ import logging
from threading import Thread from threading import Thread
from datetime import datetime from datetime import datetime
from typing import Optional
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
@ -346,7 +348,7 @@ def get_task_status(task_id: str) -> dict:
# 获取导出文件路径(供下载接口调用) # 获取导出文件路径(供下载接口调用)
# ============================================================================= # =============================================================================
def get_export_filepath(task_id: str) -> str | None: def get_export_filepath(task_id: str) -> Optional[str]:
""" """
根据 task_id 返回已生成文件的完整路径。 根据 task_id 返回已生成文件的完整路径。
未完成或不存在返回 None。 未完成或不存在返回 None。

View File

@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""
AI Vision 模块 - CLIP Vision Encoder ONNX 推理
"""
import os
import numpy as np
from PIL import Image
import onnxruntime as ort
# ============================================================================
# 全局模型单例(项目启动时加载一次)
# ============================================================================
MODEL_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'models', 'clip_vision.onnx')
# 加载选项CPU 推理,禁用依赖库的启动开销
_session_options = ort.SessionOptions()
_session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
ort_session: ort.InferenceSession = None
def load_clip_model():
"""启动时调用:全局加载 CLIP Vision 模型"""
global ort_session
if ort_session is not None:
return ort_session
if not os.path.exists(MODEL_PATH):
raise FileNotFoundError(f"CLIP Vision 模型未找到: {MODEL_PATH}")
ort_session = ort.InferenceSession(MODEL_PATH, sess_options=_session_options, providers=['CPUExecutionProvider'])
print(f"✅ [AI Vision] CLIP 模型加载成功: {MODEL_PATH}")
return ort_session
# ============================================================================
# CLIP 预处理常量
# ============================================================================
# ImageNet 标准归一化CLIP 官方)
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
# 模型输入尺寸
INPUT_SIZE = 224
def _center_crop_and_resize(image: Image.Image) -> Image.Image:
"""
CLIP 官方预处理:中心裁剪抗干扰
- 将图片最短边缩放到 224
- 从正中间切取 224x224 区域
"""
w, h = image.size
# 计算缩放后的目标尺寸
if w < h:
new_w = INPUT_SIZE
new_h = int(h * INPUT_SIZE / w)
else:
new_h = INPUT_SIZE
new_w = int(w * INPUT_SIZE / h)
# 缩放
image = image.resize((new_w, new_h), Image.BILINEAR)
# 中心裁剪
left = (new_w - INPUT_SIZE) // 2
top = (new_h - INPUT_SIZE) // 2
right = left + INPUT_SIZE
bottom = top + INPUT_SIZE
return image.crop((left, top, right, bottom))
def _normalize(image_np: np.ndarray) -> np.ndarray:
"""
对 224x224x3 图像进行 CLIP 标准归一化
image_np: shape (H, W, C), dtype uint8, 值域 [0, 255]
返回: shape (C, H, W), dtype float32, 值域 [0, 1]
"""
# HWC -> CHW
image_np = image_np.transpose(2, 0, 1).astype(np.float32) / 255.0
# 归一化
for i, (mean, std) in enumerate(zip(IMAGENET_MEAN, IMAGENET_STD)):
image_np[i] = (image_np[i] - mean) / std
return image_np
# ============================================================================
# 主函数:提取图像 embedding
# ============================================================================
def get_image_embedding(image_path: str) -> list:
"""
提取图像的 512 维 CLIP embedding 向量
参数:
image_path: 图像文件路径
返回:
list: 512 维浮点向量
"""
if ort_session is None:
load_clip_model()
# 1. 图片预处理
image = Image.open(image_path).convert('RGB')
image = _center_crop_and_resize(image)
input_data = _normalize(np.array(image))
input_data = np.expand_dims(input_data, axis=0) # [1, 3, 224, 224]
# 2. 构造占位符输入 (关键修复)
dummy_ids = np.zeros((1, 77), dtype=np.int64)
dummy_mask = np.zeros((1, 77), dtype=np.int64)
# 3. 传入模型进行推理
# 注意: 模型输入名在你的模型里必须叫 'pixel_values', 'input_ids', 'attention_mask'
# 如果报错找不到输入名,请打印 ort_session.get_inputs()[0].name 确认
outputs = ort_session.run(
['image_embeds'],
{
'input_ids': dummy_ids,
'pixel_values': input_data.astype(np.float32),
'attention_mask': dummy_mask
}
)
return outputs[0][0].tolist()

View File

@ -10,6 +10,10 @@ flask-cors==4.0.0
redis==5.0.1 redis==5.0.1
# 图片处理核心库 # 图片处理核心库
Pillow>=10.0.0 Pillow>=10.0.0
# ONNX 模型本地 CPU 推理
onnxruntime>=1.16.0
# 数值计算ONNX 推理依赖)
numpy>=1.24.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错) # [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0 python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持) # [新增] 二维码生成库 (标签打印必需包含PIL支持)

View File

@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
"""
全量历史图片向量初始化脚本
功能:遍历配置表中所有历史图片字段,批量提取 CLIP 512 维向量并存回数据库。
用法python scripts/init_all_vectors.py
"""
import os
import json
import sys
from datetime import datetime
from typing import List, Optional
# 将项目根目录加入 Python 路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from tqdm import tqdm
from sqlalchemy import text
# Flask 应用环境
from app import create_app
from app.extensions import db
from app.utils.ai_vision import get_image_embedding, load_clip_model
# ============================================================================
# 业务配置:表 → 图片字段 → 向量字段 映射
# ============================================================================
TARGET_TABLES = [
# 基础物料
{"table": "material_base", "img_col": "product_image", "vec_col": "img_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"},
]
# 物理图片根目录(相对于 app 目录的相对路径 ../uploads/
APP_DIR = os.path.join(os.path.dirname(__file__), '..', 'app')
UPLOADS_ROOT = os.path.abspath(os.path.join(APP_DIR, '..', 'uploads'))
# ============================================================================
# 核心工具函数
# ============================================================================
def parse_img_field(raw_value: str) -> List[str]:
"""
健壮解析图片字段,支持以下格式:
- JSON 数组字符串: ["a.jpg", "b.jpg"]
- 纯字符串单图片: "a.jpg"
- 带 /api/v1/files/ 前缀: ["/api/v1/files/a.jpg"]
返回: 提取出的文件名列表
"""
if not raw_value or (isinstance(raw_value, str) and not raw_value.strip()):
return []
try:
# 先尝试按 JSON 解析(处理 JSON 数组字符串)
parsed = json.loads(raw_value)
if isinstance(parsed, list):
items = parsed
else:
items = [parsed]
except (json.JSONDecodeError, TypeError):
# JSON 解析失败,说明是纯字符串,直接按单图片处理
items = [raw_value.strip()]
filenames = []
for item in items:
if not item or not isinstance(item, str):
continue
item = item.strip()
if not item:
continue
# 去掉可能的 /api/v1/files/ 前缀
filename = os.path.basename(item)
filenames.append(filename)
return filenames
def build_local_path(filename: str) -> str:
"""
将文件名拼装成本地绝对路径
"""
return os.path.join(UPLOADS_ROOT, filename)
def extract_first_valid_vector(raw_img_field: str, table_name: str, img_col: str) -> Optional[str]:
"""
读取图片字段,从第一条有效图片提取向量,返回写入 DB 的 JSON 字符串。
如果所有图片均失败,返回 None。
"""
filenames = parse_img_field(raw_img_field)
if not filenames:
return None
for filename in filenames:
local_path = build_local_path(filename)
if not os.path.exists(local_path):
print(f"\033[91m[WARN] {table_name}.{img_col} | 文件不存在: {local_path}\033[0m")
continue
try:
vec = get_image_embedding(local_path)
if vec is not None:
return json.dumps(vec)
except Exception as e:
print(f"\033[91m[WARN] {table_name}.{img_col} | 推理异常 [{filename}]: {type(e).__name__}: {e}\033[0m")
continue
return None
# ============================================================================
# 主入口
# ============================================================================
def main():
start = datetime.now()
total_success = 0
total_skip = 0
print("=" * 60)
print("📦 全量历史图片向量初始化")
print("=" * 60)
print(f"图片目录: {UPLOADS_ROOT}")
print(f"待处理表数: {len(TARGET_TABLES)}")
print()
# 1. 初始化 Flask 应用上下文(加载 CLIP 模型)
app = create_app()
with app.app_context():
load_clip_model()
print("✅ CLIP 模型加载完成")
print()
# 2. 遍历目标表
for config in TARGET_TABLES:
table_name = config["table"]
img_col = config["img_col"]
vec_col = config["vec_col"]
print(f"正在处理表: {table_name}, 字段: {img_col}")
# 3. 查询待清洗记录(只选未处理过的)
sql = text(f"""
SELECT id, {img_col}
FROM {table_name}
WHERE {img_col} IS NOT NULL
AND {img_col} != '[]'
AND ({vec_col} IS NULL)
""")
rows = db.session.execute(sql).fetchall()
if not rows:
print(f"[{table_name}/{img_col}] ⏭ 无待处理记录")
continue
print(f"\n[{table_name}/{img_col}] 📋 待处理: {len(rows)}")
# 4. 逐条处理
processed = 0
success_count = 0
for row in tqdm(rows, desc=f"{table_name}/{img_col}", unit=""):
record_id = row[0]
raw_img = row[1]
try:
vec_json = extract_first_valid_vector(raw_img, table_name, img_col)
if vec_json is None:
total_skip += 1
continue
# 更新向量字段
update_sql = text(f"""
UPDATE {table_name} SET {vec_col} = :vec_str WHERE id = :id
""")
db.session.execute(update_sql, {"vec_str": vec_json, "id": record_id})
success_count += 1
# 每 50 条提交一次
if processed > 0 and processed % 50 == 0:
db.session.commit()
print(f"\n ✅ 已提交 {processed}")
except Exception as e:
print(f"\n\033[91m[WARN] {table_name}/{img_col} | ID={record_id} 处理异常: {type(e).__name__}: {e}\033[0m")
# 关键:任何异常都不中断,只 continue 下一条
db.session.rollback()
continue
finally:
processed += 1
# 循环结束后补一次 commit处理未凑满50条的剩余数据
try:
db.session.commit()
except Exception:
db.session.rollback()
total_success += success_count
print(f"[{table_name}/{img_col}] ✅ 完成,成功 {success_count} 条 / 跳过 {len(rows) - success_count}")
# 5. 汇总报告
elapsed = (datetime.now() - start).total_seconds()
print()
print("=" * 60)
print(f"🏁 全部完成!总计耗时 {elapsed:.1f}")
print(f" ✅ 成功写入向量: {total_success}")
print(f" ⏭ 无有效图片(跳过): {total_skip}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@ -11,6 +11,15 @@
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script> <script>
window.initDifyChatbot = function() { window.initDifyChatbot = function() {
// 【关键】增加保护检查:确保 DOM 已经就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', performInit);
} else {
performInit();
}
};
function performInit() {
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || ''; var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
var username = localStorage.getItem("username") || ''; var username = localStorage.getItem("username") || '';
@ -19,17 +28,16 @@
return; return;
} }
// 【新增 1】彻底清理浏览器内存中残留的 Dify 全局对象 // 彻底清理浏览器内存中残留的 Dify 全局对象
window.difyChatbot = undefined; window.difyChatbot = undefined;
delete window.difyChatbot; delete window.difyChatbot;
// 【新增 2】清理旧的 DOM 节点 // 清理旧的 DOM 节点
var oldScript = document.getElementById('6T0eTgukUEqzK0iW'); var oldScript = document.getElementById('6T0eTgukUEqzK0iW');
if (oldScript) oldScript.remove(); if (oldScript) oldScript.remove();
document.querySelectorAll('[id^="dify-chatbot-"]').forEach(function(el) { el.remove(); }); document.querySelectorAll('[id^="dify-chatbot-"]').forEach(function(el) { el.remove(); });
// 【核心破解 3】动态化 user_id打破 Dify 会话锁定机制 // 动态化 user_id打破 Dify 会话锁定机制
// 取 token 的最后 8 位拼在用户名后。只要 Token 变了Dify 就会开启新会话,强制读取新 Token。
var dynamicUserId = username + '_' + currentToken.slice(-8); var dynamicUserId = username + '_' + currentToken.slice(-8);
window.difyChatbotConfig = { window.difyChatbotConfig = {
@ -39,22 +47,20 @@
"user_token": currentToken "user_token": currentToken
}, },
systemVariables: { systemVariables: {
"user_id": dynamicUserId // <- 这里使用了动态 ID "user_id": dynamicUserId
}, },
userVariables: {}, userVariables: {},
}; };
// 【新增 4】在脚本 URL 后加上时间戳,破除浏览器强缓存 // 重新挂载
var script = document.createElement('script'); var script = document.createElement('script');
script.src = 'http://172.16.0.198:8080/embed.min.js?t=' + new Date().getTime(); script.src = 'http://172.16.0.198:8080/embed.min.js?t=' + new Date().getTime();
script.id = '6T0eTgukUEqzK0iW'; script.id = '6T0eTgukUEqzK0iW';
script.defer = true; script.defer = true;
document.head.appendChild(script); document.head.appendChild(script);
console.log('✅ Dify chatbot 已挂载新会话,当前绑定 ID:', dynamicUserId);
};
setTimeout(window.initDifyChatbot, 100); console.log('✅ Dify chatbot 已挂载新会话,当前绑定 ID:', dynamicUserId);
}
</script> </script>
<!--<script--> <!--<script-->
@ -71,7 +77,7 @@
/* 变成"独立悬浮窗口" */ /* 变成"独立悬浮窗口" */
#dify-chatbot-bubble-window { #dify-chatbot-bubble-window {
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */ /* 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
top: 15vh !important; top: 15vh !important;
left: 20vw !important; left: 20vw !important;
bottom: auto !important; bottom: auto !important;
@ -82,9 +88,9 @@
height: 70vh !important; height: 70vh !important;
border-radius: 12px !important; border-radius: 12px !important;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important; /* 增加超大弥散阴影,浮现感更强 */ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important;
/* 👇 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */ /* 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
resize: both !important; resize: both !important;
overflow: hidden !important; overflow: hidden !important;
padding-bottom: 16px !important; padding-bottom: 16px !important;
@ -124,4 +130,4 @@
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@ -20,6 +20,10 @@ onMounted(() => {
if (userStore.token) { if (userStore.token) {
userStore.refreshUserPermissions() 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"> <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.29添加AI助手版 当前版本:V3.30添加AI助手版
</span> </span>
</footer> </footer>

View File

@ -3,7 +3,6 @@ import request from '@/utils/request'
/** /**
* 上传文件通用接口 * 上传文件通用接口
* @param data File 对象 或 FormData 对象 * @param data File 对象 或 FormData 对象
* 适配说明list.vue 中 customUpload 已经封装了 FormData所以这里支持直接传 FormData
*/ */
export function uploadFile(data: File | FormData) { export function uploadFile(data: File | FormData) {
let formData: FormData let formData: FormData
@ -11,14 +10,12 @@ export function uploadFile(data: File | FormData) {
if (data instanceof FormData) { if (data instanceof FormData) {
formData = data formData = data
} else { } else {
// 如果传入的是原始 File 对象,则手动封装
formData = new FormData() formData = new FormData()
// @ts-ignore // @ts-ignore
formData.append('file', data) formData.append('file', data)
} }
return request({ return request({
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
url: '/v1/common/upload', url: '/v1/common/upload',
method: 'post', method: 'post',
data: formData, data: formData,
@ -29,13 +26,50 @@ export function uploadFile(data: File | FormData) {
} }
/** /**
* 删除文件通用接口 (新增) * 删除文件通用接口
* @param filename 文件名 (例如: a1b2c3d4.jpg) * @param filename 文件名 (例如: a1b2c3d4.jpg)
*/ */
export function deleteFile(filename: string) { export function deleteFile(filename: string) {
return request({ return request({
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
url: `/v1/common/files/${filename}`, url: `/v1/common/files/${filename}`,
method: 'delete' 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'
}
})
} }

View 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>

View File

@ -84,6 +84,9 @@
<el-button type="primary" plain @click="handleQuery">搜索</el-button> <el-button type="primary" plain @click="handleQuery">搜索</el-button>
<el-button plain @click="resetQuery">重置</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 <el-popover
v-model:visible="advancedFilterVisible" v-model:visible="advancedFilterVisible"
placement="bottom" placement="bottom"
@ -564,6 +567,12 @@
/> />
</el-dialog> </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-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"> <el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
@ -633,7 +642,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, 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 { 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';
@ -655,6 +664,8 @@ import {
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload'; import { usePasteUpload } from '@/hooks/usePasteUpload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'; 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(); const userStore = useUserStore();
@ -716,6 +727,7 @@ const isUploading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large'); const tableSize = ref<'large' | 'default' | 'small'>('large');
const advancedFilterVisible = ref(false); const advancedFilterVisible = ref(false);
const imageSearchVisible = ref(false);
const advancedConditions = ref([{ field: '', operator: '', value: '' }]); const advancedConditions = ref([{ field: '', operator: '', value: '' }]);
const fieldOptions = computed(() => { const fieldOptions = computed(() => {
const allFields = [ const allFields = [
@ -1585,15 +1597,8 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
if (res.code === 200) { if (res.code === 200) {
const newUrl = res.data.url const newUrl = res.data.url
form.value[targetField].push(newUrl) 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('上传成功') ElMessage.success('上传成功')
onSuccess(res) onSuccess(res) // el-upload v-model 自动更新 fileList无需手动 push
} else { } else {
ElMessage.error(res.msg || '上传失败'); ElMessage.error(res.msg || '上传失败');
onError(new 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 = () => { const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' }); advancedConditions.value.push({ field: '', operator: '', value: '' });
}; };