44 Commits

Author SHA1 Message Date
DXC
8bb3e58b44 前端全局:<el-select remote> 三道防线扩展到 BOM 配方/采购/采购入库/售后入库
- 第一道防线:<el-select> 模板显式补充 reserve-keyword="true" / default-first-option="true",覆盖 4 文件 5 实例

- 第二道防线:handleRemoteSearch / handleSearchMaterial 首行深度净化 query(零宽字符/控制字符/BOM/不可见 Unicode)

- 第三道防线:handleVisibleChange / handleMaterialDropdownVisible 加竞态守卫,已有 searchKeyword 或 options 非空时跳过默认列表加载;带 debounce 的场景主动 clearTimeout 互斥

- service.vue 原本缺少 searchKeyword 状态,本轮新增 ref('') 专供 el-select 守卫使用

- BomManage.vue 父件/子件共用 handleVisibleChange,两套守卫分别按 parentQueryParams.keyword 和 state.queryParams.keyword 隔离判断
2026-06-04 16:44:59 +08:00
DXC
cdac915a4b 半成品/成品入库:物料/BOM 远程搜索粘贴失效 Bug 修复(三层防御)
- 深度净化 query:剔除零宽字符(U+200B-U+200D)/BOM(U+FEFF)/控制字符(U+0000-U+001F,U+007F-U+009F),应对外部复制粘贴混入隐形 Unicode 导致 ilike 匹配失败的场景

- 显式 reserve-keyword="true" / default-first-option="true":物料与 BOM 两个 <el-select> 全部显式标注,防止 Element 框架在选择后清空关键字(BOM 下拉框原缺失)

