Compare commits
44 Commits
2.0权限管理
...
8bb3e58b44
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bb3e58b44 | |||
| cdac915a4b | |||
| 8a2da1ac1e | |||
| 332ae3c4cf | |||
| d51c6f147f | |||
| 2977acbae7 | |||
| 90eed24441 | |||
| 91444034e0 | |||
| 8f901e3f08 | |||
| bac670ef7a | |||
| 1c0c02fd36 | |||
| fffee9d964 | |||
| a3d47f6328 | |||
| 6149662fd8 | |||
| f18dfd9819 | |||
| 992e08aee9 | |||
| f27488e693 | |||
| 034418df8a | |||
| cd54ca3fe2 | |||
| 05aff2dd83 | |||
| c1d364b786 | |||
| 6e50762da6 | |||
| b4945cbba4 | |||
| 7d828d3ebf | |||
| 7e09e9de31 | |||
| fb5b8d873b | |||
| 682139bab8 | |||
| e564c5a5d2 | |||
| 9406669f1c | |||
| 92e1f7275e | |||
| 895d78a5e7 | |||
| 567c3175f6 | |||
| 81ea4a0ab3 | |||
| 1da4b454cd | |||
| ee9b19e72a | |||
| 3ffcd35093 | |||
| 8c635d6afe | |||
| 465452ef46 | |||
| d119bebe94 | |||
| baaaf7799a | |||
| c273f5a9d9 | |||
| 1a7c06f197 | |||
| 621431dcb9 | |||
| 6d044b234c |
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
venv/
|
.git
|
||||||
__pycache__/
|
.idea
|
||||||
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
.git/
|
*.pyo
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
env
|
||||||
|
uploads
|
||||||
|
pgdata
|
||||||
.env
|
.env
|
||||||
pgdata/
|
simhei.ttf
|
||||||
|
|||||||
@ -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 - 借还/维修/报废)
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
|
|||||||
@ -382,3 +382,41 @@ def get_bom_parents():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
|
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
|
||||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bom_bp.route('/cascade-inventory', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('bom_manage')
|
||||||
|
def get_cascade_inventory():
|
||||||
|
"""
|
||||||
|
根据 BOM 编号和订单数量,计算所有子件的级联库存缺口(供 AI 调用)
|
||||||
|
Query参数:
|
||||||
|
- bom_no: BOM编号(必填)
|
||||||
|
- order_qty: 订单需求量(必填,数值)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bom_no = request.args.get('bom_no', '').strip()
|
||||||
|
order_qty_str = request.args.get('order_qty', '').strip()
|
||||||
|
|
||||||
|
if not bom_no:
|
||||||
|
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
|
||||||
|
if not order_qty_str:
|
||||||
|
return jsonify({'code': 400, 'msg': 'order_qty 不能为空'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
order_qty = float(order_qty_str)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'code': 400, 'msg': 'order_qty 必须为有效数字'}), 400
|
||||||
|
|
||||||
|
data = BomService.calculate_cascade_inventory(bom_no, order_qty)
|
||||||
|
if data is None:
|
||||||
|
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': 'success',
|
||||||
|
'data': data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'级联库存计算失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|||||||
299
inventory-backend/app/api/v1/common/image_search.py
Normal file
299
inventory-backend/app/api/v1/common/image_search.py
Normal 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
|
||||||
@ -98,6 +98,24 @@ def search_base():
|
|||||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 1.1 计量单位字典接口 (GET /api/v1/inbound/base/units)
|
||||||
|
# ==============================================================================
|
||||||
|
@inbound_base_bp.route('/units', methods=['GET'])
|
||||||
|
@permission_required('material_list')
|
||||||
|
def get_unit_dict():
|
||||||
|
"""
|
||||||
|
获取所有已存在的非空计量单位(去重 + 排序),用于前端
|
||||||
|
新增/编辑弹窗中"计量单位"下拉框的历史记录。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
units = MaterialBaseService.get_distinct_units()
|
||||||
|
return jsonify({"code": 200, "msg": "success", "data": units})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 2. 列表接口 (GET /api/v1/inbound/base/list)
|
# 2. 列表接口 (GET /api/v1/inbound/base/list)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@ -60,7 +60,8 @@ def search_base():
|
|||||||
def search_bom():
|
def search_bom():
|
||||||
try:
|
try:
|
||||||
keyword = request.args.get('keyword', '')
|
keyword = request.args.get('keyword', '')
|
||||||
data = ProductInboundService.search_bom_options(keyword)
|
parent_spec = request.args.get('parent_spec', None)
|
||||||
|
data = ProductInboundService.search_bom_options(keyword, parent_spec=parent_spec)
|
||||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@ -60,7 +60,8 @@ def search_base():
|
|||||||
def search_bom():
|
def search_bom():
|
||||||
try:
|
try:
|
||||||
keyword = request.args.get('keyword', '')
|
keyword = request.args.get('keyword', '')
|
||||||
data = SemiInboundService.search_bom_options(keyword)
|
parent_spec = request.args.get('parent_spec', None)
|
||||||
|
data = SemiInboundService.search_bom_options(keyword, parent_spec=parent_spec)
|
||||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 关联关系区域
|
# 关联关系区域
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@ -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')
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ from app.extensions import db
|
|||||||
from app.models.bom import BomTable
|
from app.models.bom import BomTable
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
|
from app.models.inbound.semi import StockSemi
|
||||||
|
from app.models.inbound.product import StockProduct
|
||||||
from sqlalchemy import func, distinct, or_, case
|
from sqlalchemy import func, distinct, or_, case
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import uuid
|
import uuid
|
||||||
@ -140,24 +142,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 +187,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 +203,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 分组
|
||||||
@ -443,3 +460,68 @@ class BomService:
|
|||||||
if not bom_no: return []
|
if not bom_no: return []
|
||||||
detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
|
detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
|
||||||
return detail['children'] if detail else []
|
return detail['children'] if detail else []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_cascade_inventory(bom_no, order_qty):
|
||||||
|
"""
|
||||||
|
根据 bom_no 和订单数量,计算所有子件的级联库存缺口。
|
||||||
|
返回结构供 AI 消费,每个子件包含:parent_name / spec / name / level_type /
|
||||||
|
need_qty / available_stock / suggested_qty / gap
|
||||||
|
若 BOM 不存在返回 None。
|
||||||
|
"""
|
||||||
|
# 1. 获取 BOM 明细
|
||||||
|
detail = BomService.get_bom_detail(bom_no)
|
||||||
|
if not detail or not detail.get('children'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parent_name = detail.get('parent_name', '')
|
||||||
|
|
||||||
|
# 2. 提取所有子件 ID,查询采购库存(stock_buy)
|
||||||
|
child_ids = [child['child_id'] for child in detail['children']]
|
||||||
|
|
||||||
|
buy_stats = db.session.query(
|
||||||
|
StockBuy.base_id,
|
||||||
|
func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty')
|
||||||
|
).filter(
|
||||||
|
StockBuy.base_id.in_(child_ids)
|
||||||
|
).group_by(StockBuy.base_id).all()
|
||||||
|
|
||||||
|
buy_map = {stat.base_id: float(stat.total_qty) for stat in buy_stats}
|
||||||
|
|
||||||
|
# 3. 提取所有子件的基础物料信息(名称/规格/类型)
|
||||||
|
materials = db.session.query(
|
||||||
|
MaterialBase.id,
|
||||||
|
MaterialBase.name,
|
||||||
|
MaterialBase.spec_model
|
||||||
|
).filter(MaterialBase.id.in_(child_ids)).all()
|
||||||
|
|
||||||
|
mat_map = {
|
||||||
|
m.id: {'name': m.name or '', 'spec': m.spec_model or ''}
|
||||||
|
for m in materials
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. 遍历子件,计算每个子件的缺口数据
|
||||||
|
results = []
|
||||||
|
for child in detail['children']:
|
||||||
|
child_id = child['child_id']
|
||||||
|
dosage = float(child.get('dosage') or 0)
|
||||||
|
need_qty = dosage * order_qty
|
||||||
|
|
||||||
|
available_stock = buy_map.get(child_id, 0)
|
||||||
|
suggested_qty = max(0.0, min(need_qty, available_stock))
|
||||||
|
gap = available_stock - need_qty
|
||||||
|
|
||||||
|
mat_info = mat_map.get(child_id, {'name': '', 'spec': ''})
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'parent_name': parent_name,
|
||||||
|
'spec': mat_info['spec'],
|
||||||
|
'name': mat_info['name'],
|
||||||
|
'level_type': 'child',
|
||||||
|
'need_qty': round(need_qty, 4),
|
||||||
|
'available_stock': round(available_stock, 4),
|
||||||
|
'suggested_qty': round(suggested_qty, 4),
|
||||||
|
'gap': round(gap, 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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。
|
||||||
|
|||||||
@ -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 != '':
|
||||||
@ -524,6 +528,29 @@ class MaterialBaseService:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return {"categories": [], "types": [], "companies": []}
|
return {"categories": [], "types": [], "companies": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_distinct_units():
|
||||||
|
"""
|
||||||
|
获取所有已存在且非空的计量单位(去重 + 排序)。
|
||||||
|
用于前端"基础信息"新增/编辑弹窗的"计量单位"下拉历史记录。
|
||||||
|
|
||||||
|
SQL 语义:
|
||||||
|
SELECT DISTINCT unit FROM material_base
|
||||||
|
WHERE unit IS NOT NULL AND unit != ''
|
||||||
|
ORDER BY unit ASC
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = db.session.query(MaterialBase.unit) \
|
||||||
|
.filter(MaterialBase.unit.isnot(None), MaterialBase.unit != '') \
|
||||||
|
.distinct() \
|
||||||
|
.all()
|
||||||
|
sorted_units = sorted([u[0] for u in rows if u[0]])
|
||||||
|
return sorted_units
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"查询计量单位字典失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_material(data):
|
def create_material(data):
|
||||||
"""新增基础信息"""
|
"""新增基础信息"""
|
||||||
@ -555,9 +582,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 +626,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 +710,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 +774,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 +1111,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 +1127,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 +1136,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
|
||||||
@ -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
|
||||||
@ -342,7 +382,8 @@ class BuyInboundService:
|
|||||||
|
|
||||||
# 2. 类别独立搜索
|
# 2. 类别独立搜索
|
||||||
if category and category.strip():
|
if category and category.strip():
|
||||||
query = query.filter(MaterialBase.category == category.strip())
|
# 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
|
||||||
|
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
|
||||||
|
|
||||||
# 3. 类型独立搜索
|
# 3. 类型独立搜索
|
||||||
if material_type and material_type.strip():
|
if material_type and material_type.strip():
|
||||||
|
|||||||
@ -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:
|
||||||
@ -63,7 +66,7 @@ class ProductInboundService:
|
|||||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_bom_options(keyword):
|
def search_bom_options(keyword, parent_spec=None):
|
||||||
from app.models.bom import BomTable
|
from app.models.bom import BomTable
|
||||||
try:
|
try:
|
||||||
query = db.session.query(
|
query = db.session.query(
|
||||||
@ -76,6 +79,9 @@ class ProductInboundService:
|
|||||||
if hasattr(BomTable, 'is_enabled'):
|
if hasattr(BomTable, 'is_enabled'):
|
||||||
query = query.filter(BomTable.is_enabled == True)
|
query = query.filter(BomTable.is_enabled == True)
|
||||||
|
|
||||||
|
if parent_spec:
|
||||||
|
query = query.filter(MaterialBase.spec_model == parent_spec)
|
||||||
|
|
||||||
if keyword:
|
if keyword:
|
||||||
kw = f'%{keyword}%'
|
kw = f'%{keyword}%'
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@ -184,7 +190,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 +233,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 +291,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
|
||||||
@ -313,7 +352,8 @@ class ProductInboundService:
|
|||||||
sku_str = f'%{sku.strip()}%'
|
sku_str = f'%{sku.strip()}%'
|
||||||
query = query.filter(StockProduct.sku.ilike(sku_str))
|
query = query.filter(StockProduct.sku.ilike(sku_str))
|
||||||
if category and category.strip():
|
if category and category.strip():
|
||||||
query = query.filter(MaterialBase.category == category.strip())
|
# 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
|
||||||
|
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
|
||||||
if material_type and material_type.strip():
|
if material_type and material_type.strip():
|
||||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
@ -68,7 +71,7 @@ class SemiInboundService:
|
|||||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_bom_options(keyword):
|
def search_bom_options(keyword, parent_spec=None):
|
||||||
from app.models.bom import BomTable
|
from app.models.bom import BomTable
|
||||||
try:
|
try:
|
||||||
query = db.session.query(
|
query = db.session.query(
|
||||||
@ -81,6 +84,9 @@ class SemiInboundService:
|
|||||||
if hasattr(BomTable, 'is_enabled'):
|
if hasattr(BomTable, 'is_enabled'):
|
||||||
query = query.filter(BomTable.is_enabled == True)
|
query = query.filter(BomTable.is_enabled == True)
|
||||||
|
|
||||||
|
if parent_spec:
|
||||||
|
query = query.filter(MaterialBase.spec_model == parent_spec)
|
||||||
|
|
||||||
if keyword:
|
if keyword:
|
||||||
kw = f'%{keyword}%'
|
kw = f'%{keyword}%'
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@ -221,7 +227,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 +288,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 +379,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
|
||||||
@ -404,7 +442,8 @@ class SemiInboundService:
|
|||||||
sku_str = f'%{sku.strip()}%'
|
sku_str = f'%{sku.strip()}%'
|
||||||
query = query.filter(StockSemi.sku.ilike(sku_str))
|
query = query.filter(StockSemi.sku.ilike(sku_str))
|
||||||
if category and category.strip():
|
if category and category.strip():
|
||||||
query = query.filter(MaterialBase.category == category.strip())
|
# 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
|
||||||
|
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
|
||||||
if material_type and material_type.strip():
|
if material_type and material_type.strip():
|
||||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||||
|
|
||||||
|
|||||||
BIN
inventory-backend/app/services/print/simhei.ttf
Normal file
BIN
inventory-backend/app/services/print/simhei.ttf
Normal file
Binary file not shown.
279
inventory-backend/app/utils/ai_vision.py
Normal file
279
inventory-backend/app/utils/ai_vision.py
Normal 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
|
||||||
25
inventory-backend/app/utils/executor.py
Normal file
25
inventory-backend/app/utils/executor.py
Normal 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)
|
||||||
@ -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支持)
|
||||||
@ -22,3 +28,7 @@ openpyxl>=3.1.2
|
|||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
# [新增] 时区处理 (APScheduler 需要)
|
# [新增] 时区处理 (APScheduler 需要)
|
||||||
pytz
|
pytz
|
||||||
|
# [新增] 进度条库 (脚本和任务所需)
|
||||||
|
tqdm>=4.66.0
|
||||||
|
# [新增] pgvector 向量数据库支持(以图搜图 / 实时向量提取)
|
||||||
|
pgvector>=0.2.0
|
||||||
231
inventory-backend/scripts/init_all_vectors.py
Normal file
231
inventory-backend/scripts/init_all_vectors.py
Normal 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()
|
||||||
@ -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
|
||||||
@ -10,8 +10,35 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<script>
|
<script>
|
||||||
// 获取当前用户的登录凭证 (Token)
|
window.initDifyChatbot = function() {
|
||||||
|
// 【关键】增加保护检查:确保 DOM 已经就绪
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', performInit);
|
||||||
|
} else {
|
||||||
|
performInit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function performInit() {
|
||||||
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
|
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
|
||||||
|
var username = localStorage.getItem("username") || '';
|
||||||
|
|
||||||
|
if (!currentToken) {
|
||||||
|
console.log('未检测到 Token,暂不加载 Dify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 彻底清理浏览器内存中残留的 Dify 全局对象
|
||||||
|
window.difyChatbot = undefined;
|
||||||
|
delete window.difyChatbot;
|
||||||
|
|
||||||
|
// 清理旧的 DOM 节点
|
||||||
|
var oldScript = document.getElementById('6T0eTgukUEqzK0iW');
|
||||||
|
if (oldScript) oldScript.remove();
|
||||||
|
document.querySelectorAll('[id^="dify-chatbot-"]').forEach(function(el) { el.remove(); });
|
||||||
|
|
||||||
|
// 动态化 user_id,打破 Dify 会话锁定机制
|
||||||
|
var dynamicUserId = username + '_' + currentToken.slice(-8);
|
||||||
|
|
||||||
window.difyChatbotConfig = {
|
window.difyChatbotConfig = {
|
||||||
token: '6T0eTgukUEqzK0iW',
|
token: '6T0eTgukUEqzK0iW',
|
||||||
@ -19,16 +46,28 @@
|
|||||||
inputs: {
|
inputs: {
|
||||||
"user_token": currentToken
|
"user_token": currentToken
|
||||||
},
|
},
|
||||||
systemVariables: {},
|
systemVariables: {
|
||||||
|
"user_id": dynamicUserId
|
||||||
|
},
|
||||||
userVariables: {},
|
userVariables: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重新挂载
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = 'http://172.16.0.198:8080/embed.min.js?t=' + new Date().getTime();
|
||||||
|
script.id = '6T0eTgukUEqzK0iW';
|
||||||
|
script.defer = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
console.log('✅ Dify chatbot 已挂载新会话,当前绑定 ID:', dynamicUserId);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script
|
<!--<script-->
|
||||||
src="http://172.16.0.198:8080/embed.min.js"
|
<!-- src="http://172.16.0.198:8080/embed.min.js"-->
|
||||||
id="6T0eTgukUEqzK0iW"
|
<!-- id="6T0eTgukUEqzK0iW"-->
|
||||||
defer>
|
<!-- defer>-->
|
||||||
</script>
|
<!--</script>-->
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#dify-chatbot-bubble-button {
|
#dify-chatbot-bubble-button {
|
||||||
@ -38,7 +77,7 @@
|
|||||||
|
|
||||||
/* 变成"独立悬浮窗口" */
|
/* 变成"独立悬浮窗口" */
|
||||||
#dify-chatbot-bubble-window {
|
#dify-chatbot-bubble-window {
|
||||||
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
|
/* 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
|
||||||
top: 15vh !important;
|
top: 15vh !important;
|
||||||
left: 20vw !important;
|
left: 20vw !important;
|
||||||
bottom: auto !important;
|
bottom: auto !important;
|
||||||
@ -49,9 +88,9 @@
|
|||||||
height: 70vh !important;
|
height: 70vh !important;
|
||||||
|
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important; /* 增加超大弥散阴影,浮现感更强 */
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important;
|
||||||
|
|
||||||
/* 👇 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
|
/* 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
|
||||||
resize: both !important;
|
resize: both !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
padding-bottom: 16px !important;
|
padding-bottom: 16px !important;
|
||||||
|
|||||||
@ -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.41
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@ -246,7 +251,7 @@ const handleLogout = () => {
|
|||||||
v-model="profileDialogVisible"
|
v-model="profileDialogVisible"
|
||||||
title="个人中心"
|
title="个人中心"
|
||||||
width="480px"
|
width="480px"
|
||||||
:close-on-click-modal="!passwordLoading"
|
:close-on-click-modal="false"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
class="profile-dialog"
|
class="profile-dialog"
|
||||||
>
|
>
|
||||||
@ -326,7 +331,7 @@ const handleLogout = () => {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 绑定/修改邮箱弹窗 -->
|
<!-- 绑定/修改邮箱弹窗 -->
|
||||||
<el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" @close="resetEmailForm">
|
<el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" :close-on-click-modal="false" @close="resetEmailForm">
|
||||||
<el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px">
|
<el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px">
|
||||||
<el-form-item label="新邮箱" prop="email">
|
<el-form-item label="新邮箱" prop="email">
|
||||||
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />
|
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -43,11 +43,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 搜索BOM
|
// 搜索BOM
|
||||||
export function searchBom(keyword: string) {
|
export function searchBom(keyword: string, parent_spec?: string) {
|
||||||
return request({
|
return request({
|
||||||
url: '/inbound/product/search-bom',
|
url: '/inbound/product/search-bom',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { keyword }
|
params: { keyword, parent_spec }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,11 +45,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5.5 搜索BOM (新增)
|
// 5.5 搜索BOM (新增)
|
||||||
export function searchBom(keyword: string) {
|
export function searchBom(keyword: string, parent_spec?: string) {
|
||||||
return request({
|
return request({
|
||||||
url: '/inbound/semi/search-bom',
|
url: '/inbound/semi/search-bom',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { keyword }
|
params: { keyword, parent_spec }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -87,3 +87,11 @@ export function markWarningOrdered(data: { baseId: number; isOrdered: boolean })
|
|||||||
data
|
data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 9. 获取计量单位字典 (新增/编辑弹窗下拉历史)
|
||||||
|
export function getMaterialUnitsAPI() {
|
||||||
|
return request({
|
||||||
|
url: '/inbound/base/units',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
491
inventory-web/src/components/ImageSearchDialog.vue
Normal file
491
inventory-web/src/components/ImageSearchDialog.vue
Normal 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>
|
||||||
@ -84,6 +84,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
localStorage.setItem('refresh_token', data.refresh_token)
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Dify] 登录成功,重新初始化 Dify(Token 变化时 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')
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
<el-table :data="group.items" border style="width: 100%">
|
<el-table :data="group.items" border style="width: 100%">
|
||||||
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
|
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span style="cursor: pointer; color: #409EFF;" @click="handleEdit(row)">{{ row.bom_no }}</span>
|
<span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
|
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
|
||||||
@ -51,9 +51,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
|
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
|
||||||
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
|
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="200" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
|
||||||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
||||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
@ -64,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
|
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@ -76,13 +75,14 @@
|
|||||||
placeholder="请搜索并选择父件"
|
placeholder="请搜索并选择父件"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
||||||
:loading="selectLoading"
|
:loading="selectLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
:disabled="isEditMode"
|
:disabled="isReadOnlyMode || isEditMode"
|
||||||
class="beautified-select"
|
class="beautified-select"
|
||||||
popper-class="bom-loadmore-popper parent-popper"
|
popper-class="bom-loadmore-popper parent-popper"
|
||||||
|
default-first-option="true"
|
||||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||||||
@change="onParentChange"
|
@change="onParentChange"
|
||||||
>
|
>
|
||||||
@ -99,7 +99,7 @@
|
|||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-link
|
<el-link
|
||||||
v-if="form.parent_id"
|
v-if="form.parent_id && !isReadOnlyMode"
|
||||||
type="primary"
|
type="primary"
|
||||||
:underline="false"
|
:underline="false"
|
||||||
style="margin-left: 12px; font-size: 13px;"
|
style="margin-left: 12px; font-size: 13px;"
|
||||||
@ -114,7 +114,7 @@
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
|
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
|
||||||
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
|
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="isReadOnlyMode || !userStore.hasPermission('bom_manage:operation')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="16"></el-col>
|
<el-col :span="16"></el-col>
|
||||||
@ -131,19 +131,19 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
|
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
|
||||||
<el-input v-model="form.remark" placeholder="备注信息(可选)" />
|
<el-input v-model="form.remark" placeholder="备注信息(可选)" :disabled="isReadOnlyMode" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
|
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
|
||||||
<template v-if="isSaveAsMode">
|
<template v-if="isSaveAsMode">
|
||||||
<el-radio-group v-model="form.versionUpgradeType" @change="onVersionUpgradeTypeChange">
|
<el-radio-group v-model="form.versionUpgradeType" :disabled="isReadOnlyMode" @change="onVersionUpgradeTypeChange">
|
||||||
<el-radio-button label="minor">升级次版本 ({{ versionOptions.minor }})</el-radio-button>
|
<el-radio-button label="minor">升级次版本 ({{ versionOptions.minor }})</el-radio-button>
|
||||||
<el-radio-button label="major">升级主版本 ({{ versionOptions.major }})</el-radio-button>
|
<el-radio-button label="major">升级主版本 ({{ versionOptions.major }})</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-input v-model="form.version" placeholder="如: V1.0" />
|
<el-input v-model="form.version" placeholder="如: V1.0" :disabled="isReadOnlyMode" />
|
||||||
</template>
|
</template>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -160,11 +160,12 @@
|
|||||||
clearable
|
clearable
|
||||||
style="width: 300px; margin-bottom: 10px;"
|
style="width: 300px; margin-bottom: 10px;"
|
||||||
:prefix-icon="Search"
|
:prefix-icon="Search"
|
||||||
|
:disabled="isReadOnlyMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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
|
||||||
@ -172,16 +173,18 @@
|
|||||||
placeholder="请搜索原料"
|
placeholder="请搜索原料"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
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)"
|
:disabled="isReadOnlyMode"
|
||||||
|
default-first-option="true"
|
||||||
|
@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"
|
||||||
@ -192,12 +195,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
|
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id && !isReadOnlyMode">
|
||||||
<el-button
|
<el-button
|
||||||
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>
|
||||||
@ -207,32 +210,32 @@
|
|||||||
|
|
||||||
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
|
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number v-model="row.dosage" :min="0" :precision="0" style="width: 100%" :controls="false" />
|
<el-input-number v-model="row.dosage" :min="0" :precision="0" style="width: 100%" :controls="false" :disabled="isReadOnlyMode" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
|
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.remark" placeholder="备注" />
|
<el-input v-model="row.remark" placeholder="备注" :disabled="isReadOnlyMode" />
|
||||||
</template>
|
</template>
|
||||||
</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 v-if="!isReadOnlyMode" type="danger" link @click="removeChild(row.rowKey)">删</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id')">
|
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id') && !isReadOnlyMode">
|
||||||
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
|
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
<el-button v-if="!isReadOnlyMode" type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -240,7 +243,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 +269,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
|
||||||
@ -279,6 +283,7 @@ const dialogVisible = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
const isSaveAsMode = ref(false)
|
const isSaveAsMode = ref(false)
|
||||||
|
const isReadOnlyMode = ref(false)
|
||||||
let originalVersion = ''
|
let originalVersion = ''
|
||||||
let currentBomNo = ''
|
let currentBomNo = ''
|
||||||
let originalChildren: ChildRow[] = []
|
let originalChildren: ChildRow[] = []
|
||||||
@ -288,6 +293,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 +335,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 +367,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 +387,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,48 +413,57 @@ const fetchMaterialOptions = async (
|
|||||||
const handleRemoteSearch = (
|
const handleRemoteSearch = (
|
||||||
query: string,
|
query: string,
|
||||||
type: 'parent' | 'child',
|
type: 'parent' | 'child',
|
||||||
index?: number
|
rowKey?: number
|
||||||
) => {
|
) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
if (type === 'parent') {
|
if (type === 'parent') {
|
||||||
parentQueryParams.keyword = query
|
parentQueryParams.keyword = safeQuery
|
||||||
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 = safeQuery
|
||||||
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') {
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。
|
||||||
|
if (parentQueryParams.keyword || parentOptions.value.length > 0) return
|
||||||
parentQueryParams.page = 1
|
parentQueryParams.page = 1
|
||||||
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)!
|
||||||
|
// 防御性拦截:竞态条件守卫(同上)
|
||||||
|
if (state.queryParams.keyword || state.options.length > 0) return
|
||||||
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 +471,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 +488,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 +508,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 +538,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 +548,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 || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -648,27 +687,80 @@ const handleCreate = () => {
|
|||||||
dialogTitle.value = '新建 BOM'
|
dialogTitle.value = '新建 BOM'
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
isSaveAsMode.value = false
|
isSaveAsMode.value = false
|
||||||
|
isReadOnlyMode.value = false
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = async (row: BomItem) => {
|
const handleView = async (row: BomItem) => {
|
||||||
await loadDetail(row.bom_no, row.version)
|
await loadDetail(row.bom_no, row.version)
|
||||||
dialogTitle.value = '编辑 BOM'
|
dialogTitle.value = '查看 BOM'
|
||||||
isEditMode.value = true
|
isEditMode.value = false
|
||||||
isSaveAsMode.value = false
|
isSaveAsMode.value = false
|
||||||
|
isReadOnlyMode.value = true
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveAs = async (row: BomItem) => {
|
const handleSaveAs = async (row: BomItem) => {
|
||||||
await loadDetail(row.bom_no, row.version)
|
// 1. 重置 form 基础状态
|
||||||
dialogTitle.value = '另存为新版/变体'
|
resetForm()
|
||||||
isEditMode.value = true
|
|
||||||
isSaveAsMode.value = true
|
// 2. 获取源 BOM 详情(不通过 loadDetail,显式走"深拷贝+清 ID"路径)
|
||||||
originalVersion = form.version
|
const res = await getBomDetail(row.bom_no, row.version)
|
||||||
|
if (res.code !== 200) return
|
||||||
|
const raw = JSON.parse(JSON.stringify(res.data))
|
||||||
|
|
||||||
|
// 3. ★ 核心:显式深拷贝 + 清除所有主键 ID(防止后端误判为更新操作)
|
||||||
|
if ('id' in raw) delete raw.id
|
||||||
|
if ('bom_id' in raw) delete raw.bom_id
|
||||||
|
if (Array.isArray(raw.children)) {
|
||||||
|
raw.children.forEach((c: any) => {
|
||||||
|
if ('id' in c) delete c.id
|
||||||
|
if ('bom_id' in c) delete c.bom_id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 把"已清除 ID 的纯净数据"写入 form(保留子件下拉回显 + 父件下拉回显)
|
||||||
|
form.children = raw.children.map((child: any, idx: number) => ({
|
||||||
|
rowKey: idx,
|
||||||
|
child_id: child.child_id,
|
||||||
|
dosage: child.dosage,
|
||||||
|
remark: child.remark || ''
|
||||||
|
}))
|
||||||
|
form.children.forEach((child, idx) => {
|
||||||
|
initChildDropdownState(idx)
|
||||||
|
if (child.child_id) {
|
||||||
|
const state = childDropdownStates.value.get(idx)!
|
||||||
|
state.options = [{
|
||||||
|
id: raw.children[idx].child_id,
|
||||||
|
name: raw.children[idx].child_name || '未知物料',
|
||||||
|
spec: raw.children[idx].child_spec || ''
|
||||||
|
}]
|
||||||
|
state.hasMore = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (raw.parent_id) {
|
||||||
|
form.parent_id = raw.parent_id
|
||||||
|
parentOptions.value = [{
|
||||||
|
id: raw.parent_id,
|
||||||
|
name: raw.parent_name || '未知产品',
|
||||||
|
spec: raw.parent_spec || ''
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
|
||||||
|
form.remark = raw.remark || ''
|
||||||
|
|
||||||
|
// 5. 设置"另存为"模式特有状态(版本升级单选 + 子件变更检测)
|
||||||
|
originalVersion = raw.version || ''
|
||||||
currentBomNo = row.bom_no
|
currentBomNo = row.bom_no
|
||||||
originalChildren = JSON.parse(JSON.stringify(form.children))
|
originalChildren = JSON.parse(JSON.stringify(form.children))
|
||||||
form.versionUpgradeType = 'minor'
|
form.versionUpgradeType = 'minor'
|
||||||
form.version = versionOptions.value.minor
|
form.version = versionOptions.value.minor
|
||||||
|
|
||||||
|
// 6. 弹窗状态机:标题"新增 BOM",父件可改,启用版本升级单选
|
||||||
|
dialogTitle.value = '新增 BOM'
|
||||||
|
isEditMode.value = false
|
||||||
|
isSaveAsMode.value = true
|
||||||
|
isReadOnlyMode.value = false
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -677,8 +769,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 +779,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)!
|
||||||
@ -741,6 +834,7 @@ const resetForm = () => {
|
|||||||
form.is_enabled = true
|
form.is_enabled = true
|
||||||
form.children = []
|
form.children = []
|
||||||
isSaveAsMode.value = false
|
isSaveAsMode.value = false
|
||||||
|
isReadOnlyMode.value = false
|
||||||
originalVersion = ''
|
originalVersion = ''
|
||||||
currentBomNo = ''
|
currentBomNo = ''
|
||||||
childSearchKeyword.value = ''
|
childSearchKeyword.value = ''
|
||||||
@ -755,26 +849,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 () => {
|
||||||
@ -846,9 +931,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingBom) {
|
if (existingBom) {
|
||||||
// ★ 情况 A:已经有BOM了,直接打开编辑(查看)弹窗
|
// ★ 情况 A:已经有BOM了,直接打开只读查看弹窗
|
||||||
ElMessage.success('检测到该物料已有 BOM,已自动为您打开');
|
ElMessage.success('检测到该物料已有 BOM,已自动为您打开');
|
||||||
handleEdit(existingBom);
|
handleView(existingBom);
|
||||||
} else {
|
} else {
|
||||||
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
||||||
handleCreate();
|
handleCreate();
|
||||||
|
|||||||
@ -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('用户名或密码错误')
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
<el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery">
|
<el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery">
|
||||||
<el-option label="全部" value="all" />
|
<el-option label="全部" value="all" />
|
||||||
<el-option label="名称" value="name" />
|
<el-option label="名称" value="name" />
|
||||||
<el-option label="俗名" value="common_name" />
|
<el-option label="专业名称" value="common_name" />
|
||||||
<el-option label="规格" value="spec" />
|
<el-option label="规格" value="spec" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
@ -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>
|
||||||
@ -212,7 +222,7 @@
|
|||||||
|
|
||||||
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" />
|
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" />
|
||||||
|
|
||||||
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip sortable="custom">
|
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
|
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
|
||||||
<span v-else style="color: #ccc;">-</span>
|
<span v-else style="color: #ccc;">-</span>
|
||||||
@ -249,6 +259,7 @@
|
|||||||
:src="getImageUrl(getImagesOnly(row.generalImage)[0])"
|
:src="getImageUrl(getImagesOnly(row.generalImage)[0])"
|
||||||
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
|
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
|
hide-on-click-modal
|
||||||
fit="cover"
|
fit="cover"
|
||||||
/>
|
/>
|
||||||
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
|
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
|
||||||
@ -267,6 +278,7 @@
|
|||||||
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
|
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
|
hide-on-click-modal
|
||||||
/>
|
/>
|
||||||
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
|
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -303,7 +315,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 +348,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"
|
||||||
@ -351,13 +363,23 @@
|
|||||||
append-to-body
|
append-to-body
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@close="cancel"
|
@close="cancel"
|
||||||
:close-on-click-modal="!isUploading"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="!isUploading"
|
:close-on-press-escape="!isUploading"
|
||||||
:show-close="!isUploading"
|
:show-close="!isUploading"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
|
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
|
||||||
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
|
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 16px;">
|
||||||
|
<el-link
|
||||||
|
v-if="form.id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
style="font-size: 14px;"
|
||||||
|
@click="handleSaveAs"
|
||||||
|
>
|
||||||
|
<el-icon style="margin-right: 4px"><DocumentCopy /></el-icon>另存为新项
|
||||||
|
</el-link>
|
||||||
<el-link
|
<el-link
|
||||||
v-if="form.id"
|
v-if="form.id"
|
||||||
type="success"
|
type="success"
|
||||||
@ -368,6 +390,7 @@
|
|||||||
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
|
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||||
|
|
||||||
@ -378,7 +401,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="俗名" prop="commonName" v-if="hasFieldPermission('commonName')">
|
<el-form-item label="专业名称" prop="commonName" v-if="hasFieldPermission('commonName')">
|
||||||
<el-input v-model="form.commonName" placeholder="标准名称" />
|
<el-input v-model="form.commonName" placeholder="标准名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -397,6 +420,20 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
|
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
|
||||||
|
<el-autocomplete
|
||||||
|
v-model="form.type"
|
||||||
|
:fetch-suggestions="querySearchType"
|
||||||
|
placeholder="可输入或选择"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="24">
|
||||||
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
|
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
|
||||||
<div style="display: flex; width: 100%; align-items: center;">
|
<div style="display: flex; width: 100%; align-items: center;">
|
||||||
<el-cascader
|
<el-cascader
|
||||||
@ -418,26 +455,6 @@
|
|||||||
style="width: 50%;"
|
style="width: 50%;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<el-row>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
|
|
||||||
<el-autocomplete
|
|
||||||
v-model="form.type"
|
|
||||||
:fetch-suggestions="querySearchType"
|
|
||||||
placeholder="可输入或选择"
|
|
||||||
clearable
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
|
|
||||||
<el-input v-model="form.spec" placeholder="请输入规格型号" />
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@ -445,13 +462,26 @@
|
|||||||
<el-row>
|
<el-row>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')">
|
<el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')">
|
||||||
<el-input v-model="form.unit" placeholder="如: 个, 台, 米" />
|
<el-select
|
||||||
|
v-model="form.unit"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
placeholder="请选择或输入计量单位"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in unitOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="可见等级" prop="visibilityLevel">
|
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
|
||||||
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
|
<el-input v-model="form.spec" placeholder="请输入规格型号" />
|
||||||
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@ -482,6 +512,7 @@
|
|||||||
>
|
>
|
||||||
<template #prefix><el-icon><Link /></el-icon></template>
|
<template #prefix><el-icon><Link /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
|
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
|
||||||
@ -535,6 +566,7 @@
|
|||||||
>
|
>
|
||||||
<template #prefix><el-icon><Link /></el-icon></template>
|
<template #prefix><el-icon><Link /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
|
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
|
||||||
@ -553,10 +585,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
|
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
|
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<WebRtcCamera
|
<WebRtcCamera
|
||||||
ref="cameraRef"
|
ref="cameraRef"
|
||||||
@photo-submit="handleCameraConfirm"
|
@photo-submit="handleCameraConfirm"
|
||||||
@ -564,8 +596,15 @@
|
|||||||
/>
|
/>
|
||||||
</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 :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
|
<el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="warningDialog.selectedCount > 1"
|
v-if="warningDialog.selectedCount > 1"
|
||||||
@ -601,7 +640,7 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 批量质检设置弹窗 -->
|
<!-- 批量质检设置弹窗 -->
|
||||||
<el-dialog v-model="inspectionDialog.visible" title="批量质检设置" width="500px" append-to-body destroy-on-close>
|
<el-dialog v-model="inspectionDialog.visible" title="批量质检设置" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<el-alert
|
<el-alert
|
||||||
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
|
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
|
||||||
type="info"
|
type="info"
|
||||||
@ -632,8 +671,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, DocumentCopy, 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';
|
||||||
@ -650,11 +689,14 @@ import {
|
|||||||
exportAssetStatistics,
|
exportAssetStatistics,
|
||||||
batchSetWarning,
|
batchSetWarning,
|
||||||
batchSetInspection,
|
batchSetInspection,
|
||||||
markWarningOrdered
|
markWarningOrdered,
|
||||||
|
getMaterialUnitsAPI
|
||||||
} from '@/api/material_base';
|
} from '@/api/material_base';
|
||||||
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,12 +758,13 @@ 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 = [
|
||||||
{ value: 'companyName', label: '所属公司', perm: 'material_list:companyName' },
|
{ value: 'companyName', label: '所属公司', perm: 'material_list:companyName' },
|
||||||
{ value: 'name', label: '名称', perm: 'material_list:name' },
|
{ value: 'name', label: '名称', perm: 'material_list:name' },
|
||||||
{ value: 'commonName', label: '俗名', perm: 'material_list:commonName' },
|
{ value: 'commonName', label: '专业名称', perm: 'material_list:commonName' },
|
||||||
{ value: 'category', label: '类别', perm: 'material_list:category' },
|
{ value: 'category', label: '类别', perm: 'material_list:category' },
|
||||||
{ value: 'type', label: '类型', perm: 'material_list:type' },
|
{ value: 'type', label: '类型', perm: 'material_list:type' },
|
||||||
{ value: 'spec', label: '规格型号', perm: 'material_list:spec' },
|
{ value: 'spec', label: '规格型号', perm: 'material_list:spec' },
|
||||||
@ -871,7 +914,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 +932,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -924,15 +1024,42 @@ const hasFieldPermission = (field: string) => {
|
|||||||
const companyOptions = ref<string[]>([]);
|
const companyOptions = ref<string[]>([]);
|
||||||
const categoryOptions = ref<string[]>([]);
|
const categoryOptions = ref<string[]>([]);
|
||||||
const typeOptions = ref<string[]>([]);
|
const typeOptions = ref<string[]>([]);
|
||||||
|
const unitOptions = 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);
|
||||||
|
|
||||||
// 选中类别后自动收起下拉面板
|
// 选中类别后:1) 收起下拉面板;2) 自动提取末级 Label 末尾的英文字母填入规格型号
|
||||||
const onCategoryChange = () => {
|
const onCategoryChange = () => {
|
||||||
if (categoryCascaderRef.value) {
|
if (!categoryCascaderRef.value) return;
|
||||||
|
|
||||||
|
// 1) 收起下拉
|
||||||
categoryCascaderRef.value.togglePopperVisible(false);
|
categoryCascaderRef.value.togglePopperVisible(false);
|
||||||
|
|
||||||
|
// 2) 从末级节点 Label 末尾提取连续的英文字母/数字 (例如 "电子半成品HH" -> "HH",
|
||||||
|
// "ASD定标实验室Opt9" -> "Opt9"),写入规格型号。
|
||||||
|
// 仅在 @change 触发时赋一次值,用户可继续手动修改;未匹配到则保持原值
|
||||||
|
try {
|
||||||
|
const nodes = categoryCascaderRef.value.getCheckedNodes?.() || [];
|
||||||
|
const node = nodes[0];
|
||||||
|
const label: string = (node && node.label) || '';
|
||||||
|
const match = label.match(/[a-zA-Z0-9]+$/);
|
||||||
|
if (match) {
|
||||||
|
form.value.spec = match[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('提取类别编码后缀失败', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -941,7 +1068,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: '',
|
||||||
@ -1038,6 +1165,17 @@ const getOptionsList = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取计量单位字典(新增/编辑弹窗下拉历史)
|
||||||
|
const fetchUnitList = () => {
|
||||||
|
getMaterialUnitsAPI().then((res: any) => {
|
||||||
|
if (res.code === 200) {
|
||||||
|
unitOptions.value = res.data || [];
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("获取计量单位字典失败", err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const querySearchCompany = (queryString: string, cb: any) => {
|
const querySearchCompany = (queryString: string, cb: any) => {
|
||||||
const results = queryString
|
const results = queryString
|
||||||
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
|
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
|
||||||
@ -1232,6 +1370,23 @@ const handleEdit = (row: MaterialBaseVO) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 另存为新项:把当前编辑项的数据复制一份,转为"新增"模式提交
|
||||||
|
const handleSaveAs = () => {
|
||||||
|
if (!form.value.id) return; // 防御:新增模式下不该看到此按钮
|
||||||
|
|
||||||
|
// 1. 清除主键:submitForm 用 form.value.id 判空决定走 add / update 接口
|
||||||
|
delete form.value.id;
|
||||||
|
|
||||||
|
// 2. 切换弹窗标题(项目沿用 dialog.title 命名,无 dialogType / isEdit 变量)
|
||||||
|
dialog.title = '新增基础信息';
|
||||||
|
|
||||||
|
// 3. 清空脏检查基准:让 submitForm 走"完整 payload"分支(新增模式)
|
||||||
|
originalForm.value = null;
|
||||||
|
|
||||||
|
// 4. 提示用户
|
||||||
|
ElMessage.success('已成功复制当前数据,已切换至【新增】模式。请修改特定信息(如规格型号)后点击确定保存。');
|
||||||
|
};
|
||||||
|
|
||||||
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
|
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
|
||||||
@ -1585,7 +1740,14 @@ 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 刷新
|
|
||||||
|
// 清理 el-upload 内部 push 的"待上传"占位条目(带 raw 属性的那条 blob URL 占位),
|
||||||
|
// 否则会与下方手动 push 的新条目重复显示
|
||||||
|
const targetList = targetField === 'generalImage' ? fileListImage : fileListManual
|
||||||
|
const staleIndex = targetList.value.findIndex(f => f.raw === file)
|
||||||
|
if (staleIndex !== -1) targetList.value.splice(staleIndex, 1)
|
||||||
|
|
||||||
|
// 手动构造带服务端 URL 的条目并 push,picture-card 即可正常渲染
|
||||||
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
|
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
|
||||||
if (targetField === 'generalImage') {
|
if (targetField === 'generalImage') {
|
||||||
fileListImage.value.push(fileObj)
|
fileListImage.value.push(fileObj)
|
||||||
@ -1593,7 +1755,6 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
|
|||||||
fileListManual.value.push(fileObj)
|
fileListManual.value.push(fileObj)
|
||||||
}
|
}
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success('上传成功')
|
||||||
onSuccess(res)
|
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.msg || '上传失败');
|
ElMessage.error(res.msg || '上传失败');
|
||||||
onError(new Error(res.msg))
|
onError(new Error(res.msg))
|
||||||
@ -1693,6 +1854,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,18 +1891,29 @@ const resetAdvancedFilter = () => {
|
|||||||
getList();
|
getList();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
// 以图搜图跳转:监听路由 keyword 参数,自动搜索并清理 URL
|
||||||
// 1. 修复背景联动:直接对 reactive 对象赋值
|
watch(
|
||||||
if (route.query.keyword) {
|
() => route.query.keyword,
|
||||||
queryParams.keyword = route.query.keyword as string;
|
(newKeyword) => {
|
||||||
|
if (newKeyword) {
|
||||||
|
queryParams.keyword = newKeyword as string;
|
||||||
queryParams.searchField = 'all';
|
queryParams.searchField = 'all';
|
||||||
}
|
|
||||||
|
|
||||||
// 先根据权限初始化列显示状态
|
|
||||||
initColumnPermissions();
|
|
||||||
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
|
|
||||||
getList();
|
getList();
|
||||||
|
// 清理 URL 参数,防止刷新后重复触发搜索
|
||||||
|
router.replace({ path: route.path, query: {} });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initColumnPermissions();
|
||||||
|
// 无外部 keyword 参数时执行默认查询;有 keyword 则由上方的 watch 接管
|
||||||
|
if (!route.query.keyword) {
|
||||||
|
getList();
|
||||||
|
}
|
||||||
getOptionsList();
|
getOptionsList();
|
||||||
|
fetchUnitList();
|
||||||
|
|
||||||
// 2. 修复弹窗锁定逻辑
|
// 2. 修复弹窗锁定逻辑
|
||||||
console.log('--- 准备检测外部跳转参数 ---', route.query);
|
console.log('--- 准备检测外部跳转参数 ---', route.query);
|
||||||
|
|||||||
@ -527,12 +527,14 @@ const totalExportCount = computed(() => {
|
|||||||
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
||||||
const maxBuildableSets = computed(() => {
|
const maxBuildableSets = computed(() => {
|
||||||
if (!currentBomDetail.value?.length) return 0
|
if (!currentBomDetail.value?.length) return 0
|
||||||
return currentBomDetail.value.reduce((minSets, bomItem: any) => {
|
const result = currentBomDetail.value.reduce((minSets, bomItem: any) => {
|
||||||
const dosage = parseFloat(bomItem.dosage) || 0
|
const dosage = parseFloat(bomItem.dosage) || 0
|
||||||
if (dosage <= 0) return minSets
|
if (dosage <= 0) return minSets
|
||||||
const stock = parseFloat(bomItem.current_stock) || 0
|
const stock = parseFloat(bomItem.current_stock) || 0
|
||||||
return Math.min(minSets, Math.floor(stock / dosage))
|
return Math.min(minSets, Math.floor(stock / dosage))
|
||||||
}, Infinity)
|
}, Infinity)
|
||||||
|
|
||||||
|
return result === Infinity ? 0 : result
|
||||||
})
|
})
|
||||||
|
|
||||||
const shortageList = computed(() => {
|
const shortageList = computed(() => {
|
||||||
@ -723,8 +725,8 @@ watch(selectedBomNo, async (newBomNo) => {
|
|||||||
const confirmBomAdd = async () => {
|
const confirmBomAdd = async () => {
|
||||||
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
||||||
|
|
||||||
if (allStockData.value.length === 0) {
|
if (stockList.value.length === 0) {
|
||||||
await ensureAllStockLoaded()
|
await loadStockList()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentBomDetail.value.length === 0) {
|
if (currentBomDetail.value.length === 0) {
|
||||||
@ -745,7 +747,7 @@ const confirmBomAdd = async () => {
|
|||||||
const dosage = parseFloat(bomItem.dosage) || 0
|
const dosage = parseFloat(bomItem.dosage) || 0
|
||||||
const needQty = dosage * bomSets.value
|
const needQty = dosage * bomSets.value
|
||||||
|
|
||||||
const stockCandidate = allStockData.value.find(s =>
|
const stockCandidate = stockList.value.find(s =>
|
||||||
(s.base_id && s.base_id == bomItem.child_id)
|
(s.base_id && s.base_id == bomItem.child_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ========== 新建/编辑弹窗 ========== -->
|
<!-- ========== 新建/编辑弹窗 ========== -->
|
||||||
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<el-form ref="formRef" :model="form" label-width="110px">
|
<el-form ref="formRef" :model="form" label-width="110px">
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@ -74,14 +74,14 @@
|
|||||||
v-model="materialBaseId"
|
v-model="materialBaseId"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="输入名称或规格搜索..."
|
placeholder="输入名称或规格搜索..."
|
||||||
:remote-method="handleSearchMaterialDebounced"
|
:remote-method="handleSearchMaterialDebounced"
|
||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
popper-class="long-dropdown"
|
popper-class="long-dropdown"
|
||||||
v-loadmore="handleLoadMoreMaterials"
|
v-loadmore="handleLoadMoreMaterials"
|
||||||
@visible-change="onMaterialDropdownVisibleChange"
|
@visible-change="onMaterialDropdownVisibleChange"
|
||||||
@ -171,7 +171,7 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- ========== 详情弹窗 ========== -->
|
<!-- ========== 详情弹窗 ========== -->
|
||||||
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close>
|
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
|
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态">
|
<el-descriptions-item label="状态">
|
||||||
@ -215,7 +215,7 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- ========== 驳回原因弹窗 ========== -->
|
<!-- ========== 驳回原因弹窗 ========== -->
|
||||||
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
|
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="申请单号">
|
<el-form-item label="申请单号">
|
||||||
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
||||||
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (query: string) => {
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = query
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
hasNextPage.value = true
|
hasNextPage.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await searchMaterialPurchase(query, 1)
|
const res: any = await searchMaterialPurchase(safeQuery, 1)
|
||||||
materialOptions.value = res.data || []
|
materialOptions.value = res.data || []
|
||||||
hasNextPage.value = res.has_next !== false
|
hasNextPage.value = res.has_next !== false
|
||||||
} finally {
|
} finally {
|
||||||
@ -431,9 +434,14 @@ const handleLoadMoreMaterials = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onMaterialDropdownVisibleChange = (visible: boolean) => {
|
const onMaterialDropdownVisibleChange = (visible: boolean) => {
|
||||||
if (visible && materialOptions.value.length === 0) {
|
if (!visible) return
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||||
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
handleSearchMaterial('')
|
handleSearchMaterial('')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMaterialSelected = (id: number | null) => {
|
const onMaterialSelected = (id: number | null) => {
|
||||||
|
|||||||
@ -48,17 +48,17 @@
|
|||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<el-select
|
<el-cascader
|
||||||
v-model="queryParams.category"
|
v-model="searchCategoryPath"
|
||||||
|
:options="categoryTreeOptions"
|
||||||
|
:props="{ checkStrictly: true }"
|
||||||
placeholder="类别"
|
placeholder="类别"
|
||||||
class="filter-item-select"
|
class="filter-item-select"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
|
style="width: 220px;"
|
||||||
@change="fetchData"
|
@change="fetchData"
|
||||||
style="width: 160px;"
|
/>
|
||||||
>
|
|
||||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
|
||||||
</el-select>
|
|
||||||
|
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.material_type"
|
v-model="queryParams.material_type"
|
||||||
@ -111,18 +111,32 @@
|
|||||||
<template #reference>
|
<template #reference>
|
||||||
<el-button :icon="Setting" circle class="circle-btn" />
|
<el-button :icon="Setting" circle class="circle-btn" />
|
||||||
</template>
|
</template>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
|
||||||
|
<span style="font-weight: bold;">列展示设置</span>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="isAllSelected"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
@change="handleCheckAllChange"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
||||||
<div class="col-group-title">基础信息</div>
|
<div class="col-group-title">基础信息</div>
|
||||||
<el-row :gutter="10">
|
<el-row :gutter="10">
|
||||||
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
|
<template v-for="c in baseColumns" :key="c.prop">
|
||||||
|
<el-col :span="12" v-if="hasColumnPermission(c.prop)">
|
||||||
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
</template>
|
||||||
</el-row>
|
</el-row>
|
||||||
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
|
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
|
||||||
<el-row :gutter="10">
|
<el-row :gutter="10">
|
||||||
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
|
<template v-for="c in stockColumns" :key="c.prop">
|
||||||
|
<el-col :span="12" v-if="hasColumnPermission(c.prop)">
|
||||||
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
</template>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
@ -193,6 +207,7 @@
|
|||||||
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
|
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
|
||||||
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
|
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
|
hide-on-click-modal
|
||||||
fit="cover"
|
fit="cover"
|
||||||
lazy
|
lazy
|
||||||
>
|
>
|
||||||
@ -226,11 +241,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>
|
||||||
@ -253,7 +264,7 @@
|
|||||||
:width="'min(1000px, 95vw)'"
|
:width="'min(1000px, 95vw)'"
|
||||||
top="4vh"
|
top="4vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
:close-on-click-modal="!isUploading"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="!isUploading"
|
:close-on-press-escape="!isUploading"
|
||||||
:show-close="!isUploading"
|
:show-close="!isUploading"
|
||||||
class="stylish-dialog compact-layout"
|
class="stylish-dialog compact-layout"
|
||||||
@ -287,7 +298,7 @@
|
|||||||
v-model="form.base_id"
|
v-model="form.base_id"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="请输入名称或规格进行检索..."
|
placeholder="请输入名称或规格进行检索..."
|
||||||
:remote-method="handleSearchMaterialDebounced"
|
:remote-method="handleSearchMaterialDebounced"
|
||||||
@ -295,7 +306,7 @@
|
|||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
v-loadmore="loadMoreMaterials"
|
v-loadmore="loadMoreMaterials"
|
||||||
popper-class="long-dropdown"
|
popper-class="long-dropdown"
|
||||||
>
|
>
|
||||||
@ -456,7 +467,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 +487,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>
|
||||||
@ -638,8 +651,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
||||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<WebRtcCamera
|
<WebRtcCamera
|
||||||
ref="cameraRef"
|
ref="cameraRef"
|
||||||
@photo-submit="handleCameraConfirm"
|
@photo-submit="handleCameraConfirm"
|
||||||
@ -647,7 +660,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div v-loading="printLoading" class="preview-box">
|
<div v-loading="printLoading" class="preview-box">
|
||||||
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
||||||
@ -806,6 +819,17 @@ const isUploading = ref(false)
|
|||||||
const categoryOptions = ref<string[]>([])
|
const categoryOptions = ref<string[]>([])
|
||||||
const typeOptions = ref<string[]>([])
|
const typeOptions = ref<string[]>([])
|
||||||
const companyOptions = ref<string[]>([])
|
const companyOptions = ref<string[]>([])
|
||||||
|
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
|
||||||
|
|
||||||
|
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
|
||||||
|
const searchCategoryPath = computed({
|
||||||
|
get() {
|
||||||
|
return queryParams.category ? queryParams.category.split('/') : [];
|
||||||
|
},
|
||||||
|
set(val: string[] | null) {
|
||||||
|
queryParams.category = val && val.length > 0 ? val.join('/') : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -899,6 +923,8 @@ const operatorOptions = ref([
|
|||||||
{ value: 'le', label: '小于等于' }
|
{ value: 'le', label: '小于等于' }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// ================= 第一步:声明基础数据 =================
|
||||||
|
|
||||||
// 基础列
|
// 基础列
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{prop: 'company_name', label: '所属公司'},
|
{prop: 'company_name', label: '所属公司'},
|
||||||
@ -939,6 +965,8 @@ const stockColumns = [
|
|||||||
{prop: 'inspection_report', label: '检测报告', minWidth: '100'}
|
{prop: 'inspection_report', label: '检测报告', minWidth: '100'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const allColumns = [...baseColumns, ...stockColumns]
|
||||||
|
|
||||||
// 列与权限Code的映射关系(数据库中的code)
|
// 列与权限Code的映射关系(数据库中的code)
|
||||||
const permissionMap: Record<string, string> = {
|
const permissionMap: Record<string, string> = {
|
||||||
id: 'inbound_buy:id',
|
id: 'inbound_buy:id',
|
||||||
@ -973,14 +1001,15 @@ const permissionMap: Record<string, string> = {
|
|||||||
inspection_report: 'inbound_buy:inspection_report'
|
inspection_report: 'inbound_buy:inspection_report'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化列显示状态(纯权限驱动,废除本地缓存)
|
// ================= 第二步:声明响应式变量 =================
|
||||||
const initColumnPermissions = () => {
|
const visibleColumnProps = ref<string[]>([])
|
||||||
visibleColumnProps.value = allColumns
|
|
||||||
.filter(col => hasColumnPermission(col.prop))
|
|
||||||
.map(col => col.prop)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查列权限
|
// ================= 第三步:按依赖顺序放置方法和监听 =================
|
||||||
|
|
||||||
|
// 1. 获取唯一缓存 Key
|
||||||
|
const getStorageKey = () => `MOM_INBOUND_BUY_COLS_${userStore.username || 'DEFAULT'}`;
|
||||||
|
|
||||||
|
// 2. 检查列权限(依赖 permissionMap)
|
||||||
const hasColumnPermission = (prop: string) => {
|
const hasColumnPermission = (prop: string) => {
|
||||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
||||||
return true
|
return true
|
||||||
@ -989,9 +1018,49 @@ const hasColumnPermission = (prop: string) => {
|
|||||||
return code ? userStore.hasPermission(code) : false
|
return code ? userStore.hasPermission(code) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
const allColumns = [...baseColumns, ...stockColumns]
|
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey)
|
||||||
|
const initColumnPermissions = () => {
|
||||||
|
const allowedProps = allColumns
|
||||||
|
.filter(col => hasColumnPermission(col.prop))
|
||||||
|
.map(col => col.prop);
|
||||||
|
|
||||||
const visibleColumnProps = ref<string[]>([])
|
const cachedData = localStorage.getItem(getStorageKey());
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
const parsedCache = JSON.parse(cachedData);
|
||||||
|
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析列缓存失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleColumnProps.value = allowedProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 监听:只要用户勾选了列,就存入本地缓存
|
||||||
|
watch(visibleColumnProps, (newVal) => {
|
||||||
|
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// 5. 全选功能的计算属性和事件
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
|
||||||
|
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isIndeterminate = computed(() => {
|
||||||
|
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
|
||||||
|
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCheckAllChange = (val: boolean) => {
|
||||||
|
if (val) {
|
||||||
|
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
|
||||||
|
} else {
|
||||||
|
visibleColumnProps.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: undefined, base_id: undefined as number | undefined,
|
id: undefined, base_id: undefined as number | undefined,
|
||||||
@ -1067,7 +1136,16 @@ const querySearchCurrency = (queryString: string, cb: any) => {
|
|||||||
cb(filtered)
|
cb(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
|
if (!visible) return
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||||
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
|
handleSearchMaterial('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchMaterialDebounced = (query: string) => {
|
const handleSearchMaterialDebounced = (query: string) => {
|
||||||
if (searchTimer) clearTimeout(searchTimer)
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
@ -1077,13 +1155,16 @@ const handleSearchMaterialDebounced = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (query: string) => {
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = query
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await searchMaterialBase(query, 1)
|
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
||||||
materialOptions.value = apiResults
|
materialOptions.value = apiResults
|
||||||
@ -1325,6 +1406,7 @@ const fetchOptions = async () => {
|
|||||||
const res: any = await getFilterOptions()
|
const res: any = await getFilterOptions()
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
categoryOptions.value = res.data.categories
|
categoryOptions.value = res.data.categories
|
||||||
|
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
|
||||||
typeOptions.value = res.data.types
|
typeOptions.value = res.data.types
|
||||||
companyOptions.value = res.data.companies
|
companyOptions.value = res.data.companies
|
||||||
}
|
}
|
||||||
@ -1333,6 +1415,30 @@ const fetchOptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
|
||||||
|
const buildCategoryTree = (categories: string[]) => {
|
||||||
|
const root: { value: string; label: string; children?: any[] }[] = [];
|
||||||
|
categories.forEach((cat: string) => {
|
||||||
|
if (!cat) return;
|
||||||
|
const parts = cat.split('/');
|
||||||
|
let currentLevel = root;
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
let existingNode = currentLevel.find(n => n.value === part);
|
||||||
|
if (!existingNode) {
|
||||||
|
existingNode = { value: part, label: part };
|
||||||
|
currentLevel.push(existingNode);
|
||||||
|
}
|
||||||
|
if (index < parts.length - 1) {
|
||||||
|
if (!existingNode.children) {
|
||||||
|
existingNode.children = [];
|
||||||
|
}
|
||||||
|
currentLevel = existingNode.children as any[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
// 加载库位树数据
|
// 加载库位树数据
|
||||||
const loadWarehouseTree = async () => {
|
const loadWarehouseTree = async () => {
|
||||||
try {
|
try {
|
||||||
@ -1631,7 +1737,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// 打印逻辑
|
// 打印逻辑
|
||||||
|
|||||||
@ -47,17 +47,17 @@
|
|||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<el-select
|
<el-cascader
|
||||||
v-model="queryParams.category"
|
v-model="searchCategoryPath"
|
||||||
|
:options="categoryTreeOptions"
|
||||||
|
:props="{ checkStrictly: true }"
|
||||||
placeholder="类别"
|
placeholder="类别"
|
||||||
class="filter-item-select"
|
class="filter-item-select"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
|
style="width: 220px;"
|
||||||
@change="fetchData"
|
@change="fetchData"
|
||||||
style="width: 160px;"
|
/>
|
||||||
>
|
|
||||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
|
||||||
</el-select>
|
|
||||||
|
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.material_type"
|
v-model="queryParams.material_type"
|
||||||
@ -123,9 +123,23 @@
|
|||||||
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
|
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
|
||||||
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
|
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
|
||||||
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
|
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
|
||||||
|
<span style="font-weight: bold;">列展示设置</span>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="isAllSelected"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
@change="handleCheckAllChange"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
||||||
<el-row :gutter="10">
|
<el-row :gutter="10">
|
||||||
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
|
<template v-for="c in allColumns" :key="c.prop">
|
||||||
|
<el-col :span="8" v-if="hasColumnPermission(c.prop)">
|
||||||
|
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
@ -193,6 +207,7 @@
|
|||||||
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
|
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
|
||||||
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
|
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
|
hide-on-click-modal
|
||||||
fit="cover"
|
fit="cover"
|
||||||
lazy
|
lazy
|
||||||
>
|
>
|
||||||
@ -227,7 +242,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>
|
||||||
@ -244,7 +259,7 @@
|
|||||||
@current-change="fetchData"
|
@current-change="fetchData"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="!isUploading" :close-on-press-escape="!isUploading" :show-close="!isUploading" class="stylish-dialog compact-layout">
|
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="false" :close-on-press-escape="!isUploading" :show-close="!isUploading" class="stylish-dialog compact-layout">
|
||||||
<div class="dialog-scroll-container">
|
<div class="dialog-scroll-container">
|
||||||
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
|
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
|
||||||
|
|
||||||
@ -272,7 +287,7 @@
|
|||||||
v-model="form.base_id"
|
v-model="form.base_id"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="请输入名称或规格进行检索..."
|
placeholder="请输入名称或规格进行检索..."
|
||||||
:remote-method="handleSearchMaterial"
|
:remote-method="handleSearchMaterial"
|
||||||
@ -280,7 +295,7 @@
|
|||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
v-loadmore="loadMoreMaterials"
|
v-loadmore="loadMoreMaterials"
|
||||||
popper-class="product-dropdown"
|
popper-class="product-dropdown"
|
||||||
>
|
>
|
||||||
@ -389,7 +404,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 +418,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 +431,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>
|
||||||
@ -445,11 +460,14 @@
|
|||||||
v-model="form.bom_code"
|
v-model="form.bom_code"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="搜规格/编号"
|
:disabled="!form.spec_model"
|
||||||
|
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
|
||||||
:remote-method="handleSearchBom"
|
:remote-method="handleSearchBom"
|
||||||
:loading="bomSearchLoading"
|
:loading="bomSearchLoading"
|
||||||
@change="handleBomSelect"
|
@change="handleBomSelect"
|
||||||
|
default-first-option="true"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@ -529,10 +547,10 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
|
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
|
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<WebRtcCamera
|
<WebRtcCamera
|
||||||
ref="cameraRef"
|
ref="cameraRef"
|
||||||
@photo-submit="handleCameraConfirm"
|
@photo-submit="handleCameraConfirm"
|
||||||
@ -540,7 +558,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div v-loading="printLoading" class="preview-box">
|
<div v-loading="printLoading" class="preview-box">
|
||||||
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
||||||
@ -572,7 +590,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 {
|
||||||
@ -660,6 +678,18 @@ const isUploading = ref(false)
|
|||||||
|
|
||||||
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
|
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
|
||||||
const categoryOptions = ref<string[]>([])
|
const categoryOptions = ref<string[]>([])
|
||||||
|
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
|
||||||
|
|
||||||
|
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
|
||||||
|
const searchCategoryPath = computed({
|
||||||
|
get() {
|
||||||
|
return queryParams.category ? queryParams.category.split('/') : [];
|
||||||
|
},
|
||||||
|
set(val: string[] | null) {
|
||||||
|
queryParams.category = val && val.length > 0 ? val.join('/') : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const typeOptions = ref<string[]>([])
|
const typeOptions = ref<string[]>([])
|
||||||
const companyOptions = ref<string[]>([]) // [新增]
|
const companyOptions = ref<string[]>([]) // [新增]
|
||||||
const advancedFilterVisible = ref(false)
|
const advancedFilterVisible = ref(false)
|
||||||
@ -745,6 +775,8 @@ const scannerDialogVisible = ref(false)
|
|||||||
// 库位级联选择器数据
|
// 库位级联选择器数据
|
||||||
const warehouseOptions = ref<any[]>([])
|
const warehouseOptions = ref<any[]>([])
|
||||||
|
|
||||||
|
// ================= 第一步:声明基础数据 =================
|
||||||
|
|
||||||
// [核心优化] 所有列定义
|
// [核心优化] 所有列定义
|
||||||
const allColumns = [
|
const allColumns = [
|
||||||
{ prop: 'company_name', label: '所属公司', minWidth: '100', sortable: true }, // [新增]
|
{ prop: 'company_name', label: '所属公司', minWidth: '100', sortable: true }, // [新增]
|
||||||
@ -810,15 +842,15 @@ const permissionMap: Record<string, string> = {
|
|||||||
detail_link: 'inbound_product:detail_link',
|
detail_link: 'inbound_product:detail_link',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据用户权限初始化列显示状态
|
// ================= 第二步:声明响应式变量 =================
|
||||||
// 初始化列显示状态(纯权限驱动,废除本地缓存)
|
const visibleColumnProps = ref<string[]>([])
|
||||||
const initColumnPermissions = () => {
|
|
||||||
visibleColumnProps.value = allColumns
|
|
||||||
.filter(col => hasColumnPermission(col.prop))
|
|
||||||
.map(col => col.prop)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查列权限
|
// ================= 第三步:按依赖顺序放置方法和监听 =================
|
||||||
|
|
||||||
|
// 1. 获取唯一缓存 Key
|
||||||
|
const getStorageKey = () => `MOM_INBOUND_PROD_COLS_${userStore.username || 'DEFAULT'}`;
|
||||||
|
|
||||||
|
// 2. 检查列权限(依赖 permissionMap)
|
||||||
const hasColumnPermission = (prop: string) => {
|
const hasColumnPermission = (prop: string) => {
|
||||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
||||||
return true
|
return true
|
||||||
@ -827,6 +859,50 @@ const hasColumnPermission = (prop: string) => {
|
|||||||
return code ? userStore.hasPermission(code) : false
|
return code ? userStore.hasPermission(code) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey)
|
||||||
|
const initColumnPermissions = () => {
|
||||||
|
const allowedProps = allColumns
|
||||||
|
.filter(col => hasColumnPermission(col.prop))
|
||||||
|
.map(col => col.prop);
|
||||||
|
|
||||||
|
const cachedData = localStorage.getItem(getStorageKey());
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
const parsedCache = JSON.parse(cachedData);
|
||||||
|
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析列缓存失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleColumnProps.value = allowedProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 监听:只要用户勾选了列,就存入本地缓存
|
||||||
|
watch(visibleColumnProps, (newVal) => {
|
||||||
|
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// 5. 全选功能的计算属性和事件
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
|
||||||
|
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isIndeterminate = computed(() => {
|
||||||
|
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
|
||||||
|
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCheckAllChange = (val: boolean) => {
|
||||||
|
if (val) {
|
||||||
|
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
|
||||||
|
} else {
|
||||||
|
visibleColumnProps.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ★ 智能聚合:当无序列号权限时,按 SKU 聚合库存
|
// ★ 智能聚合:当无序列号权限时,按 SKU 聚合库存
|
||||||
const displayData = computed(() => {
|
const displayData = computed(() => {
|
||||||
// 检查是否有序列号权限
|
// 检查是否有序列号权限
|
||||||
@ -861,7 +937,6 @@ const displayData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
|
||||||
const visibleColumnProps = ref<string[]>([])
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: undefined, base_id: undefined as number | undefined,
|
id: undefined, base_id: undefined as number | undefined,
|
||||||
@ -883,9 +958,15 @@ const form = reactive({
|
|||||||
// BOM Search Logic
|
// BOM Search Logic
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const handleSearchBom = async (query: string) => {
|
const handleSearchBom = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||||
|
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||||
|
// 3) 常规 trim
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
bomSearchLoading.value = true
|
bomSearchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await searchBom(query)
|
const res: any = await searchBom(safeQuery, form.spec_model)
|
||||||
bomOptions.value = res.data || []
|
bomOptions.value = res.data || []
|
||||||
} finally { bomSearchLoading.value = false }
|
} finally { bomSearchLoading.value = false }
|
||||||
}
|
}
|
||||||
@ -984,7 +1065,17 @@ const rules = {
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Material Search & Population Logic (已修改)
|
// Material Search & Population Logic (已修改)
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
|
if (!visible) return
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||||
|
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
|
||||||
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
|
handleSearchMaterial('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchMaterialDebounced = (query: string) => {
|
const handleSearchMaterialDebounced = (query: string) => {
|
||||||
if (searchTimer) clearTimeout(searchTimer)
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
@ -994,13 +1085,19 @@ const handleSearchMaterialDebounced = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (query: string) => {
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||||
|
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||||
|
// 3) 常规 trim
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = query
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await searchMaterialBase(query, 1)
|
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||||
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
|
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||||
materialOptions.value = apiResults
|
materialOptions.value = apiResults
|
||||||
hasNextPage.value = res.data?.has_next ?? false
|
hasNextPage.value = res.data?.has_next ?? false
|
||||||
@ -1036,6 +1133,10 @@ const onMaterialSelected = async (val: number) => {
|
|||||||
form.material_type = item.type
|
form.material_type = item.type
|
||||||
form.category = item.category
|
form.category = item.category
|
||||||
form.unit = item.unit
|
form.unit = item.unit
|
||||||
|
// 切换物料时清空已选 BOM,防止脏数据
|
||||||
|
form.bom_code = ''
|
||||||
|
form.bom_version = ''
|
||||||
|
bomOptions.value = []
|
||||||
|
|
||||||
// 获取该物料历史入库库位(新增独立接口)
|
// 获取该物料历史入库库位(新增独立接口)
|
||||||
try {
|
try {
|
||||||
@ -1097,6 +1198,7 @@ const fetchOptions = async () => {
|
|||||||
const res: any = await getFilterOptions()
|
const res: any = await getFilterOptions()
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
categoryOptions.value = res.data.categories
|
categoryOptions.value = res.data.categories
|
||||||
|
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
|
||||||
typeOptions.value = res.data.types
|
typeOptions.value = res.data.types
|
||||||
companyOptions.value = res.data.companies // [新增]
|
companyOptions.value = res.data.companies // [新增]
|
||||||
}
|
}
|
||||||
@ -1105,6 +1207,30 @@ const fetchOptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
|
||||||
|
const buildCategoryTree = (categories: string[]) => {
|
||||||
|
const root: { value: string; label: string; children?: any[] }[] = [];
|
||||||
|
categories.forEach((cat: string) => {
|
||||||
|
if (!cat) return;
|
||||||
|
const parts = cat.split('/');
|
||||||
|
let currentLevel = root;
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
let existingNode = currentLevel.find(n => n.value === part);
|
||||||
|
if (!existingNode) {
|
||||||
|
existingNode = { value: part, label: part };
|
||||||
|
currentLevel.push(existingNode);
|
||||||
|
}
|
||||||
|
if (index < parts.length - 1) {
|
||||||
|
if (!existingNode.children) {
|
||||||
|
existingNode.children = [];
|
||||||
|
}
|
||||||
|
currentLevel = existingNode.children as any[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
// 加载库位树数据
|
// 加载库位树数据
|
||||||
const loadWarehouseTree = async () => {
|
const loadWarehouseTree = async () => {
|
||||||
try {
|
try {
|
||||||
@ -1358,7 +1484,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 }
|
||||||
|
|||||||
@ -48,17 +48,17 @@
|
|||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<el-select
|
<el-cascader
|
||||||
v-model="queryParams.category"
|
v-model="searchCategoryPath"
|
||||||
|
:options="categoryTreeOptions"
|
||||||
|
:props="{ checkStrictly: true }"
|
||||||
placeholder="类别"
|
placeholder="类别"
|
||||||
class="filter-item-select"
|
class="filter-item-select"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
|
style="width: 220px;"
|
||||||
@change="fetchData"
|
@change="fetchData"
|
||||||
style="width: 160px;"
|
/>
|
||||||
>
|
|
||||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
|
||||||
</el-select>
|
|
||||||
|
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.material_type"
|
v-model="queryParams.material_type"
|
||||||
@ -127,18 +127,32 @@
|
|||||||
<template #reference>
|
<template #reference>
|
||||||
<el-button :icon="Setting" circle class="circle-btn" />
|
<el-button :icon="Setting" circle class="circle-btn" />
|
||||||
</template>
|
</template>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
|
||||||
|
<span style="font-weight: bold;">列展示设置</span>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="isAllSelected"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
@change="handleCheckAllChange"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
||||||
<div class="col-group-title">基础信息</div>
|
<div class="col-group-title">基础信息</div>
|
||||||
<el-row :gutter="10">
|
<el-row :gutter="10">
|
||||||
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
|
<template v-for="c in baseColumns" :key="c.prop">
|
||||||
|
<el-col :span="12" v-if="hasColumnPermission(c.prop)">
|
||||||
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
</template>
|
||||||
</el-row>
|
</el-row>
|
||||||
<div class="col-group-title" style="margin-top:10px">生产与库存</div>
|
<div class="col-group-title" style="margin-top:10px">生产与库存</div>
|
||||||
<el-row :gutter="10">
|
<el-row :gutter="10">
|
||||||
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
|
<template v-for="c in stockColumns" :key="c.prop">
|
||||||
|
<el-col :span="12" v-if="hasColumnPermission(c.prop)">
|
||||||
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
</template>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
@ -216,6 +230,7 @@
|
|||||||
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
|
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
|
||||||
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
|
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
|
||||||
preview-teleported
|
preview-teleported
|
||||||
|
hide-on-click-modal
|
||||||
fit="cover"
|
fit="cover"
|
||||||
lazy
|
lazy
|
||||||
>
|
>
|
||||||
@ -250,11 +265,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>
|
||||||
@ -277,7 +288,7 @@
|
|||||||
width="min(1000px, 95vw)"
|
width="min(1000px, 95vw)"
|
||||||
top="5vh"
|
top="5vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
:close-on-click-modal="!isUploading"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="!isUploading"
|
:close-on-press-escape="!isUploading"
|
||||||
:show-close="!isUploading"
|
:show-close="!isUploading"
|
||||||
class="stylish-dialog compact-layout"
|
class="stylish-dialog compact-layout"
|
||||||
@ -311,7 +322,7 @@
|
|||||||
v-model="form.base_id"
|
v-model="form.base_id"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="请输入名称或规格进行检索..."
|
placeholder="请输入名称或规格进行检索..."
|
||||||
:remote-method="handleSearchMaterial"
|
:remote-method="handleSearchMaterial"
|
||||||
@ -319,7 +330,7 @@
|
|||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
v-loadmore="loadMoreMaterials"
|
v-loadmore="loadMoreMaterials"
|
||||||
popper-class="long-dropdown"
|
popper-class="long-dropdown"
|
||||||
>
|
>
|
||||||
@ -468,7 +479,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 +491,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>
|
||||||
@ -514,11 +525,14 @@
|
|||||||
v-model="form.bom_code"
|
v-model="form.bom_code"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="搜规格/编号"
|
:disabled="!form.spec_model"
|
||||||
|
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
|
||||||
:remote-method="handleSearchBom"
|
:remote-method="handleSearchBom"
|
||||||
:loading="bomSearchLoading"
|
:loading="bomSearchLoading"
|
||||||
@change="handleBomSelect"
|
@change="handleBomSelect"
|
||||||
|
default-first-option="true"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@ -592,15 +606,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
||||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<WebRtcCamera
|
<WebRtcCamera
|
||||||
ref="cameraRef"
|
ref="cameraRef"
|
||||||
@photo-submit="handleCameraConfirm"
|
@photo-submit="handleCameraConfirm"
|
||||||
@cancel="cameraDialogVisible = false"
|
@cancel="cameraDialogVisible = false"
|
||||||
/>
|
/>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div v-loading="printLoading" class="preview-box">
|
<div v-loading="printLoading" class="preview-box">
|
||||||
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
||||||
@ -627,7 +641,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 {
|
||||||
@ -717,6 +731,18 @@ const isUploading = ref(false)
|
|||||||
|
|
||||||
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
|
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
|
||||||
const categoryOptions = ref<string[]>([])
|
const categoryOptions = ref<string[]>([])
|
||||||
|
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
|
||||||
|
|
||||||
|
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
|
||||||
|
const searchCategoryPath = computed({
|
||||||
|
get() {
|
||||||
|
return queryParams.category ? queryParams.category.split('/') : [];
|
||||||
|
},
|
||||||
|
set(val: string[] | null) {
|
||||||
|
queryParams.category = val && val.length > 0 ? val.join('/') : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const typeOptions = ref<string[]>([])
|
const typeOptions = ref<string[]>([])
|
||||||
const companyOptions = ref<string[]>([]) // [新增]
|
const companyOptions = ref<string[]>([]) // [新增]
|
||||||
const advancedFilterVisible = ref(false)
|
const advancedFilterVisible = ref(false)
|
||||||
@ -802,6 +828,8 @@ const warehouseOptions = ref<any[]>([])
|
|||||||
const entryMode = ref('batch')
|
const entryMode = ref('batch')
|
||||||
const modeLocked = ref(false)
|
const modeLocked = ref(false)
|
||||||
|
|
||||||
|
// ================= 第一步:声明基础数据 =================
|
||||||
|
|
||||||
// 列定义
|
// 列定义
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{prop: 'company_name', label: '所属公司', sortable: true}, // [新增]
|
{prop: 'company_name', label: '所属公司', sortable: true}, // [新增]
|
||||||
@ -838,14 +866,8 @@ const stockColumns = [
|
|||||||
{prop: 'quality_report_link', label: '质量报告', minWidth: '100', sortable: false},
|
{prop: 'quality_report_link', label: '质量报告', minWidth: '100', sortable: false},
|
||||||
{prop: 'detail_link', label: '详情链接', minWidth: '100', sortable: false},
|
{prop: 'detail_link', label: '详情链接', minWidth: '100', sortable: false},
|
||||||
]
|
]
|
||||||
const allColumns = [...baseColumns, ...stockColumns]
|
|
||||||
|
|
||||||
// 初始化列显示状态(纯权限驱动,废除本地缓存)
|
const allColumns = [...baseColumns, ...stockColumns]
|
||||||
const initColumnPermissions = () => {
|
|
||||||
visibleColumnProps.value = allColumns
|
|
||||||
.filter(col => hasColumnPermission(col.prop))
|
|
||||||
.map(col => col.prop)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列与权限Code的映射关系(数据库中的code)
|
// 列与权限Code的映射关系(数据库中的code)
|
||||||
const permissionMap: Record<string, string> = {
|
const permissionMap: Record<string, string> = {
|
||||||
@ -886,7 +908,15 @@ const permissionMap: Record<string, string> = {
|
|||||||
detail_link: 'inbound_semi:detail_link',
|
detail_link: 'inbound_semi:detail_link',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查列权限
|
// ================= 第二步:声明响应式变量 =================
|
||||||
|
const visibleColumnProps = ref<string[]>([])
|
||||||
|
|
||||||
|
// ================= 第三步:按依赖顺序放置方法和监听 =================
|
||||||
|
|
||||||
|
// 1. 获取唯一缓存 Key
|
||||||
|
const getStorageKey = () => `MOM_INBOUND_SEMI_COLS_${userStore.username || 'DEFAULT'}`;
|
||||||
|
|
||||||
|
// 2. 检查列权限(依赖 permissionMap)
|
||||||
const hasColumnPermission = (prop: string) => {
|
const hasColumnPermission = (prop: string) => {
|
||||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
|
||||||
return true
|
return true
|
||||||
@ -895,8 +925,51 @@ const hasColumnPermission = (prop: string) => {
|
|||||||
return code ? userStore.hasPermission(code) : false
|
return code ? userStore.hasPermission(code) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey)
|
||||||
|
const initColumnPermissions = () => {
|
||||||
|
const allowedProps = allColumns
|
||||||
|
.filter(col => hasColumnPermission(col.prop))
|
||||||
|
.map(col => col.prop);
|
||||||
|
|
||||||
|
const cachedData = localStorage.getItem(getStorageKey());
|
||||||
|
if (cachedData) {
|
||||||
|
try {
|
||||||
|
const parsedCache = JSON.parse(cachedData);
|
||||||
|
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析列缓存失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleColumnProps.value = allowedProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 监听:只要用户勾选了列,就存入本地缓存
|
||||||
|
watch(visibleColumnProps, (newVal) => {
|
||||||
|
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// 5. 全选功能的计算属性和事件
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
|
||||||
|
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isIndeterminate = computed(() => {
|
||||||
|
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
|
||||||
|
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCheckAllChange = (val: boolean) => {
|
||||||
|
if (val) {
|
||||||
|
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
|
||||||
|
} else {
|
||||||
|
visibleColumnProps.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
|
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
|
||||||
const visibleColumnProps = ref<string[]>([])
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: undefined, base_id: undefined as number | undefined,
|
id: undefined, base_id: undefined as number | undefined,
|
||||||
@ -932,9 +1005,15 @@ watch(
|
|||||||
// BOM Search Logic
|
// BOM Search Logic
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const handleSearchBom = async (query: string) => {
|
const handleSearchBom = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||||
|
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||||
|
// 3) 常规 trim
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
bomSearchLoading.value = true
|
bomSearchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await searchBom(query)
|
const res: any = await searchBom(safeQuery, form.spec_model)
|
||||||
bomOptions.value = res.data || []
|
bomOptions.value = res.data || []
|
||||||
} finally { bomSearchLoading.value = false }
|
} finally { bomSearchLoading.value = false }
|
||||||
}
|
}
|
||||||
@ -980,7 +1059,17 @@ const handleManagerSelect = (item: any) => {
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Material Search (Matches Buy.vue)
|
// Material Search (Matches Buy.vue)
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
|
if (!visible) return
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||||
|
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
|
||||||
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
|
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||||
|
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||||
|
handleSearchMaterial('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchMaterialDebounced = (query: string) => {
|
const handleSearchMaterialDebounced = (query: string) => {
|
||||||
if (searchTimer) clearTimeout(searchTimer)
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
@ -990,13 +1079,19 @@ const handleSearchMaterialDebounced = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (query: string) => {
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||||
|
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||||
|
// 3) 常规 trim
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchKeyword.value = query
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await searchMaterialBase(query, 1)
|
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||||
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false}))
|
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false}))
|
||||||
materialOptions.value = apiResults
|
materialOptions.value = apiResults
|
||||||
hasNextPage.value = res.data?.has_next ?? false
|
hasNextPage.value = res.data?.has_next ?? false
|
||||||
@ -1032,6 +1127,10 @@ const onMaterialSelected = async (val: number) => {
|
|||||||
form.category = item.category
|
form.category = item.category
|
||||||
form.unit = item.unit
|
form.unit = item.unit
|
||||||
form.material_type = item.type
|
form.material_type = item.type
|
||||||
|
// 切换物料时清空已选 BOM,防止脏数据
|
||||||
|
form.bom_code = ''
|
||||||
|
form.bom_version = ''
|
||||||
|
bomOptions.value = []
|
||||||
checkHistoryAndSetMode(item.id)
|
checkHistoryAndSetMode(item.id)
|
||||||
|
|
||||||
// 获取该物料历史入库库位(新增独立接口)
|
// 获取该物料历史入库库位(新增独立接口)
|
||||||
@ -1190,6 +1289,7 @@ const fetchOptions = async () => {
|
|||||||
const res: any = await getFilterOptions()
|
const res: any = await getFilterOptions()
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
categoryOptions.value = res.data.categories
|
categoryOptions.value = res.data.categories
|
||||||
|
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
|
||||||
typeOptions.value = res.data.types
|
typeOptions.value = res.data.types
|
||||||
companyOptions.value = res.data.companies // [新增]
|
companyOptions.value = res.data.companies // [新增]
|
||||||
}
|
}
|
||||||
@ -1198,6 +1298,30 @@ const fetchOptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
|
||||||
|
const buildCategoryTree = (categories: string[]) => {
|
||||||
|
const root: { value: string; label: string; children?: any[] }[] = [];
|
||||||
|
categories.forEach((cat: string) => {
|
||||||
|
if (!cat) return;
|
||||||
|
const parts = cat.split('/');
|
||||||
|
let currentLevel = root;
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
let existingNode = currentLevel.find(n => n.value === part);
|
||||||
|
if (!existingNode) {
|
||||||
|
existingNode = { value: part, label: part };
|
||||||
|
currentLevel.push(existingNode);
|
||||||
|
}
|
||||||
|
if (index < parts.length - 1) {
|
||||||
|
if (!existingNode.children) {
|
||||||
|
existingNode.children = [];
|
||||||
|
}
|
||||||
|
currentLevel = existingNode.children as any[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
// 加载库位树数据
|
// 加载库位树数据
|
||||||
const loadWarehouseTree = async () => {
|
const loadWarehouseTree = async () => {
|
||||||
try {
|
try {
|
||||||
@ -1438,7 +1562,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 }
|
||||||
|
|||||||
@ -85,6 +85,8 @@
|
|||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
width="700px"
|
width="700px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
@close="resetDialog"
|
@close="resetDialog"
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
@ -103,14 +105,14 @@
|
|||||||
v-model="form.base_id"
|
v-model="form.base_id"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
placeholder="输入名称或规格..."
|
placeholder="输入名称或规格..."
|
||||||
:remote-method="handleSearchMaterial"
|
:remote-method="handleSearchMaterial"
|
||||||
@visible-change="handleMaterialDropdownVisible"
|
@visible-change="handleMaterialDropdownVisible"
|
||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in materialOptions"
|
v-for="item in materialOptions"
|
||||||
@ -269,6 +271,7 @@ const perPage = ref(20)
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const materialOptions = ref<any[]>([])
|
const materialOptions = ref<any[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
const searchLoading = ref(false)
|
const searchLoading = ref(false)
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
@ -329,15 +332,22 @@ const handlePageChange = (val: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
if (visible && materialOptions.value.length === 0) {
|
if (!visible) return
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||||
|
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||||
handleSearchMaterial('')
|
handleSearchMaterial('')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (query: string) => {
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
|
searchKeyword.value = safeQuery
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await searchMaterialBase(query)
|
const res = await searchMaterialBase(safeQuery)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||||
materialOptions.value = apiResults
|
materialOptions.value = apiResults
|
||||||
|
|||||||
Reference in New Issue
Block a user