18 Commits

Author SHA1 Message Date
dxc
682139bab8 版本变更V3.34将图像的处理统一更换到新表当中 2026-05-26 08:57:41 +08:00
DXC
e564c5a5d2 fix: 以图搜图跳转物料页面用 watch 接管查询,防止 URL 参数残留 2026-05-26 08:50:53 +08:00
DXC
9406669f1c fix: 以图搜图查看详情优先用 spec_model 跳转物料页面自动搜索 2026-05-26 08:34:03 +08:00
DXC
92e1f7275e feat: 以图搜图返回 business_data 包含 name/spec_model/url,支持详情页跳转 2026-05-25 17:52:03 +08:00
dxc
895d78a5e7 版本变更V3.33添加支持更新后识图功能 2026-05-25 11:20:45 +08:00
DXC
567c3175f6 fix: 审计日志跳过向量字段,修复 numpy 数组比较异常;补全三大入库单更新向量提取,统一删除确认弹窗 2026-05-25 11:11:10 +08:00
dxc
81ea4a0ab3 版本变更V3.32添加支持更新后识图功能 2026-05-25 10:10:14 +08:00
DXC
1da4b454cd feat: 新增物料/入库单实时 CLIP 向量提取(新建+更新),修复 I/O 延迟和路径解析静默失败 2026-05-25 10:04:32 +08:00
dxc
ee9b19e72a 版本变更V3.31添加识图功能 2026-05-22 13:12:28 +08:00
dxc
3ffcd35093 版本变更V3.31添加识图功能 2026-05-22 11:40:35 +08:00
dxc
8c635d6afe 版本变更V3.31添加识图功能 2026-05-22 10:59:39 +08:00
dxc
465452ef46 Merge remote-tracking branch 'origin/3.0AI添加' into 3.0AI添加 2026-05-21 18:29:48 +08:00
DXC
d119bebe94 fix: BOM搜索子件名称+自动搜索防抖 2026-05-21 17:41:14 +08:00
DXC
baaaf7799a fix: BOM子件下拉修复回显丢失和索引错位问题 2026-05-21 17:14:36 +08:00
DXC
c273f5a9d9 feat: 以图搜图功能升级(跨表UNION检索 + 拍照识图入口 + 批量向量初始化脚本) 2026-05-21 15:43:45 +08:00
DXC
1a7c06f197 feat: 添加以图搜图功能(CLIP ONNX + pgvector)+ Dify会话修复 + 版本升至V3.30 2026-05-21 14:09:57 +08:00
dxc
621431dcb9 版本变更V3.29体验优化 2026-05-20 09:09:33 +08:00
dxc
6d044b234c 版本变更V3.27体验优化 2026-05-19 18:33:19 +08:00
32 changed files with 1743 additions and 224 deletions

View File

@ -1,9 +1,9 @@
version: '3.8' version: '3.8'
services: services:
# --- 数据库 (保持不变) --- # --- 数据库 (已修改为自带 pgvector 的镜像) ---
db: db:
image: postgres:15-alpine image: pgvector/pgvector:pg15
container_name: inventory_db_prod container_name: inventory_db_prod
restart: always restart: always
environment: environment:
@ -11,6 +11,7 @@ services:
POSTGRES_PASSWORD: StrongPassword123! POSTGRES_PASSWORD: StrongPassword123!
POSTGRES_DB: inventory_system POSTGRES_DB: inventory_system
volumes: volumes:
# 数据卷保持不变,你的历史数据不会丢失!
- ./pgdata_prod:/var/lib/postgresql/data - ./pgdata_prod:/var/lib/postgresql/data
# --- 后端 (Flask) (保持不变) --- # --- 后端 (Flask) (保持不变) ---
@ -29,7 +30,7 @@ services:
depends_on: depends_on:
- db - db
# --- 前端 (Nginx + Vue) (这是需要修改的部分) --- # --- 前端 (Nginx + Vue) (包含 HTTPS 配置) ---
frontend: frontend:
build: build:
context: ./inventory-web context: ./inventory-web

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"

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,238 @@
# -*- coding: utf-8 -*-
"""
以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索
数据源image_embeddings 表(统一向量存储)
"""
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
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
# 注册蓝图
image_search_bp = Blueprint('image_search', __name__)
# ============================================================================
# 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 余弦相似度检索(统一查 image_embeddings 表)
# ---------------------------------------------------------
try:
query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']'
sql = text("""
SELECT
ie.id AS embedding_id,
ie.module_name,
ie.target_id,
ie.image_url,
(1 - (ie.embedding <=> :query_vector)) AS similarity
FROM image_embeddings ie
WHERE ie.embedding IS NOT NULL
ORDER BY ie.embedding <=> :query_vector
LIMIT 200
""")
raw_records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall()
if not raw_records:
return jsonify({"code": 200, "data": []})
# 按 (module_name, target_id) 去重,每业务记录只保留最相似的那张图
seen = {}
for row in raw_records:
key = (row.module_name, row.target_id)
if key not in seen:
seen[key] = row
# 批量回填业务数据
target_ids_by_module = {}
for row in seen.values():
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
business_map = {}
# 回填 StockBuy
if 'stock_buy' in target_ids_by_module:
ids = target_ids_by_module['stock_buy']
records = (
db.session.query(StockBuy)
.filter(StockBuy.id.in_(ids))
.outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_buy', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'module_name': 'stock_buy',
'url': '/inventory/buy',
}
# 回填 StockSemi
if 'stock_semi' in target_ids_by_module:
ids = target_ids_by_module['stock_semi']
records = (
db.session.query(StockSemi)
.filter(StockSemi.id.in_(ids))
.outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_semi', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'module_name': 'stock_semi',
'url': '/inventory/semi',
}
# 回填 StockProduct
if 'stock_product' in target_ids_by_module:
ids = target_ids_by_module['stock_product']
records = (
db.session.query(StockProduct)
.filter(StockProduct.id.in_(ids))
.outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_product', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'sale_price': r.sale_price,
'module_name': 'stock_product',
'url': '/inventory/product',
}
# 回填 MaterialBase
if 'material_base' in target_ids_by_module:
ids = target_ids_by_module['material_base']
records = MaterialBase.query.filter(MaterialBase.id.in_(ids)).all()
for r in records:
business_map[('material_base', r.id)] = {
'record_id': r.id,
'name': r.name,
'spec_model': r.spec_model,
'common_name': r.common_name,
'category': r.category,
'material_type': r.material_type,
'unit': r.unit,
'module_name': 'material_base',
'url': '/material/index',
}
# 组装最终返回
results = []
for row in seen.values():
key = (row.module_name, row.target_id)
biz = business_map.get(key, {})
raw_url = row.image_url or ''
clean_url = raw_url
if raw_url.startswith('['):
try:
url_list = json.loads(raw_url)
clean_url = url_list[0] if url_list else ''
except:
pass
results.append({
"module_name": row.module_name,
"target_id": row.target_id,
"image_url": clean_url,
"similarity": round(float(row.similarity), 4),
"product_name": biz.get('name') or biz.get('material_name') or '未命名物料',
"product_id": row.target_id,
"spec_model": biz.get('spec_model') or '',
"business_data": biz,
})
if len(results) >= 10:
break
return jsonify({"code": 200, "data": results})
except Exception as e:
print(f"❌ [ImageSearch] 数据库检索失败: {e}")
return jsonify({"code": 500, "msg": f"检索失败: {str(e)}"}), 500