- handleMaterialDropdownVisible 竞态守卫:粘贴时 remote-method 与 @visible-change 同时触发,后者会 clearTimeout 前者的 debounce 定时器并加载默认列表覆盖结果。新增 !searchKeyword 守卫 + 主动 clearTimeout 互斥
2026-06-04 16:34:36 +08:00
DXC
8a2da1ac1e 半成品/成品入库:BOM 编号下拉按父件规格联动过滤(前后端双端改造)
- 后端 /inbound/{semi,product}/search-bom 增加 parent_spec 可选参数,Service 层在 MaterialBase.spec_model 上加等值过滤
2026-06-04 16:01:48 +08:00
DXC
332ae3c4cf 基础信息页:产品图/说明书上传后预览不显示修复 + 新增 Ctrl+V 粘贴蓝字提示
- customUpload 改为手动 push:移除 onSuccess(res) 调用,规避 el-upload 2.13.1 handleSuccess 未从 res.data.url 提取 url 的问题
2026-06-04 15:43:38 +08:00
DXC
d51c6f147f 前端:所有 <el-dialog> 统一添加 :close-on-click-modal="false" 防误触关闭(保留 Esc 关闭) 2026-06-04 15:16:16 +08:00
DXC
2977acbae7 BOM 配方管理:禁止编辑原数据,引入另存为(深拷贝+清 ID)+ 只读查看模式(点击编号进只读弹窗) 2026-06-04 14:44:29 +08:00
DXC
90eed24441 基础信息页:编辑弹窗新增另存为新项功能(清主键+切标题+清脏检查基准,复用 addMaterialBase 接口) 2026-06-04 14:07:34 +08:00
DXC
91444034e0 基础信息页:将出厂名称展示文案统一改为专业名称(5 处,变量名/接口字段保持不变) 2026-06-04 13:32:52 +08:00
DXC
8f901e3f08 基础信息页:类别→规格型号自动提取正则扩展为支持字母+数字(如 Opt9) 2026-06-04 13:27:00 +08:00
DXC
bac670ef7a 基础信息页:计量单位改 el-select(下拉历史+手动输入);表单排版重排为 4 行(类别占满行);类别末级英文后缀自动填规格型号 2026-06-04 13:22:51 +08:00
DXC
1c0c02fd36 基础信息页新增/编辑弹窗隐藏“可见等级”表单项(v-if=“false”,代码保留可恢复) 2026-06-04 11:40:34 +08:00
DXC
fffee9d964 入库管理三页面类别搜索中间节点支持子级匹配(buy/semi/product 类别过滤改为 ilike 前缀,与基础信息页一致) 2026-06-04 11:31:44 +08:00
DXC
a3d47f6328 入库管理三页面类别搜索统一为级联选择器;基础信息“俗名”改名为“出厂名称” 2026-06-04 11:05:58 +08:00
dxc
6149662fd8 V3.41修改AI接口 2026-06-01 11:07:24 +08:00
DXC
f18dfd9819 新增 /cascade-inventory 级联库存缺口查询接口,供 AI 调用 BOM 出库缺口分析 2026-06-01 09:59:48 +08:00
dxc
992e08aee9 V3.40 2026-06-01 09:29:03 +08:00
dxc
f27488e693 V3.39版本推送,出库选单依据BOM子件0的逻辑修改 2026-06-01 09:28:22 +08:00
dxc
034418df8a V3.38版本修改,三种入库按照基础信息内容进行修改 2026-05-29 14:26:52 +08:00
dxc
cd54ca3fe2 V3.37版本修改,基础信息图片点击边缘空白处即可关闭 2026-05-29 11:33:54 +08:00
dxc
05aff2dd83 V3.36版本修改,基础信息列展示规则,分页数量修改,类别搜索修改 2026-05-29 11:23:05 +08:00
DXC
c1d364b786 基础信息页:新增列展示本地缓存 + 全选功能 2026-05-29 10:51:52 +08:00
dxc
6e50762da6 服务器数据库端口暴露宿主机修改 2026-05-28 11:49:06 +08:00
dxc
b4945cbba4 修复服务器端打印字体丢失问题,修复上传脚本打包过大问题 2026-05-27 09:57:01 +08:00
dxc
7d828d3ebf 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 12:01:58 +08:00
dxc
7e09e9de31 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 11:28:42 +08:00
dxc
fb5b8d873b 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 11:28:26 +08:00
dxc
682139bab8 版本变更V3.34将图像的处理统一更换到新表当中 2026-05-26 08:57:41 +08:00
DXC
e564c5a5d2 fix: 以图搜图跳转物料页面用 watch 接管查询,防止 URL 参数残留 2026-05-26 08:50:53 +08:00
DXC
9406669f1c fix: 以图搜图查看详情优先用 spec_model 跳转物料页面自动搜索 2026-05-26 08:34:03 +08:00
DXC
92e1f7275e feat: 以图搜图返回 business_data 包含 name/spec_model/url,支持详情页跳转 2026-05-25 17:52:03 +08:00
dxc
895d78a5e7 版本变更V3.33添加支持更新后识图功能 2026-05-25 11:20:45 +08:00
DXC
567c3175f6 fix: 审计日志跳过向量字段,修复 numpy 数组比较异常;补全三大入库单更新向量提取,统一删除确认弹窗 2026-05-25 11:11:10 +08:00
dxc
81ea4a0ab3 版本变更V3.32添加支持更新后识图功能 2026-05-25 10:10:14 +08:00
DXC
1da4b454cd feat: 新增物料/入库单实时 CLIP 向量提取(新建+更新),修复 I/O 延迟和路径解析静默失败 2026-05-25 10:04:32 +08:00
dxc
ee9b19e72a 版本变更V3.31添加识图功能 2026-05-22 13:12:28 +08:00
dxc
3ffcd35093 版本变更V3.31添加识图功能 2026-05-22 11:40:35 +08:00
dxc
8c635d6afe 版本变更V3.31添加识图功能 2026-05-22 10:59:39 +08:00
dxc
465452ef46 Merge remote-tracking branch 'origin/3.0AI添加' into 3.0AI添加 2026-05-21 18:29:48 +08:00
DXC
d119bebe94 fix: BOM搜索子件名称+自动搜索防抖 2026-05-21 17:41:14 +08:00
DXC
baaaf7799a fix: BOM子件下拉修复回显丢失和索引错位问题 2026-05-21 17:14:36 +08:00
DXC
c273f5a9d9 feat: 以图搜图功能升级(跨表UNION检索 + 拍照识图入口 + 批量向量初始化脚本) 2026-05-21 15:43:45 +08:00
DXC
1a7c06f197 feat: 添加以图搜图功能(CLIP ONNX + pgvector)+ Dify会话修复 + 版本升至V3.30 2026-05-21 14:09:57 +08:00
dxc
621431dcb9 版本变更V3.29体验优化 2026-05-20 09:09:33 +08:00
dxc
6d044b234c 版本变更V3.27体验优化 2026-05-19 18:33:19 +08:00
47 changed files with 3042 additions and 486 deletions

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
"""
以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索
数据源image_embeddings 表(统一向量存储)
"""
import os
import uuid
import json
from flask import Blueprint, request, jsonify
from sqlalchemy import text
from app.extensions import db
from app.utils.ai_vision import load_clip_model, get_image_embedding
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
# 注册蓝图
image_search_bp = Blueprint('image_search', __name__)
# ============================================================================
# 可配置参数
# ============================================================================
# 以图搜图相似度阈值:余弦距离必须小于此值(距离越小越相似)
# 即余弦相似度 = 1 - 距离,必须 > (1 - SIMILARITY_THRESHOLD)
# 默认 0.25 对应余弦相似度 > 0.75
SIMILARITY_DISTANCE_THRESHOLD = 0.40
# ============================================================================
# POST /api/v1/common/image-search
# 以图搜图:上传图片 → CLIP embedding → pgvector 余弦相似度检索
# ============================================================================
@image_search_bp.route('/image-search', methods=['POST'])
def image_search():
# ---------------------------------------------------------
# 1. 检查文件
# ---------------------------------------------------------
if 'file' not in request.files:
return jsonify({"code": 400, "msg": "未找到图片文件"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"code": 400, "msg": "未选择文件"}), 400
# ---------------------------------------------------------
# 2. 安全保存临时文件
# ---------------------------------------------------------
ext = file.filename.rsplit('.', 1)[-1].lower()
if ext not in {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}:
return jsonify({"code": 400, "msg": "不支持的图片格式"}), 400
tmp_filename = f"{uuid.uuid4().hex}.{ext}"
tmp_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'uploads')
os.makedirs(tmp_dir, exist_ok=True)
tmp_path = os.path.join(tmp_dir, tmp_filename)
try:
file.save(tmp_path)
print(f"💾 [ImageSearch] 临时文件已保存: {tmp_path}")
# ---------------------------------------------------------
# 3. 提取 CLIP embedding
# ---------------------------------------------------------
load_clip_model()
embedding = get_image_embedding(tmp_path)
print(f"✅ [ImageSearch] Embedding 提取成功,维度: {len(embedding)}")
except Exception as e:
print(f"❌ [ImageSearch] 图像处理失败: {e}")
return jsonify({"code": 500, "msg": f"图像处理失败: {str(e)}"}), 500
finally:
# ---------------------------------------------------------
# 4. 无论成功与否,都删除临时文件
# ---------------------------------------------------------
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
print(f"🗑️ [ImageSearch] 临时文件已清理: {tmp_path}")
except Exception as e:
print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}")
# ---------------------------------------------------------
# 5. pgvector 余弦相似度检索(统一查 image_embeddings 表)
# ---------------------------------------------------------
try:
query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']'
sql = text("""
SELECT
ie.id AS embedding_id,
ie.module_name,
ie.target_id,
ie.image_url,
(1 - (ie.embedding <=> :query_vector)) AS similarity,
(ie.embedding <=> :query_vector) AS distance
FROM image_embeddings ie
WHERE ie.embedding IS NOT NULL
AND (ie.embedding <=> :query_vector) < :distance_threshold
ORDER BY ie.embedding <=> :query_vector
LIMIT 200
""")
raw_records = db.session.execute(sql, {
"query_vector": query_vector_str,
"distance_threshold": SIMILARITY_DISTANCE_THRESHOLD
}).fetchall()
if not raw_records:
return jsonify({"code": 200, "data": [], "msg": "未找到相似图片(阈值过滤后)"})
# ---------------------------------------------------------
# Step 1: 初步去重(同入库单只保留最相似的图片)
# ---------------------------------------------------------
first_img_seen = {}
unique_records = []
for row in raw_records:
key = (row.module_name, row.target_id)
if key not in first_img_seen:
first_img_seen[key] = True
unique_records.append(row)
# ---------------------------------------------------------
# Step 2: 按物料维度去重(相同物料只保留第一条 = 相似度最高的那条)
# ---------------------------------------------------------
target_ids_by_module = {}
for row in unique_records:
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
# 查询每条记录的 base_id跨 stock_buy/semi/product/material_base
base_id_map = {}
for module in ('stock_buy', 'stock_semi', 'stock_product'):
if module not in target_ids_by_module:
continue
ids = target_ids_by_module[module]
ModelCls = StockBuy if module == 'stock_buy' else (StockSemi if module == 'stock_semi' else StockProduct)
id_col = getattr(ModelCls, 'id')
base_col = getattr(ModelCls, 'base_id')
rows = (
db.session.query(id_col, base_col)
.outerjoin(MaterialBase, base_col == MaterialBase.id)
.filter(id_col.in_(ids))
.all()
)
for rec_id, base_id in rows:
base_id_map[(module, rec_id)] = base_id
if 'material_base' in target_ids_by_module:
for rec_id in target_ids_by_module['material_base']:
base_id_map[('material_base', rec_id)] = rec_id
# 按 base_id 去重:相同物料只保留第一张图
material_seen = {}
final_records = []
for row in unique_records:
base_id = base_id_map.get((row.module_name, row.target_id))
if base_id is not None and base_id in material_seen:
continue
if base_id is not None:
material_seen[base_id] = True
final_records.append(row)
# ---------------------------------------------------------
# Step 3: 批量回填业务数据(基于去重后的 final_records
# ---------------------------------------------------------
target_ids_by_module = {}
for row in final_records:
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
business_map = {}
# 回填 StockBuy
if 'stock_buy' in target_ids_by_module:
ids = target_ids_by_module['stock_buy']
records = (
db.session.query(StockBuy)
.filter(StockBuy.id.in_(ids))
.outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_buy', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'module_name': 'stock_buy',
'url': '/inventory/buy',
}
# 回填 StockSemi
if 'stock_semi' in target_ids_by_module:
ids = target_ids_by_module['stock_semi']
records = (
db.session.query(StockSemi)
.filter(StockSemi.id.in_(ids))
.outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_semi', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'module_name': 'stock_semi',
'url': '/inventory/semi',
}
# 回填 StockProduct
if 'stock_product' in target_ids_by_module:
ids = target_ids_by_module['stock_product']
records = (
db.session.query(StockProduct)
.filter(StockProduct.id.in_(ids))
.outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_product', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'sale_price': r.sale_price,
'module_name': 'stock_product',
'url': '/inventory/product',
}
# 回填 MaterialBase
if 'material_base' in target_ids_by_module:
ids = target_ids_by_module['material_base']
records = MaterialBase.query.filter(MaterialBase.id.in_(ids)).all()
for r in records:
business_map[('material_base', r.id)] = {
'record_id': r.id,
'name': r.name,
'spec_model': r.spec_model,
'common_name': r.common_name,
'category': r.category,
'material_type': r.material_type,
'unit': r.unit,
'module_name': 'material_base',
'url': '/material/index',
}
# 组装最终返回(基于 final_records按相似度从高到低
results = []
for row in final_records:
key = (row.module_name, row.target_id)
biz = business_map.get(key, {})
raw_url = row.image_url or ''
clean_url = raw_url
if raw_url.startswith('['):
try:
url_list = json.loads(raw_url)
clean_url = url_list[0] if url_list else ''
except:
pass
results.append({
"module_name": row.module_name,
"target_id": row.target_id,
"image_url": clean_url,
"similarity": round(float(row.similarity), 4),
"product_name": biz.get('name') or biz.get('material_name') or '未命名物料',
"product_id": row.target_id,
"spec_model": biz.get('spec_model') or '',
"business_data": biz,
})
if len(results) >= 10:
break
return jsonify({"code": 200, "data": results})
except Exception as e:
print(f"❌ [ImageSearch] 数据库检索失败: {e}")
return jsonify({"code": 500, "msg": f"检索失败: {str(e)}"}), 500

