Merge remote-tracking branch 'origin/3.0AI添加' into 3.0AI添加
This commit is contained in:
@ -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
|
||||||
@ -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 - 借还/维修/报废)
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
|
|||||||
153
inventory-backend/app/api/v1/common/image_search.py
Normal file
153
inventory-backend/app/api/v1/common/image_search.py
Normal 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
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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。
|
||||||
|
|||||||
132
inventory-backend/app/utils/ai_vision.py
Normal file
132
inventory-backend/app/utils/ai_vision.py
Normal 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()
|
||||||
@ -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支持)
|
||||||
|
|||||||
220
inventory-backend/scripts/init_all_vectors.py
Normal file
220
inventory-backend/scripts/init_all_vectors.py
Normal 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()
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
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 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: '' });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user