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

View File

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

View File

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

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 = {}
for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
if attr.history.has_changes():
old_val = attr.history.deleted[0] if attr.history.deleted 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)
snap = {}
for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
@ -180,6 +183,8 @@ def after_insert_listener(mapper, connection, target):
state = inspect(target)
snap = {}
for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'insert', {'created': snap})

View File

@ -1,5 +1,6 @@
# app/models/base.py
from app.extensions import db
from pgvector.sqlalchemy import Vector
import json
@ -34,6 +35,9 @@ class MaterialBase(db.Model):
# 强制质检标记(采购入库时必须上传检测报告)
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
from app.extensions import db
from pgvector.sqlalchemy import Vector
import json
# 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase
@ -55,6 +56,9 @@ class StockBuy(db.Model):
# 全局打印流水号
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')

View File

@ -1,5 +1,6 @@
# app/models/inbound/product.py
from app.extensions import db
from pgvector.sqlalchemy import Vector
import json
from app.models.base import MaterialBase
@ -58,6 +59,9 @@ class StockProduct(db.Model):
# 全局打印流水号
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')

View File

@ -1,5 +1,6 @@
# app/models/inbound/semi.py
from app.extensions import db
from pgvector.sqlalchemy import Vector
import json
from app.models.base import MaterialBase
@ -56,6 +57,9 @@ class StockSemi(db.Model):
# 全局打印流水号
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')

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()
if not target_pairs:
return []
# 2. 聚合查询详情
# 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称解决步骤3过滤遗漏问题
results = []
for bom_no, version in target_pairs:
# ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格
child_alias = db.aliased(MaterialBase)
summary = db.session.query(
BomTable.parent_id,
MaterialBase.name.label('parent_name'),
MaterialBase.spec_model.label('parent_spec'),
MaterialBase.category.label('parent_category'),
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(
MaterialBase, BomTable.parent_id == MaterialBase.id
).outerjoin(
child_alias, BomTable.child_id == child_alias.id
).filter(
BomTable.bom_no == bom_no,
BomTable.version == version
@ -174,7 +185,9 @@ class BomService:
'parent_spec': summary.parent_spec or '',
'parent_category': summary.parent_category or '',
'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)
@ -188,6 +201,8 @@ class BomService:
or kw in (r.get('parent_spec') 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('child_names') or '').lower() # ★ 修复:加入子件名称过滤
or kw in (r.get('child_specs') or '').lower() # ★ 同步加入子件规格过滤
]
# 按 parent_category 分组

View File

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

View File

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

View File

@ -12,6 +12,9 @@ import traceback
import json
import io
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
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
@ -555,8 +558,15 @@ class MaterialBaseService:
product_image=json.dumps(data.get('generalImage', [])),
is_enabled=is_enabled_val
)
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()
return new_material
@ -585,7 +595,17 @@ class MaterialBaseService:
if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual'])
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:
@ -652,6 +672,10 @@ class MaterialBaseService:
f"请先清理相关库存或仅‘禁用’此条目。"
)
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id
)
db.session.delete(material)
db.session.commit()
return material_name
@ -1048,14 +1072,15 @@ class MaterialBaseService:
@staticmethod
def get_latest_specs():
"""
获取所有规格型号的最大连号,按连续区间分组返回
获取所有规格型号的分组统计,按规则聚合后返回
- 前缀统一大写处理
- 只有数字完全连续N, N+1, N+2...)才认定为同一组
- 数字不连续时断开,形成新组
- 按每组数量降序排列
- 返回每个连续区间的最大值
- 匹配模式:(前缀)(单数字二级分类位)(纯数字部分),如 OPT12046 -> OPT, 1, 2046
- OPT 系列:使用 前缀+二级分类位 作为分组 Key如 OPT1, OPT2
- 其他前缀:直接使用前缀作为分组 Key
- 返回每个分组的数量、最大号、完整规格名
"""
import re
from collections import defaultdict
# 1. 查询所有不为空的规格型号
specs = MaterialBase.query.filter(
@ -1063,8 +1088,8 @@ class MaterialBaseService:
MaterialBase.spec_model != ''
).all()
# 2. 解析并收集所有有效的 (prefix, num, original_spec)
parsed = []
# 2. 按分组收集所有数字
groups = defaultdict(list)
for material in specs:
spec = material.spec_model
@ -1072,72 +1097,31 @@ class MaterialBaseService:
continue
base_spec = spec.split('/')[0]
match = re.match(r'^([A-Za-z]+)(\d+)$', base_spec)
match = re.match(r'^([A-Za-z]+)(\d)(\d+)$', base_spec)
if not match:
continue
prefix, num_str = match.groups()
prefix, sub_cat, num_str = match.groups()
prefix = prefix.upper()
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 升序排序
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. 构建返回结果
# 3. 生成展示用的统计数据
result = []
for item in intervals:
prefix = item['prefix']
start = item['start']
end = item['end']
for key, items in groups.items():
sorted_items = sorted(items, key=lambda x: x[0])
max_num, max_spec = sorted_items[-1]
result.append({
"group": f"{prefix}({start}-{end})",
"count": item['count'],
"latest": item['latest']
'group': key,
'count': len(sorted_items),
'latest': max_spec,
'max_num': max_num
})
# 4. 按数量降序,再按分组名升序排列
result.sort(key=lambda x: (-x['count'], x['group']))
return result

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError
import traceback
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:
@ -178,6 +181,14 @@ class BuyInboundService:
inspection_report=json.dumps(data.get('inspection_report', []))
)
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()
return new_stock
except Exception as e:
@ -240,7 +251,19 @@ class BuyInboundService:
for k, v in field_mapping.items():
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'])
# 更新税率
@ -283,8 +306,11 @@ class BuyInboundService:
try:
stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
db.session.delete(stock)
db.session.commit()
return material_name

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError
import traceback
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:
@ -184,6 +187,13 @@ class ProductInboundService:
order_id=data.get('order_id')
)
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()
return new_stock
except Exception as e:
@ -213,8 +223,17 @@ class ProductInboundService:
if f in data: setattr(stock, f, data[f])
if 'product_photo' in data:
imgs = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
new_photo_list = data['product_photo']
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:
imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
@ -255,8 +274,11 @@ class ProductInboundService:
try:
stock = StockProduct.query.get(stock_id)
if stock:
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id
)
db.session.delete(stock)
db.session.commit()
return material_name

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError
import traceback
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:
@ -221,6 +224,13 @@ class SemiInboundService:
remark=data.get('remark')
)
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()
return new_stock
except Exception as e:
@ -268,9 +278,17 @@ class SemiInboundService:
setattr(stock, db_attr, data[frontend_key])
if 'arrival_photo' in data:
imgs = data['arrival_photo']
if isinstance(imgs, list):
stock.arrival_photo = json.dumps(imgs)
new_photo_list = data['arrival_photo']
stock.arrival_photo = json.dumps(new_photo_list)
# 保存向量到独立表(全量替换)
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:
imgs = data['quality_report_link']
if isinstance(imgs, list):
@ -344,8 +362,11 @@ class SemiInboundService:
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id
)
db.session.delete(stock)
db.session.commit()
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
# 图片处理核心库
Pillow>=10.0.0
# ONNX 模型本地 CPU 推理
onnxruntime>=1.16.0
# 数值计算ONNX 推理依赖)
numpy>=1.24.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持)
@ -21,4 +25,8 @@ openpyxl>=3.1.2
# [新增] 定时任务调度器 (库存预警每日邮件)
APScheduler==3.10.4
# [新增] 时区处理 (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
# 注意:这里必须写你电脑的局域网 IP
VITE_API_BASE_URL=http://172.16.0.95:8000/api/v1
# 1. 本地局域网测试用(比如让平板连 192.168.9.33
#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,25 +10,64 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
// 获取当前用户的登录凭证 (Token)
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
window.initDifyChatbot = function() {
// 【关键】增加保护检查:确保 DOM 已经就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', performInit);
} else {
performInit();
}
};
window.difyChatbotConfig = {
token: '6T0eTgukUEqzK0iW',
baseUrl: 'http://172.16.0.198:8080',
inputs: {
"user_token": currentToken
},
systemVariables: {},
userVariables: {},
function performInit() {
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 = {
token: '6T0eTgukUEqzK0iW',
baseUrl: 'http://172.16.0.198:8080',
inputs: {
"user_token": currentToken
},
systemVariables: {
"user_id": dynamicUserId
},
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
src="http://172.16.0.198:8080/embed.min.js"
id="6T0eTgukUEqzK0iW"
defer>
</script>
<!--<script-->
<!-- src="http://172.16.0.198:8080/embed.min.js"-->
<!-- id="6T0eTgukUEqzK0iW"-->
<!-- defer>-->
<!--</script>-->
<style>
#dify-chatbot-bubble-button {
@ -38,7 +77,7 @@
/* 变成"独立悬浮窗口" */
#dify-chatbot-bubble-window {
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
/* 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
top: 15vh !important;
left: 20vw !important;
bottom: auto !important;
@ -49,9 +88,9 @@
height: 70vh !important;
border-radius: 12px !important;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important; /* 增加超大弥散阴影,浮现感更强 */
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important;
/* 👇 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
/* 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
resize: both !important;
overflow: hidden !important;
padding-bottom: 16px !important;
@ -91,4 +130,4 @@
});
</script>
</body>
</html>
</html>

View File

@ -20,6 +20,10 @@ onMounted(() => {
if (userStore.token) {
userStore.refreshUserPermissions()
}
// 当 Vue 根组件挂载完毕,确保 Dify 图标一定会被加载
if (typeof (window as any).initDifyChatbot === 'function') {
(window as any).initDifyChatbot()
}
})
// ================================================================
@ -189,7 +193,8 @@ const handleLogout = () => {
.then(async () => {
userStore.logout()
ElMessage({ type: 'success', message: '已安全退出' })
await router.replace('/login')
// 直接原生跳转,重置一切
window.location.href = '/login'
})
.catch(() => {})
}
@ -234,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.26添加AI助手
当前版本:V3.34识图
</span>
</footer>

View File

@ -3,7 +3,6 @@ import request from '@/utils/request'
/**
* 上传文件通用接口
* @param data File 对象 或 FormData 对象
* 适配说明list.vue 中 customUpload 已经封装了 FormData所以这里支持直接传 FormData
*/
export function uploadFile(data: File | FormData) {
let formData: FormData
@ -11,14 +10,12 @@ export function uploadFile(data: File | FormData) {
if (data instanceof FormData) {
formData = data
} else {
// 如果传入的是原始 File 对象,则手动封装
formData = new FormData()
// @ts-ignore
formData.append('file', data)
}
return request({
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
url: '/v1/common/upload',
method: 'post',
data: formData,
@ -29,13 +26,71 @@ export function uploadFile(data: File | FormData) {
}
/**
* 删除文件通用接口 (新增)
* 删除文件通用接口
* @param filename 文件名 (例如: a1b2c3d4.jpg)
*/
export function deleteFile(filename: string) {
return request({
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
url: `/v1/common/files/${filename}`,
method: 'delete'
})
}
// ============================================================================
// 以图搜图 API
// ============================================================================
/** 以图搜图返回的物料项 */
export interface ImageSearchItem {
product_id: number
product_name: string
spec_model: string
image_url: string
similarity: number
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)
}
// [Dify] 登录成功,重新初始化 DifyToken 变化时 Dify 会开辟新会话,解决会话串号问题)
if (typeof window.initDifyChatbot === 'function') {
window.initDifyChatbot()
}
// 登录成功后,根据角色获取权限
if (role.value) {
try {
@ -110,6 +115,11 @@ export const useUserStore = defineStore('user', () => {
const setToken = (newToken: string) => {
token.value = 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 (硬盘)
localStorage.removeItem('access_token')
// [Dify] 退出登录时,彻底销毁桌面上的 Dify 聊天窗口,防止信息泄露或报错
document.querySelectorAll('[id^="dify-chatbot-"]').forEach(el => el.remove())
// 清空其他本地存储
localStorage.removeItem('refresh_token')
localStorage.removeItem('token')
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-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;">
<el-select
@ -174,14 +174,14 @@
remote
reserve-keyword
style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
:loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
>
<el-option
v-for="item in getChildOptions($index)"
v-for="item in getChildOptions(row.rowKey)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
@ -197,7 +197,7 @@
type="primary"
link
: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;"
/>
</el-tooltip>
@ -218,8 +218,8 @@
</el-table-column>
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
<template #default="{ $index }">
<el-button type="danger" link @click="removeChild($index)">删</el-button>
<template #default="{ row }">
<el-button type="danger" link @click="removeChild(row.rowKey)">删</el-button>
</template>
</el-table-column>
</el-table>
@ -240,7 +240,7 @@
</template>
<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 { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
@ -266,6 +266,7 @@ interface MaterialBase {
spec: string
}
interface ChildRow {
rowKey: number // 唯一标识,替代 $index 作为 Map key
child_id: number | null
dosage: number
remark: string
@ -288,6 +289,15 @@ const activeCategories = ref([]) // 默认全部展开
const searchKeyword = 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 (
type: 'parent' | 'child',
index?: number,
rowKey?: number,
isLoadMore = false
) => {
// 子件行需要 index
if (type === 'child' && index === undefined) return
// 子件行需要 rowKey唯一标识不再依赖数组索引
if (type === 'child' && rowKey === undefined) return
const params = type === 'parent'
? parentQueryParams
: childDropdownStates.value.get(index!)?.queryParams
: childDropdownStates.value.get(rowKey!)?.queryParams
if (!params) return
@ -353,12 +363,19 @@ const fetchMaterialOptions = async (
const newItems = list.filter(m => !existingIds.has(m.id))
parentOptions.value.push(...newItems)
} 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
} else {
const state = childDropdownStates.value.get(index!)
const state = childDropdownStates.value.get(rowKey!)
if (!state) return
if (isLoadMore) {
@ -366,7 +383,15 @@ const fetchMaterialOptions = async (
const newItems = list.filter(m => !existingIds.has(m.id))
state.options.push(...newItems)
} 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
}
@ -384,27 +409,27 @@ const fetchMaterialOptions = async (
const handleRemoteSearch = (
query: string,
type: 'parent' | 'child',
index?: number
rowKey?: number
) => {
if (type === 'parent') {
parentQueryParams.keyword = query
parentQueryParams.page = 1
parentHasMore.value = true
fetchMaterialOptions('parent')
} else if (type === 'child' && index !== undefined) {
const state = childDropdownStates.value.get(index)
} else if (type === 'child' && rowKey !== undefined) {
const state = childDropdownStates.value.get(rowKey)
if (!state) return
state.queryParams.keyword = query
state.queryParams.page = 1
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 (type === 'parent') {
@ -412,20 +437,20 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
parentQueryParams.keyword = ''
parentHasMore.value = true
fetchMaterialOptions('parent')
} else if (type === 'child' && index !== undefined) {
} else if (type === 'child' && rowKey !== undefined) {
// 确保该行下拉状态已初始化
if (!childDropdownStates.value.has(index)) {
childDropdownStates.value.set(index, {
if (!childDropdownStates.value.has(rowKey)) {
childDropdownStates.value.set(rowKey, {
options: [],
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
hasMore: true
})
}
const state = childDropdownStates.value.get(index)!
const state = childDropdownStates.value.get(rowKey)!
state.queryParams.page = 1
state.queryParams.keyword = ''
state.hasMore = true
fetchMaterialOptions('child', index)
fetchMaterialOptions('child', rowKey)
}
// 延迟 50ms 等待弹窗 DOM 完全渲染
@ -433,7 +458,7 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
// 动态拼接精确的选择器
const exactSelector = type === 'parent'
? '.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;
@ -450,9 +475,9 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
if (scrollHeight - scrollTop - clientHeight <= 10) {
if (type === 'parent') {
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)
}
const loadMoreChild = (_el: HTMLElement, index: number) => {
const state = childDropdownStates.value.get(index)
const loadMoreChild = (_el: HTMLElement, rowKey: number) => {
const state = childDropdownStates.value.get(rowKey)
if (!state) return
if (selectLoading.value || !state.hasMore) return
state.queryParams.page++
fetchMaterialOptions('child', index, true)
fetchMaterialOptions('child', rowKey, true)
}
// ============================================================
// 【改造】初始化子件行下拉状态
// ============================================================
const initChildDropdownState = (index: number) => {
if (!childDropdownStates.value.has(index)) {
childDropdownStates.value.set(index, {
const initChildDropdownState = (rowKey: number) => {
if (!childDropdownStates.value.has(rowKey)) {
childDropdownStates.value.set(rowKey, {
options: [],
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
hasMore: true
@ -500,7 +525,7 @@ const filteredChildren = computed(() => {
}
const kw = childSearchKeyword.value.toLowerCase()
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)
if (!material) return false
const name = (material.name || '').toLowerCase()
@ -510,10 +535,11 @@ const filteredChildren = computed(() => {
})
// 获取子件规格(从 childDropdownStates 缓存中查找)
const getChildSpec = (index: number): string => {
const state = childDropdownStates.value.get(index)
if (!state || !form.children[index]?.child_id) return ''
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id)
const getChildSpec = (rowKey: number): string => {
const state = childDropdownStates.value.get(rowKey)
const row = form.children.find(c => c.rowKey === rowKey)
if (!state || !row?.child_id) return ''
const material = state.options.find((m: MaterialBase) => m.id === row.child_id)
return material?.spec || ''
}
@ -677,8 +703,9 @@ const loadDetail = async (bomNo: string, version: string) => {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
const data = res.data
// 1. 映射子件基本数据
form.children = data.children.map((child: any) => ({
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey
form.children = data.children.map((child: any, idx: number) => ({
rowKey: idx, // 用数组索引作为唯一标识(编辑场景下不会增删行)
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
@ -686,7 +713,7 @@ const loadDetail = async (bomNo: string, version: string) => {
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
form.children.forEach((child, idx) => {
initChildDropdownState(idx)
initChildDropdownState(idx) // rowKey === idx编辑场景下唯一
if (child.child_id) {
const state = childDropdownStates.value.get(idx)!
@ -755,26 +782,17 @@ const resetForm = () => {
}
const addChild = () => {
const idx = form.children.length
form.children.push({ child_id: null, dosage: 0, remark: '' })
initChildDropdownState(idx)
const rowKey = Date.now() // 生成唯一标识,不再使用数组长度
form.children.push({ rowKey, child_id: null, dosage: 0, remark: '' })
initChildDropdownState(rowKey)
}
const removeChild = (idx: number) => {
form.children.splice(idx, 1)
// 清理该行下拉状态(需要重新索引后续行的状态)
rebuildChildDropdownStates()
}
// 重建子件下拉状态索引(删除行后需要重新编号)
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 removeChild = (rowKey: number) => {
// 通过 rowKey 找到并删除该行(不再依赖数组索引)
const idx = form.children.findIndex(c => c.rowKey === rowKey)
if (idx !== -1) form.children.splice(idx, 1)
// 直接删除该行的下拉状态(无需重建索引)
childDropdownStates.value.delete(rowKey)
}
const submitForm = async () => {

View File

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

View File

@ -84,6 +84,9 @@
<el-button type="primary" plain @click="handleQuery">搜索</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
v-model:visible="advancedFilterVisible"
placement="bottom"
@ -564,6 +567,13 @@
/>
</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-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
@ -632,8 +642,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
import { ref, reactive, onMounted, nextTick, computed, watch } from '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 type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
@ -655,6 +665,8 @@ import {
import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload';
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();
@ -716,6 +728,7 @@ const isUploading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
const advancedFilterVisible = ref(false);
const imageSearchVisible = ref(false);
const advancedConditions = ref([{ field: '', operator: '', value: '' }]);
const fieldOptions = computed(() => {
const allFields = [
@ -1585,15 +1598,8 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
if (res.code === 200) {
const newUrl = res.data.url
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('上传成功')
onSuccess(res)
onSuccess(res) // el-upload v-model 自动更新 fileList无需手动 push
} else {
ElMessage.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 = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' });
};
@ -1715,17 +1736,27 @@ const resetAdvancedFilter = () => {
getList();
};
onMounted(() => {
// 1. 修复背景联动:直接对 reactive 对象赋值
if (route.query.keyword) {
queryParams.keyword = route.query.keyword as string;
queryParams.searchField = 'all';
}
// 以图搜图跳转:监听路由 keyword 参数,自动搜索并清理 URL
watch(
() => route.query.keyword,
(newKeyword) => {
if (newKeyword) {
queryParams.keyword = newKeyword as string;
queryParams.searchField = 'all';
getList();
// 清理 URL 参数,防止刷新后重复触发搜索
router.replace({ path: route.path, query: {} });
}
},
{ immediate: true }
);
// 先根据权限初始化列显示状态
onMounted(() => {
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList();
// 无外部 keyword 参数时执行默认查询;有 keyword 则由上方的 watch 接管
if (!route.query.keyword) {
getList();
}
getOptionsList();
// 2. 修复弹窗锁定逻辑

View File

@ -226,11 +226,7 @@
<el-icon><Printer/></el-icon> 打印
</el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default" v-permission="'inbound_buy:delete'">删除</el-button>
</template>
</el-popconfirm>
<el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -456,7 +452,8 @@
:http-request="(opts) => customUpload(opts, 'arrival_photo')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'arrival_photo')"
:before-upload="beforeAvatarUpload">
:before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<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')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'inspection_report')"
:before-upload="beforeAvatarUpload">
:before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<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()
}
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-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>
</el-table-column>
</el-table>
@ -389,7 +389,7 @@
<el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="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-upload>
<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-form-item label="质量报告" prop="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-upload>
<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-form-item label="检测报告" prop="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-upload>
<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 { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
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) => {
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 }

View File

@ -250,11 +250,7 @@
<el-icon><Printer/></el-icon> 打印
</el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default">删除</el-button>
</template>
</el-popconfirm>
<el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -468,7 +464,7 @@
<el-col :span="24">
<el-form-item label="到货图片" prop="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-upload>
<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-form-item label="质量报告" prop="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-upload>
<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 {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {ElMessage, ElLoading} from 'element-plus'
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
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) => {
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 }

View File

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