View File

@ -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)
# ============================================================================== # ==============================================================================

View File

@ -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()

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -12,6 +12,9 @@ import traceback
import json import json
import io import io
import datetime import datetime
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
# 需要 pip install openpyxl # 需要 pip install openpyxl
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
@ -249,7 +252,8 @@ class MaterialBaseService:
category = filters.get('category') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
query = query.filter(MaterialBase.category.ilike(category.strip())) # 在末尾拼接 '%' 实现前缀模糊匹配
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
type_val = filters.get('type') type_val = filters.get('type')
if type_val is not None and type_val != '': if type_val is not None and type_val != '':
@ -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

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class BuyInboundService: class BuyInboundService:
@ -178,7 +181,22 @@ class BuyInboundService:
inspection_report=json.dumps(data.get('inspection_report', [])) inspection_report=json.dumps(data.get('inspection_report', []))
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务(入库单必须落盘),图片向量异步后台提取
db.session.commit() db.session.commit()
photo_list = data.get('arrival_photo', [])
if isinstance(photo_list, list) and photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_BUY,
new_stock.id,
photo_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -240,7 +258,26 @@ class BuyInboundService:
for k, v in field_mapping.items(): for k, v in field_mapping.items():
if k in data: setattr(stock, v, data[k]) if k in data: setattr(stock, v, data[k])
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo']) if 'arrival_photo' in data:
new_photo_list = data['arrival_photo']
stock.arrival_photo = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_BUY,
stock.id,
new_photo_list
)
else:
stock.arrival_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report']) if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
# 更新税率 # 更新税率
@ -283,8 +320,11 @@ class BuyInboundService:
try: try:
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name
@ -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():

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class ProductInboundService: class ProductInboundService:
@ -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())

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class SemiInboundService: class SemiInboundService:
@ -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())

