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 @@ }); - + \ No newline at end of file diff --git a/inventory-web/src/App.vue b/inventory-web/src/App.vue index 0900935..3f2bb1d 100644 --- a/inventory-web/src/App.vue +++ b/inventory-web/src/App.vue @@ -20,6 +20,10 @@ onMounted(() => { if (userStore.token) { userStore.refreshUserPermissions() } + // 当 Vue 根组件挂载完毕,确保 Dify 图标一定会被加载 + if (typeof (window as any).initDifyChatbot === 'function') { + (window as any).initDifyChatbot() + } }) // ================================================================ @@ -235,7 +239,7 @@ const handleLogout = () => { diff --git a/inventory-web/src/api/common/upload.ts b/inventory-web/src/api/common/upload.ts index 51f5448..eafa4fc 100644 --- a/inventory-web/src/api/common/upload.ts +++ b/inventory-web/src/api/common/upload.ts @@ -3,7 +3,6 @@ import request from '@/utils/request' /** * 上传文件通用接口 * @param data File 对象 或 FormData 对象 - * 适配说明:list.vue 中 customUpload 已经封装了 FormData,所以这里支持直接传 FormData */ export function uploadFile(data: File | FormData) { let formData: FormData @@ -11,14 +10,12 @@ export function uploadFile(data: File | FormData) { if (data instanceof FormData) { formData = data } else { - // 如果传入的是原始 File 对象,则手动封装 formData = new FormData() // @ts-ignore formData.append('file', data) } return request({ - // 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应 url: '/v1/common/upload', method: 'post', data: formData, @@ -29,13 +26,50 @@ export function uploadFile(data: File | FormData) { } /** - * 删除文件通用接口 (新增) + * 删除文件通用接口 * @param filename 文件名 (例如: a1b2c3d4.jpg) */ export function deleteFile(filename: string) { return request({ - // 对应后端路由: @upload_bp.route('/files/', methods=['DELETE']) url: `/v1/common/files/${filename}`, 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({ + url: '/v1/common/image-search', + method: 'post', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) } \ No newline at end of file diff --git a/inventory-web/src/components/ImageSearchDialog.vue b/inventory-web/src/components/ImageSearchDialog.vue new file mode 100644 index 0000000..97395b8 --- /dev/null +++ b/inventory-web/src/components/ImageSearchDialog.vue @@ -0,0 +1,458 @@ + + + + + \ No newline at end of file diff --git a/inventory-web/src/views/material/list.vue b/inventory-web/src/views/material/list.vue index eecb890..9ee65c2 100644 --- a/inventory-web/src/views/material/list.vue +++ b/inventory-web/src/views/material/list.vue @@ -84,6 +84,9 @@ 搜索 重置 + + 拍照识图 + + + + @@ -633,7 +642,7 @@