diff --git a/docker-compose.yml b/docker-compose.yml
index 764ed1e..88b1d13 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ version: '3.8'
services:
db:
- image: postgres:15-alpine
+ image: pgvector/pgvector:pg15 # 换成这个
container_name: inventory_db
restart: always
environment:
@@ -10,7 +10,7 @@ services:
POSTGRES_PASSWORD: 1234
POSTGRES_DB: inventory_system
volumes:
- - ./pgdata_docker:/var/lib/postgresql/data
+ - ./pgdata_docker:/var/lib/postgresql/data # 这里保持不变,Docker会自动创建这个新文件夹
ports:
- "5435:5432"
@@ -41,4 +41,4 @@ services:
ports:
- "5175:5173"
depends_on:
- - backend
+ - backend
\ No newline at end of file
diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py
index 5053b9a..eb6b230 100644
--- a/inventory-backend/app/__init__.py
+++ b/inventory-backend/app/__init__.py
@@ -90,6 +90,17 @@ def create_app():
except ImportError as 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 - 借还/维修/报废)
# -----------------------------------------------------
diff --git a/inventory-backend/app/api/v1/common/image_search.py b/inventory-backend/app/api/v1/common/image_search.py
new file mode 100644
index 0000000..d1a33ae
--- /dev/null
+++ b/inventory-backend/app/api/v1/common/image_search.py
@@ -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
\ No newline at end of file
diff --git a/inventory-backend/app/services/dify_permission_service.py b/inventory-backend/app/services/dify_permission_service.py
index 7e888f5..4c47b6f 100644
--- a/inventory-backend/app/services/dify_permission_service.py
+++ b/inventory-backend/app/services/dify_permission_service.py
@@ -11,6 +11,8 @@ Dify 智能客服权限服务层
- 跨模块越权查询:直接阻断,返回角色专属的错误信息给大模型
"""
+from typing import Optional
+
from flask import g, current_app
from flask_jwt_extended import decode_token
from app.models.system import SysRolePermission
@@ -185,7 +187,7 @@ class DifyPermissionService:
返回:
{
'blocked': bool, # 是否被拦截
- 'message': str | None, # AI 应返回给用户的错误信息(如果有)
+ 'message': Optional[str], # AI 应返回给用户的错误信息(如果有)
}
"""
if DifyPermissionService.is_super_admin(role):
diff --git a/inventory-backend/app/services/export_service/excel_task.py b/inventory-backend/app/services/export_service/excel_task.py
index 21949d9..a2fa05d 100644
--- a/inventory-backend/app/services/export_service/excel_task.py
+++ b/inventory-backend/app/services/export_service/excel_task.py
@@ -20,6 +20,8 @@ import logging
from threading import Thread
from datetime import datetime
+from typing import Optional
+
from openpyxl import Workbook
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 返回已生成文件的完整路径。
未完成或不存在返回 None。
diff --git a/inventory-backend/app/utils/ai_vision.py b/inventory-backend/app/utils/ai_vision.py
new file mode 100644
index 0000000..353f1da
--- /dev/null
+++ b/inventory-backend/app/utils/ai_vision.py
@@ -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()
\ No newline at end of file
diff --git a/inventory-backend/requirements.txt b/inventory-backend/requirements.txt
index 076490f..5b0821e 100644
--- a/inventory-backend/requirements.txt
+++ b/inventory-backend/requirements.txt
@@ -10,6 +10,10 @@ flask-cors==4.0.0
redis==5.0.1
# 图片处理核心库
Pillow>=10.0.0
+# ONNX 模型本地 CPU 推理
+onnxruntime>=1.16.0
+# 数值计算(ONNX 推理依赖)
+numpy>=1.24.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需,包含PIL支持)
diff --git a/inventory-backend/scripts/init_all_vectors.py b/inventory-backend/scripts/init_all_vectors.py
new file mode 100644
index 0000000..12d1460
--- /dev/null
+++ b/inventory-backend/scripts/init_all_vectors.py
@@ -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()
\ No newline at end of file
diff --git a/inventory-web/index.html b/inventory-web/index.html
index 455e197..ee32343 100644
--- a/inventory-web/index.html
+++ b/inventory-web/index.html
@@ -11,6 +11,15 @@
@@ -71,7 +77,7 @@
/* 变成"独立悬浮窗口" */
#dify-chatbot-bubble-window {
- /* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
+ /* 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
top: 15vh !important;
left: 20vw !important;
bottom: auto !important;
@@ -82,9 +88,9 @@
height: 70vh !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;
overflow: hidden !important;
padding-bottom: 16px !important;
@@ -124,4 +130,4 @@
});