Binary file not shown.

View File

@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""
AI Vision 模块 - CLIP Vision Encoder ONNX 推理
"""
import os
import json
import time
import numpy as np
from PIL import Image
import onnxruntime as ort
import cv2
# ============================================================================
# 全局模型单例(项目启动时加载一次)
# ============================================================================
MODEL_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'models', 'clip_vision.onnx')
# 加载选项CPU 推理,禁用依赖库的启动开销
_session_options = ort.SessionOptions()
_session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
ort_session: ort.InferenceSession = None
def load_clip_model():
"""启动时调用:全局加载 CLIP Vision 模型"""
global ort_session
if ort_session is not None:
return ort_session
if not os.path.exists(MODEL_PATH):
raise FileNotFoundError(f"CLIP Vision 模型未找到: {MODEL_PATH}")
ort_session = ort.InferenceSession(MODEL_PATH, sess_options=_session_options, providers=['CPUExecutionProvider'])
print(f"✅ [AI Vision] CLIP 模型加载成功: {MODEL_PATH}")
return ort_session
# ============================================================================
# CLIP 预处理常量
# ============================================================================
# ImageNet 标准归一化CLIP 官方)
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
# 模型输入尺寸
INPUT_SIZE = 224
# ============================================================================
# 背景去除配置HSV 色彩空间阈值
# ============================================================================
# OpenCV HSV: H∈[0,180], S∈[0,255], V∈[0,255]
# 注意OpenCV 中 H 通道范围是 0-180是 OpenCV 自己的标准,和美术的 0-360 对应)
# 绿色背景阈值(工业绿幕常用色)
# H: 35~85 对应绿色谱(浅绿到深绿)
# S: 低饱和度35到高饱和度255
# V: 明暗均可30~255
BG_GREEN_LOWER = np.array([35, 35, 30])
BG_GREEN_UPPER = np.array([90, 255, 255])
# 白色/浅色背景阈值(高明度、低饱和度区域)
# H: 不限制0~180只看 S 和 V
# S: 很低的饱和度0~35→ 接近纯灰/白色
# V: 高明度180~255
BG_WHITE_LOWER = np.array([0, 0, 180])
BG_WHITE_UPPER = np.array([180, 40, 255])
# 中性灰填充色BGR → 转换后 RGB 也是 128,128,128
NEUTRAL_GRAY_BGR = (128, 128, 128)
def _remove_background(image: Image.Image) -> Image.Image:
"""
利用 OpenCV HSV 色彩空间识别并替换背景为中性灰
支持两种背景类型:
1. 工业绿幕/绿色背景H: 35~90
2. 白色/浅色背景(高亮度、低饱和度)
逻辑:
- 将 PIL Image 转为 OpenCV 格式 (RGB → BGR)
- 转 HSV分别生成绿色掩码和白色掩码
- 合并掩码后,按掩码将背景区域替换为中性灰
- 还原为 PIL Image (BGR → RGB) 返回
参数:
image: PIL Image (RGB, uint8)
返回:
处理后的 PIL Image (RGB, uint8)
"""
# PIL (RGB) → OpenCV (BGR)
img_bgr = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
# 转入 HSV 色彩空间
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
# 生成掩码 1绿色背景
mask_green = cv2.inRange(hsv, BG_GREEN_LOWER, BG_GREEN_UPPER)
# 生成掩码 2白色/浅色背景
mask_white = cv2.inRange(hsv, BG_WHITE_LOWER, BG_WHITE_UPPER)
# 合并掩码(任意一种背景都替换)
mask_combined = cv2.bitwise_or(mask_green, mask_white)
# 形态学处理:消除噪点(小面积背景噪点填平)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_combined = cv2.morphologyEx(mask_combined, cv2.MORPH_CLOSE, kernel)
mask_combined = cv2.morphologyEx(mask_combined, cv2.MORPH_OPEN, kernel)
# 背景替换:将掩码区域填充为中性灰
# 其中 mask_combined=255 的区域为背景,替换为 NEUTRAL_GRAY_BGR
img_bgr_no_bg = img_bgr.copy()
img_bgr_no_bg[mask_combined > 0] = NEUTRAL_GRAY_BGR
# OpenCV (BGR) → PIL (RGB)
result = Image.fromarray(cv2.cvtColor(img_bgr_no_bg, cv2.COLOR_BGR2RGB))
return result
def _letterbox_image(image: Image.Image, size: int = 224) -> Image.Image:
"""
Letterbox 预处理:等比例缩放 + 灰色填充,保持内容不变形
- 将原图最长边缩放到 224
- 短边按相同比例缩放
- 不足部分用 RGB(128,128,128) 灰色填充至 224x224
参数:
image: PIL Image 对象
size: 目标尺寸,默认 224
返回:
224x224 PIL Image
"""
w, h = image.size
# 计算缩放比例,使最长边等于 size
scale = size / max(w, h)
new_w = int(w * scale)
new_h = int(h * scale)
# 等比例缩放
resized = image.resize((new_w, new_h), Image.LANCZOS)
# 创建灰色画布
canvas = Image.new('RGB', (size, size), (128, 128, 128))
# 将缩放后的图片粘贴到画布正中央
paste_x = (size - new_w) // 2
paste_y = (size - new_h) // 2
canvas.paste(resized, (paste_x, paste_y))
return canvas
def _normalize(image_np: np.ndarray) -> np.ndarray:
"""
对 224x224x3 图像进行 CLIP 标准归一化
image_np: shape (H, W, C), dtype uint8, 值域 [0, 255]
返回: shape (C, H, W), dtype float32, 值域 [0, 1]
"""
# HWC -> CHW
image_np = image_np.transpose(2, 0, 1).astype(np.float32) / 255.0
# 归一化
for i, (mean, std) in enumerate(zip(IMAGENET_MEAN, IMAGENET_STD)):
image_np[i] = (image_np[i] - mean) / std
return image_np
# ============================================================================
# 主函数:提取图像 embedding
# ============================================================================
def get_image_embedding(image_path: str) -> list:
"""
提取图像的 512 维 CLIP embedding 向量
参数:
image_path: 图像文件路径
返回:
list: 512 维浮点向量
"""
if ort_session is None:
load_clip_model()
# 1. 图片预处理
# Step 1: 背景去除HSV 色彩空间,绿色/白色背景 → 中性灰替换)
image = Image.open(image_path).convert('RGB')
image = _remove_background(image)
# Step 2: Letterbox 等比例缩放(保持内容不变形)
image = _letterbox_image(image, INPUT_SIZE)
input_data = _normalize(np.array(image))
input_data = np.expand_dims(input_data, axis=0) # [1, 3, 224, 224]
# 2. 构造占位符输入 (关键修复)
dummy_ids = np.zeros((1, 77), dtype=np.int64)
dummy_mask = np.zeros((1, 77), dtype=np.int64)
# 3. 传入模型进行推理
# 注意: 模型输入名在你的模型里必须叫 'pixel_values', 'input_ids', 'attention_mask'
# 如果报错找不到输入名,请打印 ort_session.get_inputs()[0].name 确认
outputs = ort_session.run(
['image_embeds'],
{
'input_ids': dummy_ids,
'pixel_values': input_data.astype(np.float32),
'attention_mask': dummy_mask
}
)
return outputs[0][0].tolist()
# ============================================================================
# 通用向量提取工具:防呆、防错
# ============================================================================
def extract_and_embed(photo_source):
if not photo_source:
return None
try:
# 1. 提取基础字符串
photo_source_str = str(photo_source).strip()
raw_path = ""
# 尝试剥掉 JSON 外壳
try:
parsed = json.loads(photo_source_str)
if isinstance(parsed, list):
raw_path = parsed[0] if parsed else ""
elif isinstance(parsed, str):
raw_path = parsed
else:
raw_path = str(parsed)
except:
raw_path = photo_source_str
if not raw_path:
return None
# 2. 剥离出最纯净的文件名 (只取最后一段)
pure_filename = raw_path.split('/')[-1]
# 3. 【终极物理净化】强行抠掉所有多余的标点符号!
# 哪怕传进来的是 123.jpg"] 或者是 "123.jpg",全部洗干净
pure_filename = pure_filename.replace('"', '').replace("'", "").replace('[', '').replace(']', '')
# 4. 拼接真实的 Docker 物理路径
file_path = os.path.join('/app/uploads', pure_filename)
# 5. 加入重试机制 (最多等 3 秒)
max_retries = 6
for i in range(max_retries):
if os.path.exists(file_path):
# 文件找到了,开始提取向量
vec = get_image_embedding(file_path)
if isinstance(vec, np.ndarray):
return vec.tolist()
return vec
else:
print(f"[AI 识图等待] 第 {i+1} 次尝试,未找到文件 {file_path},等待 0.5s...")
time.sleep(0.5)
print(f"[AI 识图警告] 彻底失败!经过等待依然未找到图片: {file_path}")
except Exception as e:
print(f"[AI 识图错误] 实时提取向量失败: {str(e)}")
return None

View File

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

View File

@ -10,6 +10,12 @@ flask-cors==4.0.0
redis==5.0.1 redis==5.0.1
# 图片处理核心库 # 图片处理核心库
Pillow>=10.0.0 Pillow>=10.0.0
# OpenCV背景去除、HSV色彩空间抠图
opencv-python-headless>=4.8.0
# ONNX 模型本地 CPU 推理
onnxruntime>=1.16.0
# 数值计算ONNX 推理依赖)
numpy>=1.24.0
# [旧] 条形码生成库 (建议保留,防止旧代码报错) # [旧] 条形码生成库 (建议保留,防止旧代码报错)
python-barcode>=0.14.0 python-barcode>=0.14.0
# [新增] 二维码生成库 (标签打印必需包含PIL支持) # [新增] 二维码生成库 (标签打印必需包含PIL支持)
@ -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

View File

@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
"""
全量历史图片向量初始化脚本
功能:遍历配置表中所有历史图片字段,批量提取 CLIP 512 维向量并存回数据库。
用法python scripts/init_all_vectors.py
"""
import os
import json
import sys
from datetime import datetime
from typing import List, Optional
# 将项目根目录加入 Python 路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from tqdm import tqdm
from sqlalchemy import text
# Flask 应用环境
from app import create_app
from app.extensions import db
from app.utils.ai_vision import get_image_embedding, load_clip_model
# ============================================================================
# 业务配置:表 → 图片字段 → 向量字段 映射 (已全面修复)
# ============================================================================
TARGET_TABLES = [
# 1. 基础物料
{"table": "material_base", "img_col": "product_image", "vec_col": "img_embedding"},
# 2. 采购入库
{"table": "stock_buy", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
{"table": "stock_buy", "img_col": "inspection_report", "vec_col": "qc_report_image_embedding"}, # 已修复: qc_report -> inspection_report
# 3. 半成品入库 (新增)
{"table": "stock_semi", "img_col": "arrival_photo", "vec_col": "arrival_image_embedding"},
{"table": "stock_semi", "img_col": "quality_report_link", "vec_col": "qc_report_image_embedding"},
# 4. 成品入库 (新增)
{"table": "stock_product", "img_col": "product_photo", "vec_col": "arrival_image_embedding"},
{"table": "stock_product", "img_col": "quality_report_link", "vec_col": "qc_report_image_embedding"}
# 注意:成品入库表还有一个 inspection_report_link但由于数据库中成品表目前只加了两个向量字段
# 暂不将该字段加入遍历,以免覆盖 quality_report_link 的特征。
]
# 物理图片根目录(相对于 app 目录的相对路径 ../uploads/
APP_DIR = os.path.join(os.path.dirname(__file__), '..', 'app')
UPLOADS_ROOT = os.path.abspath(os.path.join(APP_DIR, '..', 'uploads'))
# ============================================================================
# 核心工具函数
# ============================================================================
def parse_img_field(raw_value: str) -> List[str]:
"""
健壮解析图片字段,支持以下格式:
- JSON 数组字符串: ["a.jpg", "b.jpg"]
- 纯字符串单图片: "a.jpg"
- 带 /api/v1/files/ 前缀: ["/api/v1/files/a.jpg"]
返回: 提取出的文件名列表
"""
if not raw_value or (isinstance(raw_value, str) and not raw_value.strip()):
return []
try:
# 先尝试按 JSON 解析(处理 JSON 数组字符串)
parsed = json.loads(raw_value)
if isinstance(parsed, list):
items = parsed
else:
items = [parsed]
except (json.JSONDecodeError, TypeError):
# JSON 解析失败,说明是纯字符串,直接按单图片处理
items = [raw_value.strip()]
filenames = []
for item in items:
if not item or not isinstance(item, str):
continue
item = item.strip()
if not item:
continue
# 去掉可能的 /api/v1/files/ 前缀
filename = os.path.basename(item)
filenames.append(filename)
return filenames
def build_local_path(filename: str) -> str:
"""
将文件名拼装成本地绝对路径
"""
return os.path.join(UPLOADS_ROOT, filename)
def extract_first_valid_vector(raw_img_field: str, table_name: str, img_col: str) -> Optional[str]:
"""
读取图片字段,从第一条有效图片提取向量,返回写入 DB 的 JSON 字符串。
如果所有图片均失败,返回 None。
"""
filenames = parse_img_field(raw_img_field)
if not filenames:
return None
for filename in filenames:
local_path = build_local_path(filename)
if not os.path.exists(local_path):
print(f"\033[91m[WARN] {table_name}.{img_col} | 文件不存在: {local_path}\033[0m")
continue
try:
vec = get_image_embedding(local_path)
if vec is not None:
return json.dumps(vec)
except Exception as e:
print(f"\033[91m[WARN] {table_name}.{img_col} | 推理异常 [{filename}]: {type(e).__name__}: {e}\033[0m")
continue
return None
# ============================================================================
# 主入口
# ============================================================================
def main():
start = datetime.now()
total_success = 0
total_skip = 0
print("=" * 60)
print("📦 全量历史图片向量初始化")
print("=" * 60)
print(f"图片目录: {UPLOADS_ROOT}")
print(f"待处理表数: {len(TARGET_TABLES)}")
print()
# 1. 初始化 Flask 应用上下文(加载 CLIP 模型)
app = create_app()
with app.app_context():
load_clip_model()
print("✅ CLIP 模型加载完成")
print()
# 2. 遍历目标表
for config in TARGET_TABLES:
table_name = config["table"]
img_col = config["img_col"]
vec_col = config["vec_col"]
print(f"正在处理表: {table_name}, 字段: {img_col}")
# 3. 查询待清洗记录(只选未处理过的)
sql = text(f"""
SELECT id, {img_col}
FROM {table_name}
WHERE {img_col} IS NOT NULL
AND {img_col} != '[]'
AND ({vec_col} IS NULL)
""")
rows = db.session.execute(sql).fetchall()
if not rows:
print(f"[{table_name}/{img_col}] ⏭ 无待处理记录")
continue
print(f"\n[{table_name}/{img_col}] 📋 待处理: {len(rows)}")
# 4. 逐条处理
processed = 0
success_count = 0
for row in tqdm(rows, desc=f"{table_name}/{img_col}", unit=""):
record_id = row[0]
raw_img = row[1]
try:
vec_json = extract_first_valid_vector(raw_img, table_name, img_col)
if vec_json is None:
total_skip += 1
continue
# 更新向量字段
update_sql = text(f"""
UPDATE {table_name} SET {vec_col} = :vec_str WHERE id = :id
""")
db.session.execute(update_sql, {"vec_str": vec_json, "id": record_id})
success_count += 1
# 每 50 条提交一次
if processed > 0 and processed % 50 == 0:
db.session.commit()
print(f"\n ✅ 已提交 {processed}")
except Exception as e:
print(f"\n\033[91m[WARN] {table_name}/{img_col} | ID={record_id} 处理异常: {type(e).__name__}: {e}\033[0m")
# 关键:任何异常都不中断,只 continue 下一条
db.session.rollback()
continue
finally:
processed += 1
# 循环结束后补一次 commit处理未凑满50条的剩余数据
try:
db.session.commit()
except Exception:
db.session.rollback()
total_success += success_count
print(f"[{table_name}/{img_col}] ✅ 完成,成功 {success_count} 条 / 跳过 {len(rows) - success_count}")
# 5. 汇总报告
elapsed = (datetime.now() - start).total_seconds()
print()
print("=" * 60)
print(f"🏁 全部完成!总计耗时 {elapsed:.1f}")
print(f" ✅ 成功写入向量: {total_success}")
print(f" ⏭ 无有效图片(跳过): {total_skip}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

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

View File

@ -10,8 +10,35 @@
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script> <script>
// 获取当前用户的登录凭证 (Token) window.initDifyChatbot = function() {
// 【关键】增加保护检查:确保 DOM 已经就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', performInit);
} else {
performInit();
}
};
function performInit() {
var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || ''; var currentToken = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
var username = localStorage.getItem("username") || '';
if (!currentToken) {
console.log('未检测到 Token暂不加载 Dify');
return;
}
// 彻底清理浏览器内存中残留的 Dify 全局对象
window.difyChatbot = undefined;
delete window.difyChatbot;
// 清理旧的 DOM 节点
var oldScript = document.getElementById('6T0eTgukUEqzK0iW');
if (oldScript) oldScript.remove();
document.querySelectorAll('[id^="dify-chatbot-"]').forEach(function(el) { el.remove(); });
// 动态化 user_id打破 Dify 会话锁定机制
var dynamicUserId = username + '_' + currentToken.slice(-8);
window.difyChatbotConfig = { window.difyChatbotConfig = {
token: '6T0eTgukUEqzK0iW', token: '6T0eTgukUEqzK0iW',
@ -19,16 +46,28 @@
inputs: { inputs: {
"user_token": currentToken "user_token": currentToken
}, },
systemVariables: {}, systemVariables: {
"user_id": dynamicUserId
},
userVariables: {}, userVariables: {},
};
// 重新挂载
var script = document.createElement('script');
script.src = 'http://172.16.0.198:8080/embed.min.js?t=' + new Date().getTime();
script.id = '6T0eTgukUEqzK0iW';
script.defer = true;
document.head.appendChild(script);
console.log('✅ Dify chatbot 已挂载新会话,当前绑定 ID:', dynamicUserId);
} }
</script> </script>
<script <!--<script-->
src="http://172.16.0.198:8080/embed.min.js" <!-- src="http://172.16.0.198:8080/embed.min.js"-->
id="6T0eTgukUEqzK0iW" <!-- id="6T0eTgukUEqzK0iW"-->
defer> <!-- defer>-->
</script> <!--</script>-->
<style> <style>
#dify-chatbot-bubble-button { #dify-chatbot-bubble-button {
@ -38,7 +77,7 @@
/* 变成"独立悬浮窗口" */ /* 变成"独立悬浮窗口" */
#dify-chatbot-bubble-window { #dify-chatbot-bubble-window {
/* 👇 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */ /* 解除原本锁定在右下角的限制,将其定位在屏幕中间偏左上 */
top: 15vh !important; top: 15vh !important;
left: 20vw !important; left: 20vw !important;
bottom: auto !important; bottom: auto !important;
@ -49,9 +88,9 @@
height: 70vh !important; height: 70vh !important;
border-radius: 12px !important; border-radius: 12px !important;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important; /* 增加超大弥散阴影,浮现感更强 */ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2) !important;
/* 👇 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */ /* 开启右下角拖拽,并强制留出 16px 的白边给拖拽手柄 */
resize: both !important; resize: both !important;
overflow: hidden !important; overflow: hidden !important;
padding-bottom: 16px !important; padding-bottom: 16px !important;

View File

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

View File

@ -3,7 +3,6 @@ import request from '@/utils/request'
/** /**
* 上传文件通用接口 * 上传文件通用接口
* @param data File 对象 或 FormData 对象 * @param data File 对象 或 FormData 对象
* 适配说明list.vue 中 customUpload 已经封装了 FormData所以这里支持直接传 FormData
*/ */
export function uploadFile(data: File | FormData) { export function uploadFile(data: File | FormData) {
let formData: FormData let formData: FormData
@ -11,14 +10,12 @@ export function uploadFile(data: File | FormData) {
if (data instanceof FormData) { if (data instanceof FormData) {
formData = data formData = data
} else { } else {
// 如果传入的是原始 File 对象,则手动封装
formData = new FormData() formData = new FormData()
// @ts-ignore // @ts-ignore
formData.append('file', data) formData.append('file', data)
} }
return request({ return request({
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
url: '/v1/common/upload', url: '/v1/common/upload',
method: 'post', method: 'post',
data: formData, data: formData,
@ -29,13 +26,71 @@ export function uploadFile(data: File | FormData) {
} }
/** /**
* 删除文件通用接口 (新增) * 删除文件通用接口
* @param filename 文件名 (例如: a1b2c3d4.jpg) * @param filename 文件名 (例如: a1b2c3d4.jpg)
*/ */
export function deleteFile(filename: string) { export function deleteFile(filename: string) {
return request({ return request({
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
url: `/v1/common/files/${filename}`, url: `/v1/common/files/${filename}`,
method: 'delete' method: 'delete'
}) })
} }
// ============================================================================
// 以图搜图 API
// ============================================================================
/** 以图搜图返回的物料项 */
export interface ImageSearchItem {
product_id: number
product_name: string
spec_model: string
image_url: string
similarity: number
module_name: string
target_id: number
business_data: {
record_id: number
name?: string
spec_model?: string
sku?: string
barcode?: string
serial_number?: string
batch_number?: string
status?: string
warehouse_location?: string
stock_quantity?: number
sale_price?: number
common_name?: string
category?: string
material_type?: string
unit?: string
module_name: string
url: string
}
}
/** 以图搜图响应结构 */
export interface ImageSearchResponse {
code: number
msg: string
data: ImageSearchItem[]
}
/**
* 以图搜图
* @param file 图片文件 (File 对象或 Blob)
*/
export function imageSearch(file: File | Blob) {
const formData = new FormData()
formData.append('file', file)
return request<ImageSearchResponse>({
url: '/v1/common/image-search',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

View File

@ -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 }
}) })
} }

View File

@ -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 }
}) })
} }

View File

@ -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'
})
}

View File

@ -0,0 +1,491 @@
<template>
<el-dialog
v-model="visible"
title="以图搜图"
width="95%"
style="max-width: 680px;"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<div class="image-search-body">
<!-- 左侧图片上传 -->
<div class="upload-section">
<el-upload
ref="uploadRef"
class="image-uploader"
:auto-upload="false"
:show-file-list="false"
accept="image/*"
capture="environment"
:on-change="handleFileChange"
>
<div v-if="!previewUrl" class="upload-placeholder">
<el-icon class="upload-icon" :size="48"><Camera /></el-icon>
<div class="upload-text">点击拍照或选择图片</div>
<div class="upload-hint">支持 jpg/png 格式</div>
</div>
<div v-else class="preview-wrapper">
<img :src="previewUrl" class="preview-image" />
<div class="preview-overlay">
<el-button size="small" @click.stop="clearImage">重新拍照</el-button>
</div>
</div>
</el-upload>
<div v-if="searching" class="loading-tip">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在识别图片并检索...</span>
</div>
</div>
<!-- 右侧搜索结果 -->
<div class="result-section">
<div v-if="!searched && !searching" class="result-empty">
<el-icon :size="40" color="#c0c4cc"><Picture /></el-icon>
<p>上传图片后自动检索</p>
</div>
<div v-else-if="searched && results.length === 0" class="result-empty">
<el-icon :size="40" color="#c0c4cc"><WarningFilled /></el-icon>
<p>未找到相似物料</p>
<p class="result-hint">请尝试更换图片</p>
</div>
<div v-else class="result-list">
<div
v-for="(item, index) in results"
:key="item.product_id"
class="result-item"
>
<div class="item-rank">{{ index + 1 }}</div>
<div class="item-image">
<img
v-if="item.image_url"
:src="fullImageUrl(item.image_url)"
@error="handleImgError($event)"
/>
<div v-else class="image-placeholder">
<el-icon :size="24" color="#c0c4cc"><Picture /></el-icon>
</div>
</div>
<div class="item-info">
<div class="item-name">{{ item.product_name || '未命名物料' }}</div>
<div class="item-spec">{{ item.spec_model || '无规格' }}</div>
<div class="item-similarity">
<span class="similarity-label">相似度</span>
<span class="similarity-value">{{ (item.similarity * 100).toFixed(2) }}%</span>
</div>
</div>
<div class="item-actions">
<el-button
type="primary"
size="small"
@click="handleView(item)"
>
查看详情
</el-button>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Camera, Loading, Picture, WarningFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
const router = useRouter()
interface Props {
modelValue: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'use', item: ImageSearchItem): void
(e: 'view', item: ImageSearchItem): void
}>()
const visible = ref(props.modelValue)
const uploadRef = ref()
const previewUrl = ref('')
const currentFile = ref<File | null>(null)
const searching = ref(false)
const searched = ref(false)
const results = ref<ImageSearchItem[]>([])
watch(() => props.modelValue, (val) => {
visible.value = val
if (!val) {
resetState()
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleFileChange = (uploadFile: any) => {
const file = uploadFile.raw
if (!file) return
// 校验格式
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp']
if (!allowedTypes.includes(file.type)) {
ElMessage.warning('仅支持 jpg/png/gif/webp/bmp 格式')
return
}
currentFile.value = file
previewUrl.value = URL.createObjectURL(file)
// 自动触发搜索
doSearch(file)
}
const doSearch = async (file: File) => {
if (searching.value) return
searching.value = true
searched.value = false
results.value = []
try {
const res = await imageSearch(file)
if (res.code === 200) {
results.value = res.data || []
} else {
ElMessage.error(res.msg || '检索失败')
}
} catch (err: any) {
console.error('image search error:', err)
ElMessage.error(err.message || '网络错误,请重试')
} finally {
searching.value = false
searched.value = true
}
}
const clearImage = () => {
previewUrl.value = ''
currentFile.value = null
results.value = []
searched.value = false
uploadRef.value?.clearFiles()
}
const fullImageUrl = (path: string) => {
if (!path) return '';
// 直接原样返回,完全信任后端传过来的 image_url
return path.startsWith('http') ? path : path;
}
const handleImgError = (e: Event) => {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
const handleUse = (item: ImageSearchItem) => {
emit('use', item)
handleClose()
}
const handleView = (item: ImageSearchItem) => {
const biz = item.business_data
if (biz?.spec_model) {
router.push({ path: '/material/index', query: { keyword: biz.spec_model } })
} else if (biz?.url) {
router.push(biz.url)
} else {
ElMessage.warning('无法定位目标页面')
}
handleClose()
}
const handleClose = () => {
visible.value = false
}
const resetState = () => {
previewUrl.value = ''
currentFile.value = null
searching.value = false
searched.value = false
results.value = []
}
</script>
<style scoped>
.image-search-body {
display: flex;
gap: 24px;
min-height: 380px;
}
/* ── 左侧上传区 ── */
.upload-section {
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 12px;
}
.image-uploader {
width: 100%;
}
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 2px dashed #dcdfe6;
border-radius: 8px;
transition: border-color 0.2s;
}
:deep(.el-upload-dragger:hover) {
border-color: #409eff;
}
.upload-placeholder {
text-align: center;
color: #909399;
}
.upload-icon {
color: #c0c4cc;
margin-bottom: 12px;
}
.upload-text {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.upload-hint {
font-size: 12px;
color: #c0c4cc;
}
.preview-wrapper {
position: relative;
width: 100%;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
overflow: hidden;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
background: rgba(0, 0, 0, 0.5);
text-align: center;
}
.preview-overlay .el-button {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
}
.loading-tip {
display: flex;
align-items: center;
gap: 8px;
color: #409eff;
font-size: 13px;
}
/* ── 右侧结果区 ── */
.result-section {
flex: 1;
overflow-y: auto;
}
.result-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 280px;
color: #909399;
text-align: center;
}
.result-empty p {
margin: 8px 0 0;
font-size: 14px;
}
.result-hint {
font-size: 12px !important;
color: #c0c4cc;
}
.result-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #f5f7fa;
border-radius: 8px;
transition: background 0.2s;
}
.result-item:hover {
background: #ecf5ff;
}
.item-rank {
flex: 0 0 24px;
width: 24px;
height: 24px;
background: #409eff;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.item-image {
flex: 0 0 60px;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-name {
font-size: 14px;
font-weight: 600;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-spec {
font-size: 12px;
color: #909399;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-similarity {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.similarity-label {
font-size: 12px;
color: #909399;
}
.similarity-value {
font-size: 14px;
font-weight: 700;
color: #67c23a;
}
.item-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
}
/* ---- 新增:移动端适配样式 ---- */
@media screen and (max-width: 768px) {
.image-search-body {
flex-direction: column;
gap: 16px;
min-height: auto;
}
.upload-section {
flex: none;
width: 100%;
}
:deep(.el-upload), :deep(.el-upload-dragger) {
height: 160px;
}
.preview-image {
max-height: 140px;
width: auto;
object-fit: contain;
}
.result-section {
width: 100%;
max-height: 50vh;
}
}
</style>

View File

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

View File

@ -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();

View File

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

View File

@ -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 的条目并 pushpicture-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);

View File

@ -527,12 +527,14 @@ const totalExportCount = computed(() => {
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stockO(N),无嵌套循环)--- // --- BOM 齐套性分析计算属性(使用后端已计算的 current_stockO(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)
) )

View File

@ -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) => {

View File

@ -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);
});
});
};
// ------------------------------------ // ------------------------------------
// 打印逻辑 // 打印逻辑

View File

@ -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 }

View File

@ -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 }

View File

@ -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

View File

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