View File

@ -142,6 +142,7 @@ def before_update_listener(mapper, connection, target):
changes = {} changes = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
if attr.history.has_changes(): if attr.history.has_changes():
old_val = attr.history.deleted[0] if attr.history.deleted else None old_val = attr.history.deleted[0] if attr.history.deleted else None
new_val = attr.history.added[0] if attr.history.added else None new_val = attr.history.added[0] if attr.history.added else None
@ -164,6 +165,8 @@ def before_delete_listener(mapper, connection, target):
state = inspect(target) state = inspect(target)
snap = {} snap = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
val = getattr(target, attr.key, None) val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val) snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap}) _create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
@ -180,6 +183,8 @@ def after_insert_listener(mapper, connection, target):
state = inspect(target) state = inspect(target)
snap = {} snap = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
val = getattr(target, attr.key, None) val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val) snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'insert', {'created': snap}) _create_audit_log(connection, mapper, target, 'insert', {'created': snap})

View File

@ -1,5 +1,6 @@
# app/models/base.py # app/models/base.py
from app.extensions import db from app.extensions import db
from pgvector.sqlalchemy import Vector
import json import json
@ -34,6 +35,9 @@ class MaterialBase(db.Model):
# 强制质检标记(采购入库时必须上传检测报告) # 强制质检标记(采购入库时必须上传检测报告)
is_inspection_required = db.Column(db.Boolean, default=False, comment='是否强制要求质检') is_inspection_required = db.Column(db.Boolean, default=False, comment='是否强制要求质检')
# CLIP 视觉向量(用于以图搜图)
img_embedding = db.Column(Vector(512), nullable=True)
# ============================================================ # ============================================================
# 关联关系区域 # 关联关系区域
# ============================================================ # ============================================================

View File

@ -1,5 +1,6 @@
# inventory-backend/app/models/inbound/buy.py # inventory-backend/app/models/inbound/buy.py
from app.extensions import db from app.extensions import db
from pgvector.sqlalchemy import Vector
import json import json
# 显式导入 MaterialBase 以防 relationship 找不到引用 # 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase from app.models.base import MaterialBase
@ -55,6 +56,9 @@ class StockBuy(db.Model):
# 全局打印流水号 # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# CLIP 视觉向量(用于以图搜图)
arrival_image_embedding = db.Column(Vector(512), nullable=True)
# 关系定义 # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_buys') base = db.relationship('MaterialBase', back_populates='stock_buys')

View File

@ -1,5 +1,6 @@
# app/models/inbound/product.py # app/models/inbound/product.py
from app.extensions import db from app.extensions import db
from pgvector.sqlalchemy import Vector
import json import json
from app.models.base import MaterialBase from app.models.base import MaterialBase
@ -58,6 +59,9 @@ class StockProduct(db.Model):
# 全局打印流水号 # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# CLIP 视觉向量(用于以图搜图)
arrival_image_embedding = db.Column(Vector(512), nullable=True)
# 关系定义 # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_products') base = db.relationship('MaterialBase', back_populates='stock_products')

View File

@ -1,5 +1,6 @@
# app/models/inbound/semi.py # app/models/inbound/semi.py
from app.extensions import db from app.extensions import db
from pgvector.sqlalchemy import Vector
import json import json
from app.models.base import MaterialBase from app.models.base import MaterialBase
@ -56,6 +57,9 @@ class StockSemi(db.Model):
# 全局打印流水号 # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# CLIP 视觉向量(用于以图搜图)
arrival_image_embedding = db.Column(Vector(512), nullable=True)
# 关系定义 # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_semis') base = db.relationship('MaterialBase', back_populates='stock_semis')

View File

@ -140,24 +140,35 @@ class BomService:
) )
) )
# ★ 调试:打印 SQL 语句
logger.info(f"[BOM List] keyword={keyword!r} → SQL:\n{str(query_base.statement.compile(compile_kwargs={'literal_binds': True}))}")
# 获取符合条件的唯一组合 # 获取符合条件的唯一组合
target_pairs = query_base.distinct().all() target_pairs = query_base.distinct().all()
if not target_pairs: if not target_pairs:
return [] return []
# 2. 聚合查询详情 # 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称解决步骤3过滤遗漏问题
results = [] results = []
for bom_no, version in target_pairs: for bom_no, version in target_pairs:
# ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格
child_alias = db.aliased(MaterialBase)
summary = db.session.query( summary = db.session.query(
BomTable.parent_id, BomTable.parent_id,
MaterialBase.name.label('parent_name'), MaterialBase.name.label('parent_name'),
MaterialBase.spec_model.label('parent_spec'), MaterialBase.spec_model.label('parent_spec'),
MaterialBase.category.label('parent_category'), MaterialBase.category.label('parent_category'),
BomTable.is_enabled, BomTable.is_enabled,
func.count(BomTable.child_id).label('child_count') func.count(BomTable.child_id).label('child_count'),
# ★ 聚合子件名称为逗号分隔字符串用于步骤3关键词过滤
func.string_agg(child_alias.name, ', ').label('child_names'),
# ★ 同时聚合子件规格(备用)
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
).join( ).join(
MaterialBase, BomTable.parent_id == MaterialBase.id MaterialBase, BomTable.parent_id == MaterialBase.id
).outerjoin(
child_alias, BomTable.child_id == child_alias.id
).filter( ).filter(
BomTable.bom_no == bom_no, BomTable.bom_no == bom_no,
BomTable.version == version BomTable.version == version
@ -174,7 +185,9 @@ class BomService:
'parent_spec': summary.parent_spec or '', 'parent_spec': summary.parent_spec or '',
'parent_category': summary.parent_category or '', 'parent_category': summary.parent_category or '',
'is_enabled': summary.is_enabled, 'is_enabled': summary.is_enabled,
'child_count': summary.child_count 'child_count': summary.child_count,
'child_names': summary.child_names or '', # ★ 新增:子件名称聚合
'child_specs': summary.child_specs or '' # ★ 新增:子件规格聚合
}) })
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True) results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
@ -188,6 +201,8 @@ class BomService:
or kw in (r.get('parent_spec') or '').lower() or kw in (r.get('parent_spec') or '').lower()
or kw in (r.get('bom_no') or '').lower() or kw in (r.get('bom_no') or '').lower()
or kw in (r.get('parent_category') or '').lower() or kw in (r.get('parent_category') or '').lower()
or kw in (r.get('child_names') or '').lower() # ★ 修复:加入子件名称过滤
or kw in (r.get('child_specs') or '').lower() # ★ 同步加入子件规格过滤
] ]
# 按 parent_category 分组 # 按 parent_category 分组

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

