25 Commits

Author SHA1 Message Date
dxc
05aff2dd83 V3.36版本修改,基础信息列展示规则,分页数量修改,类别搜索修改 2026-05-29 11:23:05 +08:00
DXC
c1d364b786 基础信息页:新增列展示本地缓存 + 全选功能 2026-05-29 10:51:52 +08:00
dxc
6e50762da6 服务器数据库端口暴露宿主机修改 2026-05-28 11:49:06 +08:00
dxc
b4945cbba4 修复服务器端打印字体丢失问题,修复上传脚本打包过大问题 2026-05-27 09:57:01 +08:00
dxc
7d828d3ebf 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 12:01:58 +08:00
dxc
7e09e9de31 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 11:28:42 +08:00
dxc
fb5b8d873b 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 11:28:26 +08:00
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
37 changed files with 2153 additions and 293 deletions

Binary file not shown.

View File

@ -8,53 +8,81 @@ REMOTE_BACKUP_BASE="$REMOTE_DIR/data_copy"
REMOTE_BACKUP_DIR="$REMOTE_BACKUP_BASE/$TIMESTAMP" REMOTE_BACKUP_DIR="$REMOTE_BACKUP_BASE/$TIMESTAMP"
echo "===================================================" echo "==================================================="
echo "🚀 开始增量部署 (仅代码,不影响数据库和图片)" echo "🚀 开始自动化增量部署 (含自我修复与依赖瘦身)"
echo "===================================================" echo "==================================================="
# 1. 远端备份 (使用 sudo 提权) # 1. 远端备份与环境急救
echo "[1/4] 正在服务器上备份旧代码并清理多余备份 (可能需要输入服务器密码)..." echo "[1/4] 正在服务器上急救环境并备份旧代码 (可能需要输入密码)..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \ ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
cd $REMOTE_DIR && \ cd $REMOTE_DIR && \
echo '>> 检查并修复缺失目录 (防止 tar 崩溃)...' && \
sudo mkdir -p inventory-backend inventory-web && \
echo '>> 执行代码备份...' && \
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz inventory-backend inventory-web docker-compose.prod.yml && \ sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz inventory-backend inventory-web docker-compose.prod.yml && \
echo '>> 执行清理:仅保留 data_copy 下最新的 2 个备份...' && \ echo '>> 执行清理:仅保留 data_copy 下最新的 2 个备份...' && \
cd $REMOTE_BACKUP_BASE && \ cd $REMOTE_BACKUP_BASE && \
sudo sh -c 'ls -dt */ | tail -n +3 | xargs -I {} rm -rf {}'" sudo sh -c 'ls -dt */ 2>/dev/null | tail -n +3 | xargs -I {} rm -rf {} || true'"
if [ $? -ne 0 ]; then echo "❌ 服务器备份或清理失败,终止部署!"; exit 1; fi if [ $? -ne 0 ]; then echo "❌ 服务器备份或清理失败,终止部署!"; exit 1; fi
# 2. 本地打包 (新增了精准拦截本地图片和本地数据库) # 2. 本地精准打包 (绝对屏蔽垃圾文件)
echo "[2/4] 正在本地打包新代码 (自动剔除本地图片、数据库和缓存)..." echo "[2/4] 正在本地打包新代码 (执行终极瘦身)..."
tar -czf deploy.tar.gz \ tar -czf deploy.tar.gz \
--exclude='node_modules' \ --exclude="*/node_modules" \
--exclude='.venv' \ --exclude="*/venv" \
--exclude='venv' \ --exclude="*/.venv" \
--exclude='__pycache__' \ --exclude="*/__pycache__" \
--exclude='*.pyc' \ --exclude="*.pyc" \
--exclude='.git' \ --exclude="*/.git" \
--exclude='.idea' \ --exclude="*/.idea" \
--exclude='.vscode' \ --exclude="*/.vscode" \
--exclude='pgdata_*' \ --exclude="inventory-backend/uploads" \
--exclude='uploads_*' \ --exclude="inventory-backend/models" \
--exclude='data_copy' \ --exclude="inventory-backend/pgdata" \
--exclude='inventory-backend/uploads' \ --exclude="inventory-backend/pgdata_docker" \
--exclude='inventory-backend/pgdata' \ --exclude="data_copy" \
inventory-backend inventory-web docker-compose.prod.yml inventory-backend inventory-web docker-compose.prod.yml
# 3. 传输到生产环境的 /tmp 目录 (避开权限拦截) # 检查压缩包大小是否正常 (如果超过 50MB 则发出警告)
FILESIZE=$(stat -c%s "deploy.tar.gz" 2>/dev/null || stat -f%z "deploy.tar.gz")
MB_SIZE=$((FILESIZE / 1024 / 1024))
echo ">> 打包完成,当前传输包大小: ${MB_SIZE} MB"
if [ "$MB_SIZE" -gt 50 ]; then
echo "⚠️ 警告:包体积仍大于 50MB可能存在未排除的大文件但继续执行..."
fi
# 3. 传输到生产环境的 /tmp 目录
echo "[3/4] 正在传输代码到服务器的临时目录..." echo "[3/4] 正在传输代码到服务器的临时目录..."
scp deploy.tar.gz $SERVER:/tmp/deploy.tar.gz scp deploy.tar.gz $SERVER:/tmp/deploy.tar.gz
if [ $? -ne 0 ]; then
echo "❌ 传输失败!可能是网络中断,终止部署!"
rm -f deploy.tar.gz
exit 1
fi
# 4. 服务器执行替换与重启 (修复了 docker-compose 找不到命令的问题) # 4. 服务器执行替换与重启
echo "[4/4] 正在生产环境执行热更新 (可能需要输入服务器密码)..." echo "[4/4] 正在生产环境执行热更新 (可能需要输入密码)..."
ssh -t $SERVER "cd $REMOTE_DIR && \ ssh -t $SERVER "cd $REMOTE_DIR && \
echo '>> 移除历史旧目录...' && \
sudo rm -rf inventory-backend_old inventory-web_old && \ sudo rm -rf inventory-backend_old inventory-web_old && \
echo '>> 备份当前目录为 old...' && \
(sudo mv inventory-backend inventory-backend_old 2>/dev/null || true) && \ (sudo mv inventory-backend inventory-backend_old 2>/dev/null || true) && \
(sudo mv inventory-web inventory-web_old 2>/dev/null || true) && \ (sudo mv inventory-web inventory-web_old 2>/dev/null || true) && \
echo '>> 部署新代码...' && \
sudo mv /tmp/deploy.tar.gz . && \ sudo mv /tmp/deploy.tar.gz . && \
sudo tar -xzf deploy.tar.gz && \ sudo tar -xzf deploy.tar.gz && \
echo '>> 重启 Docker 容器...' && \
sudo docker compose -f docker-compose.prod.yml up -d --build backend frontend && \ sudo docker compose -f docker-compose.prod.yml up -d --build backend frontend && \
sudo rm deploy.tar.gz" sudo rm deploy.tar.gz"
echo "===================================================" if [ $? -ne 0 ]; then
echo "✅ 部署完成!请刷新网页查看效果。" echo "❌ 远端启动失败,请检查 Docker 日志!"
echo "===================================================" else
echo "==================================================="
echo "✅ 部署完美完成!请刷新网页查看效果。"
echo "==================================================="
fi
# 扫尾清理本地文件
rm -f deploy.tar.gz

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,8 +11,10 @@ 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
ports:
- "5432:5432"
# --- 后端 (Flask) (保持不变) --- # --- 后端 (Flask) (保持不变) ---
backend: backend:
build: build:
@ -29,7 +31,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