@ -12,6 +12,9 @@ import traceback
import json import json
import io import io
import datetime import datetime
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
# 需要 pip install openpyxl # 需要 pip install openpyxl
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
@ -555,8 +558,15 @@ class MaterialBaseService:
product_image=json.dumps(data.get('generalImage', [])), product_image=json.dumps(data.get('generalImage', [])),
is_enabled=is_enabled_val is_enabled=is_enabled_val
) )
db.session.add(new_material) db.session.add(new_material)
db.session.flush() # 获取 new_material.id
# 提取产品图向量到独立表(失败不影响业务)
image_list = data.get('generalImage', [])
if isinstance(image_list, list) and image_list:
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, new_material.id, image_list
)
db.session.commit() db.session.commit()
return new_material return new_material
@ -585,7 +595,17 @@ class MaterialBaseService:
if 'generalManual' in data: if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual']) material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data: if 'generalImage' in data:
material.product_image = json.dumps(data['generalImage']) new_photo_list = data['generalImage']
material.product_image = json.dumps(new_photo_list)
# 保存向量到独立表(全量替换)
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id, new_photo_list
)
else:
material.product_image = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id
)
# 【核心修改】:兼容前端传来的布尔值 # 【核心修改】:兼容前端传来的布尔值
if 'isEnabled' in data: if 'isEnabled' in data:
@ -652,6 +672,10 @@ class MaterialBaseService:
f"请先清理相关库存或仅‘禁用’此条目。" f"请先清理相关库存或仅‘禁用’此条目。"
) )
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id
)
db.session.delete(material) db.session.delete(material)
db.session.commit() db.session.commit()
return material_name return material_name
@ -1048,14 +1072,15 @@ class MaterialBaseService:
@staticmethod @staticmethod
def get_latest_specs(): def get_latest_specs():
""" """
获取所有规格型号的最大连号,按连续区间分组返回 获取所有规格型号的分组统计,按规则聚合后返回
- 前缀统一大写处理 - 前缀统一大写处理
- 只有数字完全连续N, N+1, N+2...)才认定为同一组 - 匹配模式:(前缀)(单数字二级分类位)(纯数字部分),如 OPT12046 -> OPT, 1, 2046
- 数字不连续时断开,形成新组 - OPT 系列:使用 前缀+二级分类位 作为分组 Key如 OPT1, OPT2
- 按每组数量降序排列 - 其他前缀:直接使用前缀作为分组 Key
- 返回每个连续区间的最大值 - 返回每个分组的数量、最大号、完整规格名
""" """
import re import re
from collections import defaultdict
# 1. 查询所有不为空的规格型号 # 1. 查询所有不为空的规格型号
specs = MaterialBase.query.filter( specs = MaterialBase.query.filter(
@ -1063,8 +1088,8 @@ class MaterialBaseService:
MaterialBase.spec_model != '' MaterialBase.spec_model != ''
).all() ).all()
# 2. 解析并收集所有有效的 (prefix, num, original_spec) # 2. 按分组收集所有数字
parsed = [] groups = defaultdict(list)
for material in specs: for material in specs:
spec = material.spec_model spec = material.spec_model
@ -1072,72 +1097,31 @@ class MaterialBaseService:
continue continue
base_spec = spec.split('/')[0] base_spec = spec.split('/')[0]
match = re.match(r'^([A-Za-z]+)(\d)(\d+)$', base_spec)
match = re.match(r'^([A-Za-z]+)(\d+)$', base_spec)
if not match: if not match:
continue continue
prefix, num_str = match.groups() prefix, sub_cat, num_str = match.groups()
prefix = prefix.upper() prefix = prefix.upper()
num = int(num_str) num = int(num_str)
parsed.append((prefix, num, spec)) # OPT 系列使用 前缀+单数字二级分类 作为 Key
key = f"{prefix}{sub_cat}" if prefix == 'OPT' else prefix
groups[key].append((num, spec))
# 3. 先按 prefix 升序,再按 num 升序排序 # 3. 生成展示用的统计数据
parsed.sort(key=lambda x: (x[0], x[1]))
# 4. 遍历切分连续区间
# 核心逻辑:当 current_num != prev_num + 1 时,断开形成新组
intervals = []
current_prefix = None
current_start = None
current_end = None
current_last_spec = None
for prefix, num, spec in parsed:
if current_prefix is None:
current_prefix = prefix
current_start = num
current_end = num
current_last_spec = spec
elif prefix == current_prefix and num == current_end + 1:
current_end = num
current_last_spec = spec
else:
intervals.append({
'prefix': current_prefix,
'start': current_start,
'end': current_end,
'count': current_end - current_start + 1,
'latest': current_last_spec
})
current_prefix = prefix
current_start = num
current_end = num
current_last_spec = spec
if current_prefix is not None:
intervals.append({
'prefix': current_prefix,
'start': current_start,
'end': current_end,
'count': current_end - current_start + 1,
'latest': current_last_spec
})
# 5. 按每组数量降序排列,再按前缀升序
intervals.sort(key=lambda x: (-x['count'], x['prefix']))
# 6. 构建返回结果
result = [] result = []
for item in intervals: for key, items in groups.items():
prefix = item['prefix'] sorted_items = sorted(items, key=lambda x: x[0])
start = item['start'] max_num, max_spec = sorted_items[-1]
end = item['end']
result.append({ result.append({
"group": f"{prefix}({start}-{end})", 'group': key,
"count": item['count'], 'count': len(sorted_items),
"latest": item['latest'] 'latest': max_spec,
'max_num': max_num
}) })
# 4. 按数量降序,再按分组名升序排列
result.sort(key=lambda x: (-x['count'], x['group']))
return result return result

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class BuyInboundService: class BuyInboundService:
@ -178,6 +181,14 @@ class BuyInboundService:
inspection_report=json.dumps(data.get('inspection_report', [])) inspection_report=json.dumps(data.get('inspection_report', []))
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 提取到货图片向量到新表(失败不影响业务)
photo_list = data.get('arrival_photo', [])
if isinstance(photo_list, list) and photo_list:
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, new_stock.id, photo_list
)
db.session.commit() db.session.commit()
return new_stock return new_stock
except Exception as e: except Exception as e:
@ -240,7 +251,19 @@ class BuyInboundService:
for k, v in field_mapping.items(): for k, v in field_mapping.items():
if k in data: setattr(stock, v, data[k]) if k in data: setattr(stock, v, data[k])
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo']) if 'arrival_photo' in data:
new_photo_list = data['arrival_photo']
stock.arrival_photo = json.dumps(new_photo_list)
# 保存向量到独立表(全量替换)
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id, new_photo_list
)
else:
stock.arrival_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report']) if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
# 更新税率 # 更新税率
@ -283,8 +306,11 @@ class BuyInboundService:
try: try:
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class ProductInboundService: class ProductInboundService:
@ -184,6 +187,13 @@ class ProductInboundService:
order_id=data.get('order_id') order_id=data.get('order_id')
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 提取产品图片向量到独立表(失败不影响业务)
if isinstance(photo_list, list) and photo_list:
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, new_stock.id, photo_list
)
db.session.commit() db.session.commit()
return new_stock return new_stock
except Exception as e: except Exception as e:
@ -213,8 +223,17 @@ class ProductInboundService:
if f in data: setattr(stock, f, data[f]) if f in data: setattr(stock, f, data[f])
if 'product_photo' in data: if 'product_photo' in data:
imgs = data['product_photo'] new_photo_list = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs) stock.product_photo = json.dumps(new_photo_list)
# 保存向量到独立表(全量替换)
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id, new_photo_list
)
else:
stock.product_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id
)
if 'quality_report_link' in data: if 'quality_report_link' in data:
imgs = data['quality_report_link'] imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs) if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
@ -255,8 +274,11 @@ class ProductInboundService:
try: try:
stock = StockProduct.query.get(stock_id) stock = StockProduct.query.get(stock_id)
if stock: if stock:
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class SemiInboundService: class SemiInboundService:
@ -221,6 +224,13 @@ class SemiInboundService:
remark=data.get('remark') remark=data.get('remark')
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 提取到货图片向量到独立表(失败不影响业务)
if isinstance(arrival_list, list) and arrival_list:
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, new_stock.id, arrival_list
)
db.session.commit() db.session.commit()
return new_stock return new_stock
except Exception as e: except Exception as e:
@ -268,9 +278,17 @@ class SemiInboundService:
setattr(stock, db_attr, data[frontend_key]) setattr(stock, db_attr, data[frontend_key])
if 'arrival_photo' in data: if 'arrival_photo' in data:
imgs = data['arrival_photo'] new_photo_list = data['arrival_photo']
if isinstance(imgs, list): stock.arrival_photo = json.dumps(new_photo_list)
stock.arrival_photo = json.dumps(imgs) # 保存向量到独立表(全量替换)
ImageEmbeddingService.save_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id, new_photo_list
)
else:
stock.arrival_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id
)
if 'quality_report_link' in data: if 'quality_report_link' in data:
imgs = data['quality_report_link'] imgs = data['quality_report_link']
if isinstance(imgs, list): if isinstance(imgs, list):
@ -344,8 +362,11 @@ class SemiInboundService:
stock = StockSemi.query.get(stock_id) stock = StockSemi.query.get(stock_id)
if not stock: if not stock:
raise ValueError("记录不存在") raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name