@ -1,8 +1,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 +9,7 @@ services:
POSTGRES_PASSWORD: 1234 POSTGRES_PASSWORD: 1234
POSTGRES_DB: inventory_system POSTGRES_DB: inventory_system
volumes: volumes:
- ./pgdata_docker:/var/lib/postgresql/data - ./pgdata_docker:/var/lib/postgresql/data # 这里保持不变Docker会自动创建这个新文件夹
ports: ports:
- "5435:5432" - "5435:5432"
@ -41,4 +40,4 @@ services:
ports: ports:
- "5175:5173" - "5175:5173"
depends_on: depends_on:
- backend - backend

View File

@ -1,6 +1,12 @@
venv/ .git
__pycache__/ .idea
__pycache__
*.pyc *.pyc
.git/ *.pyo
venv
.venv
env
uploads
pgdata
.env .env
pgdata/ simhei.ttf

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,299 @@
# -*- 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__)
# ============================================================================
# 可配置参数
# ============================================================================
# 以图搜图相似度阈值:余弦距离必须小于此值(距离越小越相似)
# 即余弦相似度 = 1 - 距离,必须 > (1 - SIMILARITY_THRESHOLD)
# 默认 0.25 对应余弦相似度 > 0.75
SIMILARITY_DISTANCE_THRESHOLD = 0.40
# ============================================================================
# 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,
(ie.embedding <=> :query_vector) AS distance
FROM image_embeddings ie
WHERE ie.embedding IS NOT NULL
AND (ie.embedding <=> :query_vector) < :distance_threshold
ORDER BY ie.embedding <=> :query_vector
LIMIT 200
""")
raw_records = db.session.execute(sql, {
"query_vector": query_vector_str,
"distance_threshold": SIMILARITY_DISTANCE_THRESHOLD
}).fetchall()
if not raw_records:
return jsonify({"code": 200, "data": [], "msg": "未找到相似图片(阈值过滤后)"})
# ---------------------------------------------------------
# Step 1: 初步去重(同入库单只保留最相似的图片)
# ---------------------------------------------------------
first_img_seen = {}
unique_records = []
for row in raw_records:
key = (row.module_name, row.target_id)
if key not in first_img_seen:
first_img_seen[key] = True
unique_records.append(row)
# ---------------------------------------------------------
# Step 2: 按物料维度去重(相同物料只保留第一条 = 相似度最高的那条)
# ---------------------------------------------------------
target_ids_by_module = {}
for row in unique_records:
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
# 查询每条记录的 base_id跨 stock_buy/semi/product/material_base
base_id_map = {}
for module in ('stock_buy', 'stock_semi', 'stock_product'):
if module not in target_ids_by_module:
continue
ids = target_ids_by_module[module]
ModelCls = StockBuy if module == 'stock_buy' else (StockSemi if module == 'stock_semi' else StockProduct)
id_col = getattr(ModelCls, 'id')
base_col = getattr(ModelCls, 'base_id')
rows = (
db.session.query(id_col, base_col)
.outerjoin(MaterialBase, base_col == MaterialBase.id)
.filter(id_col.in_(ids))
.all()
)
for rec_id, base_id in rows:
base_id_map[(module, rec_id)] = base_id
if 'material_base' in target_ids_by_module:
for rec_id in target_ids_by_module['material_base']:
base_id_map[('material_base', rec_id)] = rec_id
# 按 base_id 去重:相同物料只保留第一张图
material_seen = {}
final_records = []
for row in unique_records:
base_id = base_id_map.get((row.module_name, row.target_id))
if base_id is not None and base_id in material_seen:
continue
if base_id is not None:
material_seen[base_id] = True
final_records.append(row)
# ---------------------------------------------------------
# Step 3: 批量回填业务数据(基于去重后的 final_records
# ---------------------------------------------------------
target_ids_by_module = {}
for row in final_records:
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',
}
# 组装最终返回(基于 final_records按相似度从高到低
results = []
for row in final_records:
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
@ -249,7 +252,8 @@ class MaterialBaseService:
category = filters.get('category') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
query = query.filter(MaterialBase.category.ilike(category.strip())) # 在末尾拼接 '%' 实现前缀模糊匹配
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
type_val = filters.get('type') type_val = filters.get('type')
if type_val is not None and type_val != '': if type_val is not None and type_val != '':
@ -555,9 +559,23 @@ 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
# 先提交主事务,图片向量异步后台提取
db.session.commit() db.session.commit()
image_list = data.get('generalImage', [])
if isinstance(image_list, list) and image_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_MATERIAL_BASE,
new_material.id,
image_list
)
return new_material return new_material
except Exception as e: except Exception as e:
@ -585,7 +603,24 @@ 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)
# 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
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 +687,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
@ -712,7 +751,8 @@ class MaterialBaseService:
category = filters.get('category') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
filter_conditions.append(MaterialBase.category.ilike(category.strip())) # 同样在末尾拼接 '%'
filter_conditions.append(MaterialBase.category.ilike(f"{category.strip()}%"))
type_val = filters.get('type') type_val = filters.get('type')
if type_val is not None and type_val != '': if type_val is not None and type_val != '':
filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip())) filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip()))
@ -1048,14 +1088,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 +1104,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 +1113,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,7 +181,22 @@ 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
# 先提交主事务(入库单必须落盘),图片向量异步后台提取
db.session.commit() db.session.commit()
photo_list = data.get('arrival_photo', [])
if isinstance(photo_list, list) and photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_BUY,
new_stock.id,
photo_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -240,7 +258,26 @@ 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)
# 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
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 +320,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,7 +187,21 @@ 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
# 先提交主事务,图片向量异步后台提取
db.session.commit() db.session.commit()
if isinstance(photo_list, list) and photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_PRODUCT,
new_stock.id,
photo_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -213,8 +230,24 @@ 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)
# 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
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 +288,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,7 +224,21 @@ 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
# 先提交主事务,图片向量异步后台提取
db.session.commit() db.session.commit()
if isinstance(arrival_list, list) and arrival_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_SEMI,
new_stock.id,
arrival_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -268,9 +285,24 @@ 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) # 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
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 +376,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

Binary file not shown.

View File

@ -0,0 +1,279 @@
# -*- 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
import cv2
# ============================================================================
# 全局模型单例(项目启动时加载一次)
# ============================================================================
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
# ============================================================================
# 背景去除配置HSV 色彩空间阈值
# ============================================================================
# OpenCV HSV: H∈[0,180], S∈[0,255], V∈[0,255]
# 注意OpenCV 中 H 通道范围是 0-180是 OpenCV 自己的标准,和美术的 0-360 对应)
# 绿色背景阈值(工业绿幕常用色)
# H: 35~85 对应绿色谱(浅绿到深绿)
# S: 低饱和度35到高饱和度255
# V: 明暗均可30~255
BG_GREEN_LOWER = np.array([35, 35, 30])
BG_GREEN_UPPER = np.array([90, 255, 255])
# 白色/浅色背景阈值(高明度、低饱和度区域)
# H: 不限制0~180只看 S 和 V
# S: 很低的饱和度0~35→ 接近纯灰/白色
# V: 高明度180~255
BG_WHITE_LOWER = np.array([0, 0, 180])
BG_WHITE_UPPER = np.array([180, 40, 255])
# 中性灰填充色BGR → 转换后 RGB 也是 128,128,128
NEUTRAL_GRAY_BGR = (128, 128, 128)
def _remove_background(image: Image.Image) -> Image.Image:
"""
利用 OpenCV HSV 色彩空间识别并替换背景为中性灰
支持两种背景类型:
1. 工业绿幕/绿色背景H: 35~90
2. 白色/浅色背景(高亮度、低饱和度)
逻辑:
- 将 PIL Image 转为 OpenCV 格式 (RGB → BGR)
- 转 HSV分别生成绿色掩码和白色掩码
- 合并掩码后,按掩码将背景区域替换为中性灰
- 还原为 PIL Image (BGR → RGB) 返回
参数:
image: PIL Image (RGB, uint8)
返回:
处理后的 PIL Image (RGB, uint8)
"""
# PIL (RGB) → OpenCV (BGR)
img_bgr = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
# 转入 HSV 色彩空间
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
# 生成掩码 1绿色背景
mask_green = cv2.inRange(hsv, BG_GREEN_LOWER, BG_GREEN_UPPER)
# 生成掩码 2白色/浅色背景
mask_white = cv2.inRange(hsv, BG_WHITE_LOWER, BG_WHITE_UPPER)
# 合并掩码(任意一种背景都替换)
mask_combined = cv2.bitwise_or(mask_green, mask_white)
# 形态学处理:消除噪点(小面积背景噪点填平)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_combined = cv2.morphologyEx(mask_combined, cv2.MORPH_CLOSE, kernel)
mask_combined = cv2.morphologyEx(mask_combined, cv2.MORPH_OPEN, kernel)
# 背景替换:将掩码区域填充为中性灰
# 其中 mask_combined=255 的区域为背景,替换为 NEUTRAL_GRAY_BGR
img_bgr_no_bg = img_bgr.copy()
img_bgr_no_bg[mask_combined > 0] = NEUTRAL_GRAY_BGR
# OpenCV (BGR) → PIL (RGB)
result = Image.fromarray(cv2.cvtColor(img_bgr_no_bg, cv2.COLOR_BGR2RGB))
return result
def _letterbox_image(image: Image.Image, size: int = 224) -> Image.Image:
"""
Letterbox 预处理:等比例缩放 + 灰色填充,保持内容不变形
- 将原图最长边缩放到 224
- 短边按相同比例缩放
- 不足部分用 RGB(128,128,128) 灰色填充至 224x224
参数:
image: PIL Image 对象
size: 目标尺寸,默认 224
返回:
224x224 PIL Image
"""
w, h = image.size
# 计算缩放比例,使最长边等于 size
scale = size / max(w, h)
new_w = int(w * scale)
new_h = int(h * scale)
# 等比例缩放
resized = image.resize((new_w, new_h), Image.LANCZOS)
# 创建灰色画布
canvas = Image.new('RGB', (size, size), (128, 128, 128))
# 将缩放后的图片粘贴到画布正中央
paste_x = (size - new_w) // 2
paste_y = (size - new_h) // 2
canvas.paste(resized, (paste_x, paste_y))
return canvas
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. 图片预处理
# Step 1: 背景去除HSV 色彩空间,绿色/白色背景 → 中性灰替换)
image = Image.open(image_path).convert('RGB')
image = _remove_background(image)
# Step 2: Letterbox 等比例缩放(保持内容不变形)
image = _letterbox_image(image, INPUT_SIZE)
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

@ -0,0 +1,25 @@
import atexit
import logging
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger(__name__)
# 全局初始化线程池
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix='image_embedding_')
def run_embedding_task(fn, *args, **kwargs):
"""
提交后台任务到线程池
"""
logger.info("Submitting embedding task to background thread...")
return _executor.submit(fn, *args, **kwargs)
def _shutdown_executor():
"""
优雅关闭线程池,在 Gunicorn worker 退出时触发
"""
logger.info("Shutting down background thread pool...")
_executor.shutdown(wait=False)
# 注册到系统退出事件,这样就不会报 _shutdown not defined 了
atexit.register(_shutdown_executor)

View File

@ -10,6 +10,12 @@ flask-cors==4.0.0
redis==5.0.1 redis==5.0.1
# 图片处理核心库 # 图片处理核心库
Pillow>=10.0.0 Pillow>=10.0.0
# OpenCV背景去除、HSV色彩空间抠图
opencv-python-headless>=4.8.0
# ONNX 模型本地 CPU 推理
onnxruntime>=1.16.0
# 数值计算ONNX 推理依赖)
numpy>=1.24.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错) # [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0 python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持) # [新增] 二维码生成库 (标签打印必需包含PIL支持)
@ -21,4 +27,8 @@ 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,25 +10,64 @@
<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() {
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || ''; // 【关键】增加保护检查:确保 DOM 已经就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', performInit);
} else {
performInit();
}
};
window.difyChatbotConfig = { function performInit() {
token: '6T0eTgukUEqzK0iW', var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
baseUrl: 'http://172.16.0.198:8080', var username = localStorage.getItem("username") || '';
inputs: {
"user_token": currentToken if (!currentToken) {
}, console.log('未检测到 Token暂不加载 Dify');
systemVariables: {}, return;
userVariables: {}, }
// 彻底清理浏览器内存中残留的 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>
<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;
@ -91,4 +130,4 @@
}); });
</script> </script>
</body> </body>
</html> </html>

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.36
</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

@ -32,19 +32,16 @@
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
clearable clearable
filterable filterable
allow-create
default-first-option
style="width: 240px; margin-right: 10px;" style="width: 240px; margin-right: 10px;"
@change="handleQuery" @change="handleQuery"
popper-class="long-dropdown" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.type" v-model="queryParams.type"
@ -84,6 +81,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"
@ -168,21 +168,31 @@
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" /> <el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template> </template>
<div class="column-setting-list"> <div class="column-setting-list">
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px"> <div style="display: flex; justify-content: space-between; align-items: center; font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置 <span>列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div> </div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" /> <el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.name.visible" label="名称" /> <el-checkbox v-if="hasColPermission('companyName')" v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-model="columns.commonName.visible" label="名" /> <el-checkbox v-if="hasColPermission('name')" v-model="columns.name.visible" label="名" />
<el-checkbox v-model="columns.category.visible" label="类别" /> <el-checkbox v-if="hasColPermission('commonName')" v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.type.visible" label="类" /> <el-checkbox v-if="hasColPermission('category')" v-model="columns.category.visible" label="类" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" /> <el-checkbox v-if="hasColPermission('type')" v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.unit.visible" label="单位" /> <el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" /> <el-checkbox v-if="hasColPermission('unit')" v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.available.visible" label="可用数" /> <el-checkbox v-if="hasColPermission('inventory')" v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-model="columns.files.visible" label="资料" /> <el-checkbox v-if="hasColPermission('available')" v-model="columns.available.visible" label="可用数" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" /> <el-checkbox v-if="hasColPermission('files')" v-model="columns.files.visible" label="资料" />
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
</div> </div>
</el-popover> </el-popover>
</div> </div>
@ -303,7 +313,7 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:view_warning')" label="预警状态" width="120" align="center"> <el-table-column v-if="columns.warningStatus.visible" label="预警状态" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.warningStatus === 2"> <template v-if="row.warningStatus === 2">
<el-tag type="danger" size="small">红色预警</el-tag> <el-tag type="danger" size="small">红色预警</el-tag>
@ -336,7 +346,7 @@
<el-pagination <el-pagination
v-model:current-page="queryParams.pageNum" v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize" v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]" :page-sizes="[50, 100, 200, 500]"
:background="true" :background="true"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
:total="total" :total="total"
@ -564,6 +574,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 +649,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 +672,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 +735,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 = [
@ -871,7 +891,8 @@ const columns = reactive({
available: { visible: true }, available: { visible: true },
files: { visible: true }, files: { visible: true },
isEnabled: { visible: true }, isEnabled: { visible: true },
isInspectionRequired: { visible: true } isInspectionRequired: { visible: true },
warningStatus: { visible: true }
}); });
// 列与权限Code的映射关系数据库中的code // 列与权限Code的映射关系数据库中的code
@ -888,22 +909,78 @@ const permissionMap: Record<string, string> = {
available: 'material_list:availableCount', available: 'material_list:availableCount',
files: 'material_list:files', files: 'material_list:files',
isEnabled: 'material_list:isEnabled', isEnabled: 'material_list:isEnabled',
isInspectionRequired: 'material_list:operation' isInspectionRequired: 'material_list:operation',
warningStatus: 'material_list:view_warning'
}; };
// 根据用户权限初始化列显示状态 // ================= 全选与本地缓存逻辑 =================
// 获取唯一缓存 Key (加上用户名,防止同一个浏览器切换账号时设置错乱)
const getStorageKey = () => `MOM_BASIC_INFO_COLS_${userStore.username || 'DEFAULT'}`;
// 辅助方法:判断当前用户是否有某列的权限
const hasColPermission = (key: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
const code = permissionMap[key];
return code ? !!userStore.hasPermission(code) : true;
};
// 计算属性:判断是否"全选"了所有【有权限】的列
const isAllSelected = computed(() => {
const allowedKeys = Object.keys(columns).filter(k => hasColPermission(k));
return allowedKeys.length > 0 && allowedKeys.every(k => columns[k as keyof typeof columns].visible);
});
// 计算属性:判断是否"半选" (Element UI 中 checkbox 的 indeterminate 状态)
const isIndeterminate = computed(() => {
const allowedKeys = Object.keys(columns).filter(k => hasColPermission(k));
const checkedCount = allowedKeys.filter(k => columns[k as keyof typeof columns].visible).length;
return checkedCount > 0 && checkedCount < allowedKeys.length;
});
// 事件:点击"全选"复选框时触发
const handleCheckAllChange = (val: boolean) => {
Object.keys(columns).forEach(key => {
// 只有用户有权限的列,才会被全选/全不选操作控制
if (hasColPermission(key)) {
columns[key as keyof typeof columns].visible = val;
}
});
};
// 监听:只要列展示状态发生变化,就自动保存到浏览器本地
watch(columns, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
// ================= 修改:权限初始化与读取缓存 =================
// 修改你原有的 initColumnPermissions 函数
const initColumnPermissions = () => { const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列 // 1. 尝试从本地缓存读取用户上次的设置
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') { const cachedData = localStorage.getItem(getStorageKey());
return; let parsedCache: Record<string, any> | null = null;
if (cachedData) {
try {
parsedCache = JSON.parse(cachedData);
} catch (e) {
console.error('解析列缓存失败', e);
}
} }
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏 // 2. 遍历列进行权限判断与缓存赋值
Object.keys(columns).forEach(key => { Object.keys(columns).forEach(key => {
const code = permissionMap[key]; const colKey = key as keyof typeof columns;
if (code) { const hasPerm = hasColPermission(colKey);
// 如果不具备该权限,必须设为 false
columns[key].visible = !!userStore.hasPermission(code); if (!hasPerm) {
// 【权限最高】如果没有权限,强制隐藏,无视任何缓存
columns[colKey].visible = false;
} else {
// 如果有权限,且存在本地缓存,则使用本地缓存的值
if (parsedCache && parsedCache[colKey] !== undefined) {
columns[colKey].visible = parsedCache[colKey].visible;
}
} }
}); });
}; };
@ -926,6 +1003,16 @@ const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]); const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]); const categoryTreeOptions = ref<CascaderOption[]>([]);
// 用于搜索栏级联选择器的数据绑定中转
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
// 类别级联选择器的 ref // 类别级联选择器的 ref
const categoryCascaderRef = ref<any>(null); const categoryCascaderRef = ref<any>(null);
@ -941,7 +1028,7 @@ const tempCategorySuffix = ref<string>('');
const queryParams = reactive<QueryParams>({ const queryParams = reactive<QueryParams>({
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 100,
keyword: '', keyword: '',
searchField: 'all', searchField: 'all',
category: '', category: '',
@ -1585,15 +1672,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 +1773,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 +1810,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) => {
queryParams.searchField = 'all'; if (newKeyword) {
} queryParams.keyword = newKeyword as string;
queryParams.searchField = 'all';
getList();
// 清理 URL 参数,防止刷新后重复触发搜索
router.replace({ path: route.path, query: {} });
}
},
{ immediate: true }
);
// 先根据权限初始化列显示状态 onMounted(() => {
initColumnPermissions(); initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据 // 无外部 keyword 参数时执行默认查询;有 keyword 则由上方的 watch 接管
getList(); 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