View File

@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""
AI Vision 模块 - CLIP Vision Encoder ONNX 推理
"""
import os
import json
import time
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()
# ============================================================================
# 通用向量提取工具:防呆、防错
# ============================================================================
def extract_and_embed(photo_source):
if not photo_source:
return None
try:
# 1. 提取基础字符串
photo_source_str = str(photo_source).strip()
raw_path = ""
# 尝试剥掉 JSON 外壳
try:
parsed = json.loads(photo_source_str)
if isinstance(parsed, list):
raw_path = parsed[0] if parsed else ""
elif isinstance(parsed, str):
raw_path = parsed
else:
raw_path = str(parsed)
except:
raw_path = photo_source_str
if not raw_path:
return None
# 2. 剥离出最纯净的文件名 (只取最后一段)
pure_filename = raw_path.split('/')[-1]
# 3. 【终极物理净化】强行抠掉所有多余的标点符号!
# 哪怕传进来的是 123.jpg"] 或者是 "123.jpg",全部洗干净
pure_filename = pure_filename.replace('"', '').replace("'", "").replace('[', '').replace(']', '')
# 4. 拼接真实的 Docker 物理路径
file_path = os.path.join('/app/uploads', pure_filename)
# 5. 加入重试机制 (最多等 3 秒)
max_retries = 6
for i in range(max_retries):
if os.path.exists(file_path):
# 文件找到了,开始提取向量
vec = get_image_embedding(file_path)
if isinstance(vec, np.ndarray):
return vec.tolist()
return vec
else:
print(f"[AI 识图等待] 第 {i+1} 次尝试,未找到文件 {file_path},等待 0.5s...")
time.sleep(0.5)
print(f"[AI 识图警告] 彻底失败!经过等待依然未找到图片: {file_path}")
except Exception as e:
print(f"[AI 识图错误] 实时提取向量失败: {str(e)}")
return None

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支持)
@ -22,3 +26,7 @@ openpyxl>=3.1.2
APScheduler==3.10.4 APScheduler==3.10.4
# [新增] 时区处理 (APScheduler 需要) # [新增] 时区处理 (APScheduler 需要)
pytz pytz
# [新增] 进度条库 (脚本和任务所需)
tqdm>=4.66.0
# [新增] pgvector 向量数据库支持(以图搜图 / 实时向量提取)
pgvector>=0.2.0

View File

@ -0,0 +1,231 @@
# -*- 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 = [
# 1. 基础物料
{"table": "material_base", "img_col": "product_image", "vec_col": "img_embedding"},
# 2. 采购入库
{"table": "stock_buy", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
{"table": "stock_buy", "img_col": "inspection_report", "vec_col": "qc_report_image_embedding"}, # 已修复: qc_report -> inspection_report
# 3. 半成品入库 (新增)
{"table": "stock_semi", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
{"table": "stock_semi", "img_col": "quality_report_link", "vec_col": "qc_report_image_embedding"},
# 4. 成品入库 (新增)
{"table": "stock_product", "img_col": "product_photo", "vec_col": "arrival_image_embedding"},
{"table": "stock_product", "img_col": "quality_report_link", "vec_col": "qc_report_image_embedding"}
# 注意:成品入库表还有一个 inspection_report_link但由于数据库中成品表目前只加了两个向量字段
# 暂不将该字段加入遍历,以免覆盖 quality_report_link 的特征。
]
# 物理图片根目录(相对于 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

@ -1,3 +1,6 @@
# .env.development # .env.development
# 注意:这里必须写你电脑的局域网 IP # 1. 本地局域网测试用(比如让平板连 192.168.9.33
VITE_API_BASE_URL=http://172.16.0.95:8000/api/v1 #VITE_API_BASE_URL=http://192.168.9.33:8000/api/v1
# 2. 服务器环境用(推送到服务器前,把上面那行注释掉,这行解开)
VITE_API_BASE_URL=http://172.16.0.95:8000/api/v1

View File

@ -10,8 +10,35 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script> <script>
// 获取当前用户的登录凭证 (Token) 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") || '';
if (!currentToken) {
console.log('未检测到 Token暂不加载 Dify');
return;
}
// 彻底清理浏览器内存中残留的 Dify 全局对象
window.difyChatbot = undefined;
delete window.difyChatbot;
// 清理旧的 DOM 节点
var oldScript = document.getElementById('6T0eTgukUEqzK0iW');
if (oldScript) oldScript.remove();
document.querySelectorAll('[id^="dify-chatbot-"]').forEach(function(el) { el.remove(); });
// 动态化 user_id打破 Dify 会话锁定机制
var dynamicUserId = username + '_' + currentToken.slice(-8);
window.difyChatbotConfig = { window.difyChatbotConfig = {
token: '6T0eTgukUEqzK0iW', token: '6T0eTgukUEqzK0iW',
@ -19,16 +46,28 @@
inputs: { inputs: {
"user_token": currentToken "user_token": currentToken
}, },
systemVariables: {}, systemVariables: {
"user_id": dynamicUserId
},
userVariables: {}, userVariables: {},
};
// 重新挂载
var script = document.createElement('script');
script.src = 'http://172.16.0.198:8080/embed.min.js?t=' + new Date().getTime();
script.id = '6T0eTgukUEqzK0iW';
script.defer = true;
document.head.appendChild(script);
console.log('✅ Dify chatbot 已挂载新会话,当前绑定 ID:', dynamicUserId);
} }
</script> </script>
<script <!--<script-->
src="http://172.16.0.198:8080/embed.min.js" <!-- src="http://172.16.0.198:8080/embed.min.js"-->
id="6T0eTgukUEqzK0iW" <!-- id="6T0eTgukUEqzK0iW"-->
defer> <!-- defer>-->
</script> <!--</script>-->
<style> <style>
#dify-chatbot-bubble-button { #dify-chatbot-bubble-button {
@ -38,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;
@ -49,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;

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()
}
}) })
// ================================================================ // ================================================================
@ -189,7 +193,8 @@ const handleLogout = () => {
.then(async () => { .then(async () => {
userStore.logout() userStore.logout()
ElMessage({ type: 'success', message: '已安全退出' }) ElMessage({ type: 'success', message: '已安全退出' })
await router.replace('/login') // 直接原生跳转,重置一切
window.location.href = '/login'
}) })
.catch(() => {}) .catch(() => {})
} }
@ -234,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.26添加AI助手 当前版本:V3.34识图
</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,71 @@ 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
module_name: string
target_id: number
business_data: {
record_id: number
name?: string
spec_model?: string
sku?: string
barcode?: string
serial_number?: string
batch_number?: string
status?: string
warehouse_location?: string
stock_quantity?: number
sale_price?: number
common_name?: string
category?: string
material_type?: string
unit?: string
module_name: string
url: string
}
}
/** 以图搜图响应结构 */
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,491 @@
<template>
<el-dialog
v-model="visible"
title="以图搜图"
width="95%"
style="max-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"
:auto-upload="false"
:show-file-list="false"
accept="image/*"
capture="environment"
:on-change="handleFileChange"
>
<div v-if="!previewUrl" class="upload-placeholder">
<el-icon class="upload-icon" :size="48"><Camera /></el-icon>
<div class="upload-text">点击拍照或选择图片</div>
<div class="upload-hint">支持 jpg/png 格式</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="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 { useRouter } from 'vue-router'
import { Camera, Loading, Picture, WarningFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
const router = useRouter()
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 '';
// 直接原样返回,完全信任后端传过来的 image_url
return path.startsWith('http') ? path : 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) => {
const biz = item.business_data
if (biz?.spec_model) {
router.push({ path: '/material/index', query: { keyword: biz.spec_model } })
} else if (biz?.url) {
router.push(biz.url)
} else {
ElMessage.warning('无法定位目标页面')
}
handleClose()
}
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;
}
/* ---- 新增:移动端适配样式 ---- */
@media screen and (max-width: 768px) {
.image-search-body {
flex-direction: column;
gap: 16px;
min-height: auto;
}
.upload-section {
flex: none;
width: 100%;
}
:deep(.el-upload), :deep(.el-upload-dragger) {
height: 160px;
}
.preview-image {
max-height: 140px;
width: auto;
object-fit: contain;
}
.result-section {
width: 100%;
max-height: 50vh;
}
}
</style>

View File

@ -84,6 +84,11 @@ export const useUserStore = defineStore('user', () => {
localStorage.setItem('refresh_token', data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token)
} }
// [Dify] 登录成功,重新初始化 DifyToken 变化时 Dify 会开辟新会话,解决会话串号问题)
if (typeof window.initDifyChatbot === 'function') {
window.initDifyChatbot()
}
// 登录成功后,根据角色获取权限 // 登录成功后,根据角色获取权限
if (role.value) { if (role.value) {
try { try {
@ -110,6 +115,11 @@ export const useUserStore = defineStore('user', () => {
const setToken = (newToken: string) => { const setToken = (newToken: string) => {
token.value = newToken token.value = newToken
localStorage.setItem('access_token', newToken) localStorage.setItem('access_token', newToken)
// [Dify] Token 刷新后,重新初始化 Dify 以更新用户会话
if (typeof window.initDifyChatbot === 'function') {
window.initDifyChatbot()
}
} }
// 退出逻辑 // 退出逻辑
@ -123,6 +133,11 @@ export const useUserStore = defineStore('user', () => {
// 2. 清空 LocalStorage (硬盘) // 2. 清空 LocalStorage (硬盘)
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
// [Dify] 退出登录时,彻底销毁桌面上的 Dify 聊天窗口,防止信息泄露或报错
document.querySelectorAll('[id^="dify-chatbot-"]').forEach(el => el.remove())
// 清空其他本地存储
localStorage.removeItem('refresh_token') localStorage.removeItem('refresh_token')
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('role') localStorage.removeItem('role')

View File

@ -164,7 +164,7 @@
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300"> <el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')"> <el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row, $index }"> <template #default="{ row }">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== --> <!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<el-select <el-select
@ -174,14 +174,14 @@
remote remote
reserve-keyword reserve-keyword
style="flex: 1;" style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)" :remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
:loading="selectLoading" :loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`" :loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`" :popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)" @visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
> >
<el-option <el-option
v-for="item in getChildOptions($index)" v-for="item in getChildOptions(row.rowKey)"
:key="item.id" :key="item.id"
:label="`${item.name} (${item.spec})`" :label="`${item.name} (${item.spec})`"
:value="item.id" :value="item.id"
@ -197,7 +197,7 @@
type="primary" type="primary"
link link
:icon="EditPen" :icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec($index))" @click.stop="openMaterialInNewTab(row.child_id, getChildSpec(row.rowKey))"
style="font-size: 16px; padding: 4px;" style="font-size: 16px; padding: 4px;"
/> />
</el-tooltip> </el-tooltip>
@ -218,8 +218,8 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')"> <el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
<template #default="{ $index }"> <template #default="{ row }">
<el-button type="danger" link @click="removeChild($index)">删</el-button> <el-button type="danger" link @click="removeChild(row.rowKey)">删</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -240,7 +240,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue' import { ref, reactive, onMounted, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus' import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen } from '@element-plus/icons-vue' import { Plus, Search, EditPen } from '@element-plus/icons-vue'
@ -266,6 +266,7 @@ interface MaterialBase {
spec: string spec: string
} }
interface ChildRow { interface ChildRow {
rowKey: number // 唯一标识,替代 $index 作为 Map key
child_id: number | null child_id: number | null
dosage: number dosage: number
remark: string remark: string
@ -288,6 +289,15 @@ const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref('') const searchKeyword = ref('')
const childSearchKeyword = ref('') const childSearchKeyword = ref('')
// ★ 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
watch(searchKeyword, (val) => {
// 防抖:延迟 500ms 执行,避免频繁请求
clearTimeout((window as any)._bomSearchTimer)
;(window as any)._bomSearchTimer = setTimeout(() => {
fetchBomList()
}, 500)
})
// ============================================================ // ============================================================
// 【改造】分页 + 远程搜索相关状态 // 【改造】分页 + 远程搜索相关状态
// ============================================================ // ============================================================
@ -321,15 +331,15 @@ const getChildOptions = (index: number): MaterialBase[] => {
// ============================================================ // ============================================================
const fetchMaterialOptions = async ( const fetchMaterialOptions = async (
type: 'parent' | 'child', type: 'parent' | 'child',
index?: number, rowKey?: number,
isLoadMore = false isLoadMore = false
) => { ) => {
// 子件行需要 index // 子件行需要 rowKey唯一标识不再依赖数组索引
if (type === 'child' && index === undefined) return if (type === 'child' && rowKey === undefined) return
const params = type === 'parent' const params = type === 'parent'
? parentQueryParams ? parentQueryParams
: childDropdownStates.value.get(index!)?.queryParams : childDropdownStates.value.get(rowKey!)?.queryParams
if (!params) return if (!params) return
@ -353,12 +363,19 @@ const fetchMaterialOptions = async (
const newItems = list.filter(m => !existingIds.has(m.id)) const newItems = list.filter(m => !existingIds.has(m.id))
parentOptions.value.push(...newItems) parentOptions.value.push(...newItems)
} else { } else {
parentOptions.value = list // ★ 修复回显丢失:先检查当前选中项是否在列表中,不在则从原 options 保留
const selectedId = form.parent_id
let finalList = [...list]
if (selectedId && !list.find(m => m.id === selectedId)) {
const existing = parentOptions.value.find(m => m.id === selectedId)
if (existing) finalList.unshift(existing)
}
parentOptions.value = finalList
} }
// 判断是否还有更多数据 // 判断是否还有更多数据
parentHasMore.value = parentOptions.value.length < total parentHasMore.value = parentOptions.value.length < total
} else { } else {
const state = childDropdownStates.value.get(index!) const state = childDropdownStates.value.get(rowKey!)
if (!state) return if (!state) return
if (isLoadMore) { if (isLoadMore) {
@ -366,7 +383,15 @@ const fetchMaterialOptions = async (
const newItems = list.filter(m => !existingIds.has(m.id)) const newItems = list.filter(m => !existingIds.has(m.id))
state.options.push(...newItems) state.options.push(...newItems)
} else { } else {
state.options = list // ★ 修复回显丢失:先通过 rowKey 精准找到当前行,再检查选中项是否在列表中
const currentRow = form.children.find(c => c.rowKey === rowKey)
const currentSelectedId = currentRow?.child_id
let finalList = [...list]
if (currentSelectedId && !list.find(m => m.id === currentSelectedId)) {
const existing = state.options.find(m => m.id === currentSelectedId)
if (existing) finalList.unshift(existing)
}
state.options = finalList
} }
state.hasMore = state.options.length < total state.hasMore = state.options.length < total
} }
@ -384,27 +409,27 @@ const fetchMaterialOptions = async (
const handleRemoteSearch = ( const handleRemoteSearch = (
query: string, query: string,
type: 'parent' | 'child', type: 'parent' | 'child',
index?: number rowKey?: number
) => { ) => {
if (type === 'parent') { if (type === 'parent') {
parentQueryParams.keyword = query parentQueryParams.keyword = query
parentQueryParams.page = 1 parentQueryParams.page = 1
parentHasMore.value = true parentHasMore.value = true
fetchMaterialOptions('parent') fetchMaterialOptions('parent')
} else if (type === 'child' && index !== undefined) { } else if (type === 'child' && rowKey !== undefined) {
const state = childDropdownStates.value.get(index) const state = childDropdownStates.value.get(rowKey)
if (!state) return if (!state) return
state.queryParams.keyword = query state.queryParams.keyword = query
state.queryParams.page = 1 state.queryParams.page = 1
state.hasMore = true state.hasMore = true
fetchMaterialOptions('child', index) fetchMaterialOptions('child', rowKey)
} }
} }
// ============================================================ // ============================================================
// 【改造】下拉框展开/收起处理(重置分页 + 预加载第一页) // 【改造】下拉框展开/收起处理(重置分页 + 预加载第一页)
// ============================================================ // ============================================================
const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?: number) => { const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?: number) => {
if (!visible) return if (!visible) return
if (type === 'parent') { if (type === 'parent') {
@ -412,20 +437,20 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
parentQueryParams.keyword = '' parentQueryParams.keyword = ''
parentHasMore.value = true parentHasMore.value = true
fetchMaterialOptions('parent') fetchMaterialOptions('parent')
} else if (type === 'child' && index !== undefined) { } else if (type === 'child' && rowKey !== undefined) {
// 确保该行下拉状态已初始化 // 确保该行下拉状态已初始化
if (!childDropdownStates.value.has(index)) { if (!childDropdownStates.value.has(rowKey)) {
childDropdownStates.value.set(index, { childDropdownStates.value.set(rowKey, {
options: [], options: [],
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' }, queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
hasMore: true hasMore: true
}) })
} }
const state = childDropdownStates.value.get(index)! const state = childDropdownStates.value.get(rowKey)!
state.queryParams.page = 1 state.queryParams.page = 1
state.queryParams.keyword = '' state.queryParams.keyword = ''
state.hasMore = true state.hasMore = true
fetchMaterialOptions('child', index) fetchMaterialOptions('child', rowKey)
} }
// 延迟 50ms 等待弹窗 DOM 完全渲染 // 延迟 50ms 等待弹窗 DOM 完全渲染
@ -433,7 +458,7 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
// 动态拼接精确的选择器 // 动态拼接精确的选择器
const exactSelector = type === 'parent' const exactSelector = type === 'parent'
? '.parent-popper .el-select-dropdown__wrap' ? '.parent-popper .el-select-dropdown__wrap'
: `.child-popper-${index} .el-select-dropdown__wrap`; : `.child-popper-${rowKey} .el-select-dropdown__wrap`;
const popperWrap = document.querySelector(exactSelector) as HTMLElement; const popperWrap = document.querySelector(exactSelector) as HTMLElement;
@ -450,9 +475,9 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
if (scrollHeight - scrollTop - clientHeight <= 10) { if (scrollHeight - scrollTop - clientHeight <= 10) {
if (type === 'parent') { if (type === 'parent') {
loadMoreParent(); loadMoreParent();
} else if (type === 'child' && index !== undefined) { } else if (type === 'child' && rowKey !== undefined) {
// 触发子件加载 // 触发子件加载
loadMoreChild(popperWrap, index); loadMoreChild(popperWrap, rowKey);
} }
} }
}; };
@ -470,20 +495,20 @@ const loadMoreParent = () => {
fetchMaterialOptions('parent', undefined, true) fetchMaterialOptions('parent', undefined, true)
} }
const loadMoreChild = (_el: HTMLElement, index: number) => { const loadMoreChild = (_el: HTMLElement, rowKey: number) => {
const state = childDropdownStates.value.get(index) const state = childDropdownStates.value.get(rowKey)
if (!state) return if (!state) return
if (selectLoading.value || !state.hasMore) return if (selectLoading.value || !state.hasMore) return
state.queryParams.page++ state.queryParams.page++
fetchMaterialOptions('child', index, true) fetchMaterialOptions('child', rowKey, true)
} }
// ============================================================ // ============================================================
// 【改造】初始化子件行下拉状态 // 【改造】初始化子件行下拉状态
// ============================================================ // ============================================================
const initChildDropdownState = (index: number) => { const initChildDropdownState = (rowKey: number) => {
if (!childDropdownStates.value.has(index)) { if (!childDropdownStates.value.has(rowKey)) {
childDropdownStates.value.set(index, { childDropdownStates.value.set(rowKey, {
options: [], options: [],
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' }, queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
hasMore: true hasMore: true
@ -500,7 +525,7 @@ const filteredChildren = computed(() => {
} }
const kw = childSearchKeyword.value.toLowerCase() const kw = childSearchKeyword.value.toLowerCase()
return form.children.filter(child => { return form.children.filter(child => {
const state = childDropdownStates.value.get(form.children.indexOf(child)) const state = childDropdownStates.value.get(child.rowKey)
const material = state?.options.find(m => m.id === child.child_id) const material = state?.options.find(m => m.id === child.child_id)
if (!material) return false if (!material) return false
const name = (material.name || '').toLowerCase() const name = (material.name || '').toLowerCase()
@ -510,10 +535,11 @@ const filteredChildren = computed(() => {
}) })
// 获取子件规格(从 childDropdownStates 缓存中查找) // 获取子件规格(从 childDropdownStates 缓存中查找)
const getChildSpec = (index: number): string => { const getChildSpec = (rowKey: number): string => {
const state = childDropdownStates.value.get(index) const state = childDropdownStates.value.get(rowKey)
if (!state || !form.children[index]?.child_id) return '' const row = form.children.find(c => c.rowKey === rowKey)
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id) if (!state || !row?.child_id) return ''
const material = state.options.find((m: MaterialBase) => m.id === row.child_id)
return material?.spec || '' return material?.spec || ''
} }
@ -677,8 +703,9 @@ const loadDetail = async (bomNo: string, version: string) => {
const res = await getBomDetail(bomNo, version) const res = await getBomDetail(bomNo, version)
if (res.code === 200) { if (res.code === 200) {
const data = res.data const data = res.data
// 1. 映射子件基本数据 // 1. 映射子件基本数据(使用 idx 生成唯一 rowKey
form.children = data.children.map((child: any) => ({ form.children = data.children.map((child: any, idx: number) => ({
rowKey: idx, // 用数组索引作为唯一标识(编辑场景下不会增删行)
child_id: child.child_id, child_id: child.child_id,
dosage: child.dosage, dosage: child.dosage,
remark: child.remark || '' remark: child.remark || ''
@ -686,7 +713,7 @@ const loadDetail = async (bomNo: string, version: string) => {
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题 // 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
form.children.forEach((child, idx) => { form.children.forEach((child, idx) => {
initChildDropdownState(idx) initChildDropdownState(idx) // rowKey === idx编辑场景下唯一
if (child.child_id) { if (child.child_id) {
const state = childDropdownStates.value.get(idx)! const state = childDropdownStates.value.get(idx)!
@ -755,26 +782,17 @@ const resetForm = () => {
} }
const addChild = () => { const addChild = () => {
const idx = form.children.length const rowKey = Date.now() // 生成唯一标识,不再使用数组长度
form.children.push({ child_id: null, dosage: 0, remark: '' }) form.children.push({ rowKey, child_id: null, dosage: 0, remark: '' })
initChildDropdownState(idx) initChildDropdownState(rowKey)
} }
const removeChild = (idx: number) => { const removeChild = (rowKey: number) => {
form.children.splice(idx, 1) // 通过 rowKey 找到并删除该行(不再依赖数组索引)
// 清理该行下拉状态(需要重新索引后续行的状态) const idx = form.children.findIndex(c => c.rowKey === rowKey)
rebuildChildDropdownStates() if (idx !== -1) form.children.splice(idx, 1)
} // 直接删除该行的下拉状态(无需重建索引)
childDropdownStates.value.delete(rowKey)
// 重建子件下拉状态索引(删除行后需要重新编号)
const rebuildChildDropdownStates = () => {
const newMap = new Map<number, ChildDropdownState>()
form.children.forEach((_, idx) => {
if (childDropdownStates.value.has(idx)) {
newMap.set(idx, childDropdownStates.value.get(idx)!)
}
})
childDropdownStates.value = newMap
} }
const submitForm = async () => { const submitForm = async () => {

View File

@ -80,12 +80,9 @@ const onLogin = async () => {
const success = await userStore.handleLogin(loginForm) const success = await userStore.handleLogin(loginForm)
if (success) { if (success) {
// [新增] 2. 登录成功后,立即拉取当前用户的权限字典
// 这样进入 Dashboard 时,所有按钮/列的显示状态就已经确定了
await permissionStore.loadPermissions() await permissionStore.loadPermissions()
// 直接跳转并触发完整页面重载,干净重置 Dify Embed Token
// 3. 跳转 window.location.href = '/dashboard'
router.push('/dashboard')
} else { } else {
// 失败(业务逻辑拒绝):弹出模态框 // 失败(业务逻辑拒绝):弹出模态框
showLoginFailAlert('用户名或密码错误') showLoginFailAlert('用户名或密码错误')

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,13 @@
/> />
</el-dialog> </el-dialog>
<!-- 拍照识图弹窗 -->
<ImageSearchDialog
v-model="imageSearchVisible"
@use="handleImageSearchUse"
@view="handleImageSearchView"
/>
<!-- 预警设置弹窗 --> <!-- 预警设置弹窗 -->
<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">
@ -632,8 +642,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } from 'vue'; import { ref, reactive, onMounted, nextTick, computed, watch } 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 +665,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 +728,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 +1598,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 +1699,21 @@ const handleCameraConfirm = async (file: File) => {
} }
}; };
// 点击"查看详情"直接在当前页面应用规格型号并搜索
const handleImageSearchView = (item: any) => {
// 1. 关闭以图搜图弹窗
imageSearchVisible.value = false;
// 2. 将选中的规格型号填入搜索表单的 keyword 中
queryParams.keyword = item.spec_model;
// 3. 触发列表的查询函数,刷新表格数据
handleQuery();
// 4. 给出友好提示
ElMessage.success(`已应用物料规格: ${item.spec_model} 进行搜索`);
};
const addCondition = () => { const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' }); advancedConditions.value.push({ field: '', operator: '', value: '' });
}; };
@ -1715,17 +1736,27 @@ const resetAdvancedFilter = () => {
getList(); getList();
}; };
onMounted(() => { // 以图搜图跳转:监听路由 keyword 参数,自动搜索并清理 URL
// 1. 修复背景联动:直接对 reactive 对象赋值 watch(
if (route.query.keyword) { () => route.query.keyword,
queryParams.keyword = route.query.keyword as string; (newKeyword) => {
if (newKeyword) {
queryParams.keyword = newKeyword as string;
queryParams.searchField = 'all'; queryParams.searchField = 'all';
}
// 先根据权限初始化列显示状态
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList(); getList();
// 清理 URL 参数,防止刷新后重复触发搜索
router.replace({ path: route.path, query: {} });
}
},
{ immediate: true }
);
onMounted(() => {
initColumnPermissions();
// 无外部 keyword 参数时执行默认查询;有 keyword 则由上方的 watch 接管
if (!route.query.keyword) {
getList();
}
getOptionsList(); getOptionsList();
// 2. 修复弹窗锁定逻辑 // 2. 修复弹窗锁定逻辑

View File

@ -226,11 +226,7 @@
<el-icon><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon> 打印
</el-button> </el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220"> <el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
<template #reference>
<el-button link type="danger" size="default" v-permission="'inbound_buy:delete'">删除</el-button>
</template>
</el-popconfirm>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -456,7 +452,8 @@
:http-request="(opts) => customUpload(opts, 'arrival_photo')" :http-request="(opts) => customUpload(opts, 'arrival_photo')"
:on-preview="handlePreviewPicture" :on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')"
:before-upload="beforeAvatarUpload"> :before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -475,7 +472,8 @@
:http-request="(opts) => customUpload(opts, 'inspection_report')" :http-request="(opts) => customUpload(opts, 'inspection_report')"
:on-preview="handlePreviewPicture" :on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'inspection_report')" :on-remove="(file) => handleRemoveImage(file, 'inspection_report')"
:before-upload="beforeAvatarUpload"> :before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -1631,7 +1629,39 @@ const handleSortChange = ({ column, prop, order }: any) => {
fetchData() fetchData()
} }
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } } const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除采购入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteBuyInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" 吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
// ------------------------------------ // ------------------------------------
// 打印逻辑 // 打印逻辑

View File

@ -227,7 +227,7 @@
<el-icon><Printer/></el-icon> <el-icon><Printer/></el-icon>
</el-button> </el-button>
<el-button link type="primary" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm> <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -389,7 +389,7 @@
<el-col :span="dialogStatus === 'update' ? 12 : 18"> <el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="product_photo"> <el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container" id="upload-product_photo"> <div class="upload-container" id="upload-product_photo">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -403,7 +403,7 @@
<el-col :span="12"> <el-col :span="12">
<el-form-item label="质量报告" prop="quality_report_link"> <el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container" id="upload-quality_report_link"> <div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -416,7 +416,7 @@
<el-col :span="12"> <el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link"> <el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container" id="upload-inspection_report_link"> <div class="upload-container" id="upload-inspection_report_link">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -572,7 +572,7 @@
import { ref, reactive, onMounted, watch, computed } from 'vue' import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue' import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus' import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import request from '@/utils/request' import request from '@/utils/request'
import { import {
@ -1358,7 +1358,40 @@ const submitForm = async () => {
}) })
} }
const handleDelete = async (row: any) => { try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch(e) { ElMessage.error('删除失败') } } const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteProductInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" `,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
const handlePrint = async (row: any) => { const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = '' printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku } currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku }

View File

@ -250,11 +250,7 @@
<el-icon><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon> 打印
</el-button> </el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220"> <el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
<template #reference>
<el-button link type="danger" size="default">删除</el-button>
</template>
</el-popconfirm>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -468,7 +464,7 @@
<el-col :span="24"> <el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo"> <el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container" id="upload-arrival_photo"> <div class="upload-container" id="upload-arrival_photo">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -480,7 +476,7 @@
<el-col :span="24"> <el-col :span="24">
<el-form-item label="质量报告" prop="quality_report_link"> <el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container" id="upload-quality_report_link"> <div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -627,7 +623,7 @@
import {ref, reactive, onMounted, watch, computed} from 'vue' import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue' import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import {ElMessage, ElLoading} from 'element-plus' import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import request from '@/utils/request' import request from '@/utils/request'
import { import {
@ -1438,7 +1434,40 @@ const submitForm = async () => {
}) })
} }
const handleDelete = async (row: any) => { try { await deleteSemiInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } } const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除半成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteSemiInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" 吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
const handlePrint = async (row: any) => { const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = '' printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku } currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }

View File

@ -1 +0,0 @@
fix: send correct numeric user_id to stocktake draft api to prevent 500 error