59 Commits

Author SHA1 Message Date
dxc
6cf5a25d77 V3.44 2026-06-10 12:16:00 +08:00
DXC
6686747e57 BomManage: saveDraftData 简化为全局唯一草稿静默覆盖模式 2026-06-10 12:11:53 +08:00
dxc
74bc751624 V3.43 2026-06-10 11:36:06 +08:00
DXC
c7b84ff3c6 fix: BOM草稿模块缺陷修复(事务回滚 + 外键约束 + 前端状态清理) 2026-06-10 11:30:07 +08:00
dxc
0e6d294052 V3.42 2026-06-05 16:28:43 +08:00
DXC
93b9846fc6 feat: 以图搜图集成拍照功能,支持直接调起摄像头搜图 2026-06-05 15:47:50 +08:00
DXC
1def8c7747 fix: 修复物料管理菜单空白,修正子菜单显示名称 2026-06-05 15:41:05 +08:00
DXC
907c083107 feat: 新增 Odoo 风格物料管理视图及相关路由,优化成品入库逻辑 2026-06-05 15:35:43 +08:00
DXC
afe0f25415 物料类别隔离校验:buy 改黑名单 + semi/product 改精确路径匹配,消除子串包含Bug 2026-06-05 13:01:39 +08:00
DXC
ffc482bd9e BomManage: 增删改刷新保留折叠状态,搜索时才重置 activeCategories 2026-06-05 11:31:37 +08:00
DXC
7087769a33 BomManage: 首屏懒渲染优化 — el-table 加 v-if 按需挂载,v-loading 替换为骨架屏 2026-06-05 11:24:40 +08:00
DXC
3d30cbc5c2 BomManage: autocomplete 添加 validate-event=false,rules 校验字段从 parent_id 改为 parentNameInput 2026-06-05 11:05:41 +08:00
DXC
355a21e94c 物料搜索:el-select 重构为 el-autocomplete Regression 修复(value-key 缺失 + parentNameInput 未声明 + onChildClear 不完整) 2026-06-05 11:02:35 +08:00
DXC
ff5418afa3 入库模块:物料搜索点击无感修复 + 类别校验白名单准入制
前端(buy/semi/product/service.vue,4 文件):

修复物料搜索"点击已聚焦 input 时内容被清空"交互 bug。

el-select 在 filterable+remote 模式下点击已聚焦的 input 时,el-select 内部

会 emit query='' 触发 remote-method,绕过 handleMaterialDropdownVisible

入口保护,直接清空 searchKeyword 和 materialOptions,导致用户被迫重写。

新增两层防御实现"编辑无感":

1) handleMaterialDropdownVisible 入口拦截:已选过物料(form.base_id 有值)

   时下拉打开直接 return,不请求默认列表

2) handleSearchMaterial 内部拦截:拦截 el-select 内部 emit 的空 query,

   仅在 form.base_id 有值 + safeQuery 为空 + 列表非空时 return

后端(buy/semi/product_service.py,3 文件):

入库类别校验从黑名单改为白名单准入制,彻底杜绝"成品进半成品库"

等非法组合(d94b52b 黑名单方案"成品不能进采购库"已挡不住这种组合)。

- buy_service.py: 黑名单(禁半成品/成品进采购)→ 白名单(必须含"原材料")

- semi_service.py: 统一错误信息格式为"只有【半成品】才允许半成品入库!"

- product_service.py: 统一错误信息格式为"只有【成品】才允许成品入库!"

- 三处空 category 统一显示为"未分类"

配合前端已修复的 catch 块(e.response.data.msg 精准提取),后端新错误

信息可原样弹窗给用户。
2026-06-04 17:57:17 +08:00
DXC
d94b52bf73 入库模块:物料类别隔离硬性校验(写拦截,读放宽) 2026-06-04 17:19:43 +08:00
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
55 changed files with 5372 additions and 1103 deletions

View File

@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git *)",
"Bash(del *)",
"Bash(rm *)"
]
},
"$version": 3
}

View File

@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
]
}
}

Binary file not shown.

View File

@ -8,53 +8,81 @@ REMOTE_BACKUP_BASE="$REMOTE_DIR/data_copy"
REMOTE_BACKUP_DIR="$REMOTE_BACKUP_BASE/$TIMESTAMP"
echo "==================================================="
echo "🚀 开始增量部署 (仅代码,不影响数据库和图片)"
echo "🚀 开始自动化增量部署 (含自我修复与依赖瘦身)"
echo "==================================================="
# 1. 远端备份 (使用 sudo 提权)
echo "[1/4] 正在服务器上备份旧代码并清理多余备份 (可能需要输入服务器密码)..."
# 1. 远端备份与环境急救
echo "[1/4] 正在服务器上急救环境并备份旧代码 (可能需要输入密码)..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_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 && \
echo '>> 执行清理:仅保留 data_copy 下最新的 2 个备份...' && \
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
# 2. 本地打包 (新增了精准拦截本地图片和本地数据库)
echo "[2/4] 正在本地打包新代码 (自动剔除本地图片、数据库和缓存)..."
# 2. 本地精准打包 (绝对屏蔽垃圾文件)
echo "[2/4] 正在本地打包新代码 (执行终极瘦身)..."
tar -czf deploy.tar.gz \
--exclude='node_modules' \
--exclude='.venv' \
--exclude='venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.git' \
--exclude='.idea' \
--exclude='.vscode' \
--exclude='pgdata_*' \
--exclude='uploads_*' \
--exclude='data_copy' \
--exclude='inventory-backend/uploads' \
--exclude='inventory-backend/pgdata' \
--exclude="*/node_modules" \
--exclude="*/venv" \
--exclude="*/.venv" \
--exclude="*/__pycache__" \
--exclude="*.pyc" \
--exclude="*/.git" \
--exclude="*/.idea" \
--exclude="*/.vscode" \
--exclude="inventory-backend/uploads" \
--exclude="inventory-backend/models" \
--exclude="inventory-backend/pgdata" \
--exclude="inventory-backend/pgdata_docker" \
--exclude="data_copy" \
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] 正在传输代码到服务器的临时目录..."
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 找不到命令的问题)
echo "[4/4] 正在生产环境执行热更新 (可能需要输入服务器密码)..."
# 4. 服务器执行替换与重启
echo "[4/4] 正在生产环境执行热更新 (可能需要输入密码)..."
ssh -t $SERVER "cd $REMOTE_DIR && \
echo '>> 移除历史旧目录...' && \
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-web inventory-web_old 2>/dev/null || true) && \
echo '>> 部署新代码...' && \
sudo mv /tmp/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 rm deploy.tar.gz"
echo "==================================================="
echo "✅ 部署完成!请刷新网页查看效果。"
echo "==================================================="
if [ $? -ne 0 ]; then
echo "❌ 远端启动失败,请检查 Docker 日志!"
else
echo "==================================================="
echo "✅ 部署完美完成!请刷新网页查看效果。"
echo "==================================================="
fi
# 扫尾清理本地文件
rm -f deploy.tar.gz

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify, current_app
from sqlalchemy import or_
from app.services.bom_service import BomService, _cache_delete
from app.services.bom_draft_service import BomDraftService
from app.models.base import MaterialBase
from app.models.bom import BomTable
from app.extensions import db
@ -382,3 +383,99 @@ def get_bom_parents():
except Exception as e:
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
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
# ==================== BOM 草稿接口 ====================
@bom_bp.route('/draft/save', methods=['POST'])
@jwt_required()
def save_draft():
"""暂存草稿"""
data = request.get_json()
bom_no = data.get('bom_no')
version = data.get('version', 'V1.0')
parent_id = data.get('parent_id')
children = data.get('children', [])
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
if not parent_id:
return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400
bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children)
return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}})
@bom_bp.route('/draft/detail', methods=['GET'])
@jwt_required()
def get_draft_detail():
"""读取草稿详情"""
bom_no = request.args.get('bom_no')
version = request.args.get('version', 'V1.0')
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
draft = BomDraftService.get_draft_detail(bom_no, version)
# 【核心修改】:查不到草稿是正常现象,返回 HTTP 200 即可
if draft is None:
return jsonify({'code': 200, 'msg': '无草稿', 'data': None}), 200
return jsonify({'code': 200, 'msg': '查询成功', 'data': draft})
@bom_bp.route('/draft/publish', methods=['POST'])
@jwt_required()
def publish_draft():
"""发布草稿为正式 BOM"""
data = request.get_json()
bom_no = data.get('bom_no')
version = data.get('version', 'V1.0')
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
try:
bom_draft_no = BomDraftService.publish_draft(bom_no, version)
return jsonify({'code': 200, 'msg': 'BOM 发布成功', 'data': {'bom_no': bom_draft_no}})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400

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

View File

@ -60,7 +60,8 @@ def search_base():
def search_bom():
try:
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})
except Exception as e:
traceback.print_exc()

View File

@ -60,7 +60,8 @@ def search_base():
def search_bom():
try:
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})
except Exception as e:
traceback.print_exc()

View File

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

View File

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

View File

@ -5,8 +5,18 @@ class BomTable(db.Model):
__tablename__ = 'bom_table'
id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 父子件关联高频列
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 子件过滤高频列
parent_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=False,
index=True
)
child_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=False,
index=True
)
bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') # ★ Redis 缓存 Key + 列表查询核心列
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束
@ -24,5 +34,15 @@ class BomTable(db.Model):
)
# relationships
parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents')
child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children')
parent = db.relationship(
'MaterialBase',
foreign_keys=[parent_id],
backref='bom_parents',
passive_deletes=True
)
child = db.relationship(
'MaterialBase',
foreign_keys=[child_id],
backref='bom_children',
passive_deletes=True
)

View File

@ -0,0 +1,38 @@
from app.extensions import db
class BomDraftTable(db.Model):
__tablename__ = 'bom_draft_table'
id = db.Column(db.Integer, primary_key=True)
bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号')
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本')
parent_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=True,
comment='父件物料ID'
)
child_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=True,
comment='子件物料ID'
)
dosage = db.Column(db.Numeric(19, 4), comment='个数')
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
remark = db.Column(db.Text, comment='备注')
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now(), comment='更新时间')
parent = db.relationship(
'MaterialBase',
foreign_keys=[parent_id],
backref='bom_draft_parents',
passive_deletes=True
)
child = db.relationship(
'MaterialBase',
foreign_keys=[child_id],
backref='bom_draft_children',
passive_deletes=True
)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,145 @@
from app.extensions import db
from app.models.bom_draft import BomDraftTable
from app.models.base import MaterialBase
from app.services.bom_service import BomService
import logging
logger = logging.getLogger(__name__)
class BomDraftService:
@staticmethod
def save_draft(bom_no, version, parent_id, children):
try:
# 1. 删除旧草稿
old = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old:
db.session.delete(rec)
db.session.flush()
# 2. 如果没有任何子件,必须插入一条只包含 parent_id 的占位头数据
if not children:
dummy_draft = BomDraftTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=None, dosage=0, loss_rate=0, remark=''
)
db.session.add(dummy_draft)
else:
# 正常批量插入新草稿行
for child in children:
draft = BomDraftTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=child.get('child_id'),
dosage=child.get('dosage', 0),
loss_rate=child.get('loss_rate', 0),
remark=child.get('remark', '')
)
db.session.add(draft)
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"[BomDraft] save_draft 失败 bom_no={bom_no}: {e}")
raise
return bom_no
@staticmethod
def get_draft_detail(bom_no, version):
rows = db.session.query(
BomDraftTable,
MaterialBase.name.label('child_name'),
MaterialBase.spec_model.label('child_spec')
).outerjoin(
MaterialBase, BomDraftTable.child_id == MaterialBase.id
).filter(
BomDraftTable.bom_no == bom_no,
BomDraftTable.version == version
).all()
if not rows:
return None
first = rows[0].BomDraftTable
parent_id = first.parent_id
parent_material = MaterialBase.query.get(parent_id) if parent_id else None
children = []
for draft, child_name, child_spec in rows:
# 过滤掉保存 BOM 头时插入的占位空行
if draft.child_id is not None:
children.append({
'child_id': draft.child_id,
'child_name': child_name or '',
'child_spec': child_spec or '',
'dosage': float(draft.dosage) if draft.dosage else 0.0,
'loss_rate': float(draft.loss_rate) if draft.loss_rate else 0.0,
'remark': draft.remark or '',
})
return {
'bom_no': bom_no,
'version': first.version,
'parent_id': parent_id,
'parent_name': parent_material.name if parent_material else '',
'parent_spec': parent_material.spec_model if parent_material else '',
'children': children,
}
@staticmethod
def publish_draft(bom_no, version):
"""
发布草稿为正式 BOM
1. 获取草稿数据
2. 强校验(父件不为空、子件列表非空、所有子件 ID>0、用量>0
3. 调用 BomService.save_bom 写入正式 bom_table
4. 清空草稿数据
"""
try:
# 步骤 1
draft = BomDraftService.get_draft_detail(bom_no, version)
if not draft:
raise ValueError('草稿不存在')
# 步骤 2强校验
if not draft.get('parent_id'):
raise ValueError('发布失败:父件不能为空')
children = draft.get('children', [])
if not children:
raise ValueError('发布失败:子件列表不能为空')
for child in children:
if not child.get('child_id') or child['child_id'] <= 0:
raise ValueError('发布失败子件ID必须大于0')
dosage = child.get('dosage')
if not dosage or dosage <= 0:
raise ValueError('发布失败子件用量必须大于0')
# 步骤 3复用正式 BOM 的写入逻辑(跨版本查重 + 缓存清理均在 save_bom 内完成)
publish_data = {
'bom_no': bom_no,
'version': version,
'parent_id': draft['parent_id'],
'children': [
{
'child_id': child['child_id'],
'dosage': child['dosage'],
'remark': child.get('remark', ''),
}
for child in children
],
}
BomService.save_bom(publish_data)
# 步骤 4清空草稿数据
old_rows = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_rows:
db.session.delete(rec)
db.session.commit()
logger.info(f"[BomDraft] publish_draft bom_no={bom_no} version={version} -> 已发布并清空草稿")
except Exception as e:
db.session.rollback()
logger.error(f"[BomDraft] publish_draft 失败 bom_no={bom_no}: {e}")
raise
return bom_no

View File

@ -2,6 +2,8 @@ from app.extensions import db
from app.models.bom import BomTable
from app.models.base import MaterialBase
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 collections import defaultdict
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()
if not target_pairs:
return []
# 2. 聚合查询详情
# 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称解决步骤3过滤遗漏问题
results = []
for bom_no, version in target_pairs:
# ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格
child_alias = db.aliased(MaterialBase)
summary = db.session.query(
BomTable.parent_id,
MaterialBase.name.label('parent_name'),
MaterialBase.spec_model.label('parent_spec'),
MaterialBase.category.label('parent_category'),
BomTable.is_enabled,
func.count(BomTable.child_id).label('child_count')
func.count(BomTable.child_id).label('child_count'),
# ★ 聚合子件名称为逗号分隔字符串用于步骤3关键词过滤
func.string_agg(child_alias.name, ', ').label('child_names'),
# ★ 同时聚合子件规格(备用)
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
).join(
MaterialBase, BomTable.parent_id == MaterialBase.id
).outerjoin(
child_alias, BomTable.child_id == child_alias.id
).filter(
BomTable.bom_no == bom_no,
BomTable.version == version
@ -174,7 +187,9 @@ class BomService:
'parent_spec': summary.parent_spec or '',
'parent_category': summary.parent_category or '',
'is_enabled': summary.is_enabled,
'child_count': summary.child_count
'child_count': summary.child_count,
'child_names': summary.child_names or '', # ★ 新增:子件名称聚合
'child_specs': summary.child_specs or '' # ★ 新增:子件规格聚合
})
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
@ -188,6 +203,8 @@ class BomService:
or kw in (r.get('parent_spec') or '').lower()
or kw in (r.get('bom_no') or '').lower()
or kw in (r.get('parent_category') or '').lower()
or kw in (r.get('child_names') or '').lower() # ★ 修复:加入子件名称过滤
or kw in (r.get('child_specs') or '').lower() # ★ 同步加入子件规格过滤
]
# 按 parent_category 分组
@ -414,27 +431,31 @@ class BomService:
@staticmethod
def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
if not bom_no:
existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
try:
if not bom_no:
existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
# 改为对象级删除以触发审计事件
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_records:
db.session.delete(rec)
# 改为对象级删除以触发审计事件
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_records:
db.session.delete(rec)
for item in child_list:
bom = BomTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '')
)
db.session.add(bom)
db.session.commit()
# ===== 写入后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, version)
logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}")
for item in child_list:
bom = BomTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '')
)
db.session.add(bom)
db.session.commit()
# ===== 写入后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, version)
logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}")
except Exception as e:
db.session.rollback()
logger.error(f"[BOM] create_or_update_bom 失败 bom_no={bom_no}: {e}")
raise
return True
@staticmethod
@ -442,4 +463,69 @@ class BomService:
bom_no = BomService.get_bom_no_by_parent(parent_id)
if not bom_no: return []
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_jwt_extended import decode_token
from app.models.system import SysRolePermission
@ -185,7 +187,7 @@ class DifyPermissionService:
返回:
{
'blocked': bool, # 是否被拦截
'message': str | None, # AI 应返回给用户的错误信息(如果有)
'message': Optional[str], # AI 应返回给用户的错误信息(如果有)
}
"""
if DifyPermissionService.is_super_admin(role):

View File

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

View File

@ -12,6 +12,9 @@ import traceback
import json
import io
import datetime
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
# 需要 pip install openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
@ -249,7 +252,8 @@ class MaterialBaseService:
category = filters.get('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')
if type_val is not None and type_val != '':
@ -524,6 +528,29 @@ class MaterialBaseService:
traceback.print_exc()
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
def create_material(data):
"""新增基础信息"""
@ -555,9 +582,23 @@ class MaterialBaseService:
product_image=json.dumps(data.get('generalImage', [])),
is_enabled=is_enabled_val
)
db.session.add(new_material)
db.session.flush() # 获取 new_material.id
# 先提交主事务,图片向量异步后台提取
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
except Exception as e:
@ -585,7 +626,24 @@ class MaterialBaseService:
if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data:
material.product_image = json.dumps(data['generalImage'])
new_photo_list = data['generalImage']
material.product_image = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
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:
@ -652,6 +710,10 @@ class MaterialBaseService:
f"请先清理相关库存或仅‘禁用’此条目。"
)
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id
)
db.session.delete(material)
db.session.commit()
return material_name
@ -712,7 +774,8 @@ class MaterialBaseService:
category = filters.get('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')
if type_val is not None and type_val != '':
filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip()))
@ -1048,14 +1111,15 @@ class MaterialBaseService:
@staticmethod
def get_latest_specs():
"""
获取所有规格型号的最大连号,按连续区间分组返回
获取所有规格型号的分组统计,按规则聚合后返回
- 前缀统一大写处理
- 只有数字完全连续N, N+1, N+2...)才认定为同一组
- 数字不连续时断开,形成新组
- 按每组数量降序排列
- 返回每个连续区间的最大值
- 匹配模式:(前缀)(单数字二级分类位)(纯数字部分),如 OPT12046 -> OPT, 1, 2046
- OPT 系列:使用 前缀+二级分类位 作为分组 Key如 OPT1, OPT2
- 其他前缀:直接使用前缀作为分组 Key
- 返回每个分组的数量、最大号、完整规格名
"""
import re
from collections import defaultdict
# 1. 查询所有不为空的规格型号
specs = MaterialBase.query.filter(
@ -1063,8 +1127,8 @@ class MaterialBaseService:
MaterialBase.spec_model != ''
).all()
# 2. 解析并收集所有有效的 (prefix, num, original_spec)
parsed = []
# 2. 按分组收集所有数字
groups = defaultdict(list)
for material in specs:
spec = material.spec_model
@ -1072,72 +1136,31 @@ class MaterialBaseService:
continue
base_spec = spec.split('/')[0]
match = re.match(r'^([A-Za-z]+)(\d+)$', base_spec)
match = re.match(r'^([A-Za-z]+)(\d)(\d+)$', base_spec)
if not match:
continue
prefix, num_str = match.groups()
prefix, sub_cat, num_str = match.groups()
prefix = prefix.upper()
num = int(num_str)
parsed.append((prefix, num, spec))
# OPT 系列使用 前缀+单数字二级分类 作为 Key
key = f"{prefix}{sub_cat}" if prefix == 'OPT' else prefix
groups[key].append((num, spec))
# 3. 先按 prefix 升序,再按 num 升序排序
parsed.sort(key=lambda x: (x[0], x[1]))
# 4. 遍历切分连续区间
# 核心逻辑:当 current_num != prev_num + 1 时,断开形成新组
intervals = []
current_prefix = None
current_start = None
current_end = None
current_last_spec = None
for prefix, num, spec in parsed:
if current_prefix is None:
current_prefix = prefix
current_start = num
current_end = num
current_last_spec = spec
elif prefix == current_prefix and num == current_end + 1:
current_end = num
current_last_spec = spec
else:
intervals.append({
'prefix': current_prefix,
'start': current_start,
'end': current_end,
'count': current_end - current_start + 1,
'latest': current_last_spec
})
current_prefix = prefix
current_start = num
current_end = num
current_last_spec = spec
if current_prefix is not None:
intervals.append({
'prefix': current_prefix,
'start': current_start,
'end': current_end,
'count': current_end - current_start + 1,
'latest': current_last_spec
})
# 5. 按每组数量降序排列,再按前缀升序
intervals.sort(key=lambda x: (-x['count'], x['prefix']))
# 6. 构建返回结果
# 3. 生成展示用的统计数据
result = []
for item in intervals:
prefix = item['prefix']
start = item['start']
end = item['end']
for key, items in groups.items():
sorted_items = sorted(items, key=lambda x: x[0])
max_num, max_spec = sorted_items[-1]
result.append({
"group": f"{prefix}({start}-{end})",
"count": item['count'],
"latest": item['latest']
'group': key,
'count': len(sorted_items),
'latest': max_spec,
'max_num': max_num
})
# 4. 按数量降序,再按分组名升序排列
result.sort(key=lambda x: (-x['count'], x['group']))
return result

View File

@ -9,6 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError
import traceback
import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class BuyInboundService:
@ -97,6 +100,12 @@ class BuyInboundService:
if not material: raise ValueError("所选物料不存在")
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
# ============================================================
# 物料类别隔离校验:采购入库禁止【半成品】和【成品】(黑名单拦截制)
# ============================================================
if material.category and ('/半成品' in material.category or '/成品' in material.category):
raise ValueError(f"物料【{material.name}】属于【{material.category}】,【半成品】和【成品】不允许直接采购入库!")
# ============================================================
# 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告
# ============================================================
@ -178,7 +187,22 @@ class BuyInboundService:
inspection_report=json.dumps(data.get('inspection_report', []))
)
db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务(入库单必须落盘),图片向量异步后台提取
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
except Exception as e:
db.session.rollback()
@ -240,7 +264,26 @@ class BuyInboundService:
for k, v in field_mapping.items():
if k in data: setattr(stock, v, data[k])
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo'])
if 'arrival_photo' in data:
new_photo_list = data['arrival_photo']
stock.arrival_photo = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
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'])
# 更新税率
@ -283,8 +326,11 @@ class BuyInboundService:
try:
stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
db.session.delete(stock)
db.session.commit()
return material_name
@ -342,7 +388,8 @@ class BuyInboundService:
# 2. 类别独立搜索
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. 类型独立搜索
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
import traceback
import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class ProductInboundService:
@ -63,7 +66,7 @@ class ProductInboundService:
return {"items": [], "total": 0, "page": 1, "has_next": False}
@staticmethod
def search_bom_options(keyword):
def search_bom_options(keyword, parent_spec=None):
from app.models.bom import BomTable
try:
query = db.session.query(
@ -76,6 +79,9 @@ class ProductInboundService:
if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True)
if parent_spec:
query = query.filter(MaterialBase.spec_model == parent_spec)
if keyword:
kw = f'%{keyword}%'
query = query.filter(
@ -109,6 +115,12 @@ class ProductInboundService:
if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# ============================================================
# 物料类别隔离校验:成品入库必须为【成品】类目(精确白名单准入制)
# ============================================================
if not material.category or '/成品' not in material.category:
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【成品】才允许进行成品入库!")
ProductInboundService._check_unique(
serial_number=data.get('serial_number')
)
@ -184,7 +196,21 @@ class ProductInboundService:
order_id=data.get('order_id')
)
db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务,图片向量异步后台提取
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
except Exception as e:
db.session.rollback()
@ -213,8 +239,24 @@ class ProductInboundService:
if f in data: setattr(stock, f, data[f])
if 'product_photo' in data:
imgs = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
new_photo_list = data['product_photo']
stock.product_photo = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
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:
imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
@ -255,8 +297,11 @@ class ProductInboundService:
try:
stock = StockProduct.query.get(stock_id)
if stock:
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id
)
db.session.delete(stock)
db.session.commit()
return material_name
@ -313,7 +358,8 @@ class ProductInboundService:
sku_str = f'%{sku.strip()}%'
query = query.filter(StockProduct.sku.ilike(sku_str))
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():
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
import traceback
import json
import numpy as np
from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class SemiInboundService:
@ -68,7 +71,7 @@ class SemiInboundService:
return {"items": [], "total": 0, "page": 1, "has_next": False}
@staticmethod
def search_bom_options(keyword):
def search_bom_options(keyword, parent_spec=None):
from app.models.bom import BomTable
try:
query = db.session.query(
@ -81,6 +84,9 @@ class SemiInboundService:
if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True)
if parent_spec:
query = query.filter(MaterialBase.spec_model == parent_spec)
if keyword:
kw = f'%{keyword}%'
query = query.filter(
@ -116,6 +122,12 @@ class SemiInboundService:
if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# ============================================================
# 物料类别隔离校验:半成品入库必须为【半成品】类目(精确白名单准入制)
# ============================================================
if not material.category or '/半成品' not in material.category:
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【半成品】才允许进行半成品入库!")
SemiInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
@ -221,7 +233,21 @@ class SemiInboundService:
remark=data.get('remark')
)
db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务,图片向量异步后台提取
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
except Exception as e:
db.session.rollback()
@ -268,9 +294,24 @@ class SemiInboundService:
setattr(stock, db_attr, data[frontend_key])
if 'arrival_photo' in data:
imgs = data['arrival_photo']
if isinstance(imgs, list):
stock.arrival_photo = json.dumps(imgs)
new_photo_list = data['arrival_photo']
stock.arrival_photo = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
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:
imgs = data['quality_report_link']
if isinstance(imgs, list):
@ -344,8 +385,11 @@ class SemiInboundService:
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id
)
db.session.delete(stock)
db.session.commit()
return material_name
@ -404,7 +448,8 @@ class SemiInboundService:
sku_str = f'%{sku.strip()}%'
query = query.filter(StockSemi.sku.ilike(sku_str))
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():
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
# 图片处理核心库
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
# [新增] 二维码生成库 (标签打印必需包含PIL支持)
@ -21,4 +27,8 @@ openpyxl>=3.1.2
# [新增] 定时任务调度器 (库存预警每日邮件)
APScheduler==3.10.4
# [新增] 时区处理 (APScheduler 需要)
pytz
pytz
# [新增] 进度条库 (脚本和任务所需)
tqdm>=4.66.0
# [新增] pgvector 向量数据库支持(以图搜图 / 实时向量提取)
pgvector>=0.2.0

View File

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

View File

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

View File

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

View File

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

View File

@ -42,3 +42,22 @@ export function deleteBom(bomNo: string, version: string) {
method: 'delete'
})
}
// ==========================================
// BOM 草稿相关接口
// ==========================================
// 1. 暂存草稿
export function saveDraft(data: any) {
return request({ url: '/v1/bom/draft/save', method: 'post', data })
}
// 2. 读取草稿详情
export function getDraftDetail(params: { bom_no: string; version?: string }) {
return request({ url: '/v1/bom/draft/detail', method: 'get', params })
}
// 3. 发布草稿
export function publishDraft(data: { bom_no: string; version: string }) {
return request({ url: '/v1/bom/draft/publish', method: 'post', data })
}

View File

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

View File

@ -43,11 +43,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
}
// 搜索BOM
export function searchBom(keyword: string) {
export function searchBom(keyword: string, parent_spec?: string) {
return request({
url: '/inbound/product/search-bom',
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 (新增)
export function searchBom(keyword: string) {
export function searchBom(keyword: string, parent_spec?: string) {
return request({
url: '/inbound/semi/search-bom',
method: 'get',
params: { keyword }
params: { keyword, parent_spec }
})
}

View File

@ -86,4 +86,12 @@ export function markWarningOrdered(data: { baseId: number; isOrdered: boolean })
method: 'post',
data
})
}
// 9. 获取计量单位字典 (新增/编辑弹窗下拉历史)
export function getMaterialUnitsAPI() {
return request({
url: '/inbound/base/units',
method: 'get'
})
}

View File

@ -0,0 +1,555 @@
<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>
<!-- 拍照按钮 -->
<el-button
v-if="!previewUrl"
type="primary"
class="camera-btn"
@click="openCamera"
>
<el-icon><VideoCamera /></el-icon>
调起摄像头拍照
</el-button>
<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
v-model="cameraVisible"
title="拍照"
width="95%"
style="max-width: 480px; height: 80vh; padding: 0;"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@close="closeCamera"
>
<WebRtcCamera
@cancel="closeCamera"
@photo-submit="handleCameraSubmit"
/>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Camera, Loading, Picture, WarningFilled, VideoCamera } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
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[]>([])
// 拍照相关
const cameraVisible = ref(false)
watch(() => props.modelValue, (val) => {
visible.value = val
if (!val) {
resetState()
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
// 拍照相关方法
const openCamera = () => {
cameraVisible.value = true
}
const closeCamera = () => {
cameraVisible.value = false
}
const handleCameraSubmit = (file: File) => {
// 关闭拍照弹窗
closeCamera()
// 生成预览
currentFile.value = file
previewUrl.value = URL.createObjectURL(file)
// 立即触发搜图
doSearch(file)
}
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 = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = ''
currentFile.value = null
results.value = []
searched.value = false
uploadRef.value?.clearFiles()
}
const fullImageUrl = (path: string) => {
if (!path) return '';
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 = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
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;
}
/* 拍照按钮 */
.camera-btn {
width: 100%;
margin-top: 8px;
}
/* ── 左侧上传区 ── */
.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

@ -39,17 +39,24 @@ const routes: Array<RouteRecordRaw> = [
]
},
// 3. 基础信息
// 3. 物料管理
{
path: '/material',
component: Layout,
redirect: '/material/index',
meta: { title: '物料管理', icon: 'Box' },
children: [
{
path: 'index',
name: 'MaterialBase',
component: () => import('@/views/material/list.vue'),
meta: { title: '基础信息', icon: 'Box' }
},
{
path: 'buyOdoo',
name: 'BuyOdoo',
component: () => import('@/views/material/buyOdoo.vue'),
meta: { title: '基础信息(Odoo)', icon: 'Grid' }
}
]
},

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
<el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery">
<el-option label="全部" value="all" />
<el-option label="名称" value="name" />
<el-option label="俗名" value="common_name" />
<el-option label="专业名称" value="common_name" />
<el-option label="规格" value="spec" />
</el-select>
</template>
@ -32,19 +32,16 @@
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.category"
<el-cascader
v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别"
clearable
filterable
allow-create
default-first-option
style="width: 240px; margin-right: 10px;"
@change="handleQuery"
popper-class="long-dropdown"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
/>
<el-select
v-model="queryParams.type"
@ -84,6 +81,9 @@
<el-button type="primary" plain @click="handleQuery">搜索</el-button>
<el-button plain @click="resetQuery">重置</el-button>
<el-button type="primary" plain @click="imageSearchVisible = true">
<el-icon style="margin-right: 5px"><Picture /></el-icon>拍照识图
</el-button>
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
@ -168,21 +168,31 @@
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template>
<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>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="名" />
<el-checkbox v-model="columns.category.visible" label="类别" />
<el-checkbox v-model="columns.type.visible" label="类" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-model="columns.available.visible" label="可用数" />
<el-checkbox v-model="columns.files.visible" label="资料" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
<el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" />
<el-checkbox v-if="hasColPermission('companyName')" v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-if="hasColPermission('name')" v-model="columns.name.visible" label="名" />
<el-checkbox v-if="hasColPermission('commonName')" v-model="columns.commonName.visible" label="专业名称" />
<el-checkbox v-if="hasColPermission('category')" v-model="columns.category.visible" label="类" />
<el-checkbox v-if="hasColPermission('type')" v-model="columns.type.visible" label="类型" />
<el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-if="hasColPermission('unit')" v-model="columns.unit.visible" label="单位" />
<el-checkbox v-if="hasColPermission('inventory')" v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-if="hasColPermission('available')" v-model="columns.available.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>
</el-popover>
</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.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">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span>
@ -249,6 +259,7 @@
:src="getImageUrl(getImagesOnly(row.generalImage)[0])"
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
preview-teleported
hide-on-click-modal
fit="cover"
/>
<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))"
fit="cover"
preview-teleported
hide-on-click-modal
/>
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
</div>
@ -303,7 +315,7 @@
</el-tag>
</template>
</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 v-if="row.warningStatus === 2">
<el-tag type="danger" size="small">红色预警</el-tag>
@ -336,7 +348,7 @@
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:page-sizes="[50, 100, 200, 500]"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@ -351,22 +363,33 @@
append-to-body
destroy-on-close
@close="cancel"
:close-on-click-modal="!isUploading"
:close-on-click-modal="false"
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
>
<template #header>
<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>
<el-link
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
<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
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
@ -378,7 +401,7 @@
</el-form-item>
</el-col>
<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-form-item>
</el-col>
@ -397,6 +420,20 @@
</el-form-item>
</el-col>
<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')">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
@ -418,26 +455,6 @@
style="width: 50%;"
/>
</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-col>
</el-row>
@ -445,13 +462,26 @@
<el-row>
<el-col :span="12">
<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-col>
<el-col :span="12">
<el-form-item label="可见等级" prop="visibilityLevel">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
</el-row>
@ -482,6 +512,7 @@
>
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item>
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
@ -535,6 +566,7 @@
>
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item>
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
@ -553,10 +585,10 @@
</template>
</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" />
</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
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@ -564,8 +596,15 @@
/>
</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-alert
v-if="warningDialog.selectedCount > 1"
@ -601,7 +640,7 @@
</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
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
type="info"
@ -632,8 +671,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, 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 type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
@ -650,11 +689,14 @@ import {
exportAssetStatistics,
batchSetWarning,
batchSetInspection,
markWarningOrdered
markWarningOrdered,
getMaterialUnitsAPI
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
import ImageSearchDialog from '@/components/ImageSearchDialog.vue';
import { imageSearch as imageSearchApi, type ImageSearchItem } from '@/api/common/upload';
const userStore = useUserStore();
@ -716,12 +758,13 @@ const isUploading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
const advancedFilterVisible = ref(false);
const imageSearchVisible = ref(false);
const advancedConditions = ref([{ field: '', operator: '', value: '' }]);
const fieldOptions = computed(() => {
const allFields = [
{ value: 'companyName', label: '所属公司', perm: 'material_list:companyName' },
{ 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: 'type', label: '类型', perm: 'material_list:type' },
{ value: 'spec', label: '规格型号', perm: 'material_list:spec' },
@ -871,7 +914,8 @@ const columns = reactive({
available: { visible: true },
files: { visible: true },
isEnabled: { visible: true },
isInspectionRequired: { visible: true }
isInspectionRequired: { visible: true },
warningStatus: { visible: true }
});
// 列与权限Code的映射关系数据库中的code
@ -888,22 +932,78 @@ const permissionMap: Record<string, string> = {
available: 'material_list:availableCount',
files: 'material_list:files',
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 = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return;
// 1. 尝试从本地缓存读取用户上次的设置
const cachedData = localStorage.getItem(getStorageKey());
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 => {
const code = permissionMap[key];
if (code) {
// 如果不具备该权限,必须设为 false
columns[key].visible = !!userStore.hasPermission(code);
const colKey = key as keyof typeof columns;
const hasPerm = hasColPermission(colKey);
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 categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const unitOptions = ref<string[]>([]);
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
const categoryCascaderRef = ref<any>(null);
// 选中类别后自动收起下拉面板
// 选中类别后1) 收起下拉面板2) 自动提取末级 Label 末尾的英文字母填入规格型号
const onCategoryChange = () => {
if (categoryCascaderRef.value) {
categoryCascaderRef.value.togglePopperVisible(false);
if (!categoryCascaderRef.value) return;
// 1) 收起下拉
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>({
pageNum: 1,
pageSize: 10,
pageSize: 100,
keyword: '',
searchField: 'all',
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 results = queryString
? 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> => {
try {
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) {
const newUrl = res.data.url
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) }
if (targetField === 'generalImage') {
fileListImage.value.push(fileObj)
@ -1593,7 +1755,6 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
fileListManual.value.push(fileObj)
}
ElMessage.success('上传成功')
onSuccess(res)
} else {
ElMessage.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 = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' });
};
@ -1715,18 +1891,29 @@ const resetAdvancedFilter = () => {
getList();
};
onMounted(() => {
// 1. 修复背景联动:直接对 reactive 对象赋值
if (route.query.keyword) {
queryParams.keyword = route.query.keyword as string;
queryParams.searchField = 'all';
}
// 以图搜图跳转:监听路由 keyword 参数,自动搜索并清理 URL
watch(
() => route.query.keyword,
(newKeyword) => {
if (newKeyword) {
queryParams.keyword = newKeyword as string;
queryParams.searchField = 'all';
getList();
// 清理 URL 参数,防止刷新后重复触发搜索
router.replace({ path: route.path, query: {} });
}
},
{ immediate: true }
);
// 先根据权限初始化列显示状态
onMounted(() => {
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList();
// 无外部 keyword 参数时执行默认查询;有 keyword 则由上方的 watch 接管
if (!route.query.keyword) {
getList();
}
getOptionsList();
fetchUnitList();
// 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query);

View File

@ -527,12 +527,14 @@ const totalExportCount = computed(() => {
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stockO(N),无嵌套循环)---
const maxBuildableSets = computed(() => {
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
if (dosage <= 0) return minSets
const stock = parseFloat(bomItem.current_stock) || 0
return Math.min(minSets, Math.floor(stock / dosage))
}, Infinity)
return result === Infinity ? 0 : result
})
const shortageList = computed(() => {
@ -723,8 +725,8 @@ watch(selectedBomNo, async (newBomNo) => {
const confirmBomAdd = async () => {
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
if (allStockData.value.length === 0) {
await ensureAllStockLoaded()
if (stockList.value.length === 0) {
await loadStockList()
}
if (currentBomDetail.value.length === 0) {
@ -745,7 +747,7 @@ const confirmBomAdd = async () => {
const dosage = parseFloat(bomItem.dosage) || 0
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)
)

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-row :gutter="20">
@ -74,14 +74,14 @@
v-model="materialBaseId"
filterable
remote
reserve-keyword
reserve-keyword="true"
clearable
placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials"
@visible-change="onMaterialDropdownVisibleChange"
@ -171,7 +171,7 @@
</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-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态">
@ -215,7 +215,7 @@
</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-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (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
searchKeyword.value = query
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
hasNextPage.value = true
try {
const res: any = await searchMaterialPurchase(query, 1)
const res: any = await searchMaterialPurchase(safeQuery, 1)
materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false
} finally {
@ -431,9 +434,14 @@ const handleLoadMoreMaterials = async () => {
}
const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) {
handleSearchMaterial('')
}
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const onMaterialSelected = (id: number | null) => {

View File

@ -48,17 +48,17 @@
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select
v-model="queryParams.category"
<el-cascader
v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别"
class="filter-item-select"
clearable
filterable
style="width: 220px;"
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
/>
<el-select
v-model="queryParams.material_type"
@ -111,18 +111,32 @@
<template #reference>
<el-button :icon="Setting" circle class="circle-btn" />
</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">
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
<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-col>
</template>
</el-row>
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
<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-col>
</template>
</el-row>
</el-checkbox-group>
</el-popover>
@ -193,6 +207,7 @@
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
hide-on-click-modal
fit="cover"
lazy
>
@ -226,11 +241,7 @@
<el-icon><Printer/></el-icon> 打印
</el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default" v-permission="'inbound_buy:delete'">删除</el-button>
</template>
</el-popconfirm>
<el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -253,7 +264,7 @@
:width="'min(1000px, 95vw)'"
top="4vh"
destroy-on-close
:close-on-click-modal="!isUploading"
:close-on-click-modal="false"
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
class="stylish-dialog compact-layout"
@ -283,31 +294,21 @@
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
clearable
<el-autocomplete
v-model="materialNameInput"
:fetch-suggestions="fetchMaterialSuggestions"
:value-key="'name'"
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
:trigger-on-focus="true"
clearable
style="width: 100%"
@change="onMaterialSelected"
default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
@select="onMaterialSelected"
@clear="onMaterialClear"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<template #default="{ item }">
<div class="option-item">
<div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -321,11 +322,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</div>
</el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="12" style="display: flex; align-items: center;">
@ -456,7 +454,8 @@
:http-request="(opts) => customUpload(opts, 'arrival_photo')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'arrival_photo')"
:before-upload="beforeAvatarUpload">
:before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -475,7 +474,8 @@
:http-request="(opts) => customUpload(opts, 'inspection_report')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'inspection_report')"
:before-upload="beforeAvatarUpload">
:before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -638,8 +638,8 @@
</template>
</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="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
<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" :close-on-press-escape="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@ -647,7 +647,7 @@
/>
</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 v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -806,6 +806,17 @@ const isUploading = ref(false)
const categoryOptions = ref<string[]>([])
const typeOptions = 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({
page: 1,
@ -822,11 +833,8 @@ const queryParams = reactive({
advancedFilters: [] as any[]
})
const materialNameInput = ref('')
const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
const printVisible = ref(false)
const printLoading = ref(false)
@ -899,6 +907,8 @@ const operatorOptions = ref([
{ value: 'le', label: '小于等于' }
])
// ================= 第一步:声明基础数据 =================
// 基础列
const baseColumns = [
{prop: 'company_name', label: '所属公司'},
@ -939,6 +949,8 @@ const stockColumns = [
{prop: 'inspection_report', label: '检测报告', minWidth: '100'}
]
const allColumns = [...baseColumns, ...stockColumns]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'inbound_buy:id',
@ -973,14 +985,15 @@ const permissionMap: Record<string, string> = {
inspection_report: 'inbound_buy:inspection_report'
}
// 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => {
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
}
// ================= 第二步:声明响应式变量 =================
const visibleColumnProps = ref<string[]>([])
// 检查列权限
// ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_BUY_COLS_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
@ -989,9 +1002,49 @@ const hasColumnPermission = (prop: string) => {
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({
id: undefined, base_id: undefined as number | undefined,
@ -1067,76 +1120,56 @@ const querySearchCurrency = (queryString: string, cb: any) => {
cb(filtered)
}
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try {
const res: any = await searchMaterialBase(query, 1)
if (res.data) {
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults
hasNextPage.value = res.has_next
}
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
const res: any = await searchMaterialBase(safeQuery)
if (res.code === 200 && res.data) {
cb((res.data || []).map((i: any) => ({ ...i, isHistory: false })))
} else {
hasNextPage.value = false
cb([])
}
} catch (e) {
searchPage.value -= 1
cb([])
} finally {
loadingMore.value = false
searchLoading.value = false
}
}
const onMaterialSelected = async (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.company_name = item.company_name
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
// 保存强制质检标记
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
// 更新表单校验规则
updateInspectionRules()
checkHistoryAndSetMode(item.id)
const onMaterialClear = () => {
form.base_id = undefined
form.company_name = ''
form.material_name = ''
form.spec_model = ''
form.category = ''
form.unit = ''
form.material_type = ''
isCurrentMaterialInspectionRequired.value = false
updateInspectionRules()
}
// 获取该物料历史入库库位(新增独立接口)
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
const onMaterialSelected = async (item: any) => {
form.base_id = item.id
form.company_name = item.company_name
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
materialNameInput.value = item.name
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
updateInspectionRules()
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
}
@ -1325,6 +1358,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions()
if (res.code === 200) {
categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types
companyOptions.value = res.data.companies
}
@ -1333,6 +1367,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 () => {
try {
@ -1401,6 +1459,7 @@ const handleUpdate = (row: any) => {
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }]
materialNameInput.value = row.material_name
// 设置强制质检标记
isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false
updateInspectionRules()
@ -1457,8 +1516,10 @@ const submitForm = async () => {
await fetchData()
visible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '操作失败')
} catch (error: any) {
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false }
} else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1631,7 +1692,39 @@ const handleSortChange = ({ column, prop, order }: any) => {
fetchData()
}
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除采购入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteBuyInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" 吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
// ------------------------------------
// 打印逻辑
@ -1661,8 +1754,7 @@ const confirmPrint = async () => {
}
const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
materialOptions.value = []; materialNameInput.value = ''; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
// 重置强制质检标记
isCurrentMaterialInspectionRequired.value = false
Object.assign(form, {

View File

@ -47,17 +47,17 @@
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select
v-model="queryParams.category"
<el-cascader
v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别"
class="filter-item-select"
clearable
filterable
style="width: 220px;"
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
/>
<el-select
v-model="queryParams.material_type"
@ -123,9 +123,23 @@
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<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-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-checkbox-group>
</el-popover>
@ -193,6 +207,7 @@
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
hide-on-click-modal
fit="cover"
lazy
>
@ -227,7 +242,7 @@
<el-icon><Printer/></el-icon>
</el-button>
<el-button link type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -244,7 +259,7 @@
@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">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
@ -268,24 +283,21 @@
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
<el-autocomplete
v-model="materialNameInput"
:fetch-suggestions="fetchMaterialSuggestions"
:value-key="'name'"
clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
:trigger-on-focus="true"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
v-loadmore="loadMoreMaterials"
@select="onMaterialSelected"
@clear="onMaterialClear"
popper-class="product-dropdown"
>
<template #prefix><el-icon><Search /></el-icon></template>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<template #default="{ item }">
<div class="option-item">
<div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -299,11 +311,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</div>
</el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="12" style="display: flex; align-items: center;">
@ -389,7 +398,7 @@
<el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container" id="upload-product_photo">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -403,7 +412,7 @@
<el-col :span="12">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -416,7 +425,7 @@
<el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container" id="upload-inspection_report_link">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -445,11 +454,14 @@
v-model="form.bom_code"
filterable
remote
reserve-keyword="true"
clearable
placeholder="搜规格/编号"
:disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom"
:loading="bomSearchLoading"
@change="handleBomSelect"
default-first-option="true"
style="width: 100%"
>
<el-option
@ -529,10 +541,10 @@
</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" />
</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
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@ -540,7 +552,7 @@
/>
</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 v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -572,7 +584,7 @@
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import {
@ -607,28 +619,6 @@ const debounce = (fn: Function, delay: number = 500) => {
}
// ------------------------------------
// v-loadmore
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) {
binding.value()
}
})
}
}
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
}
}
const userStore = useUserStore()
const router = useRouter()
@ -649,7 +639,6 @@ const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
@ -660,6 +649,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 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 companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false)
@ -708,11 +709,7 @@ const operatorOptions = ref([
{ label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' },
])
const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
const materialNameInput = ref('')
// BOM 搜索相关
const bomSearchLoading = ref(false)
@ -745,6 +742,8 @@ const scannerDialogVisible = ref(false)
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
// ================= 第一步:声明基础数据 =================
// [核心优化] 所有列定义
const allColumns = [
{ prop: 'company_name', label: '所属公司', minWidth: '100', sortable: true }, // [新增]
@ -810,15 +809,15 @@ const permissionMap: Record<string, string> = {
detail_link: 'inbound_product:detail_link',
}
// 根据用户权限初始化列显示状态
// 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => {
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
}
// ================= 第二步:声明响应式变量 =================
const visibleColumnProps = ref<string[]>([])
// 检查列权限
// ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_PROD_COLS_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
@ -827,6 +826,50 @@ const hasColumnPermission = (prop: string) => {
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 聚合库存
const displayData = computed(() => {
// 检查是否有序列号权限
@ -861,7 +904,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 visibleColumnProps = ref<string[]>([])
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
@ -883,9 +925,15 @@ const form = reactive({
// BOM Search Logic
// ------------------------------------
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
try {
const res: any = await searchBom(query)
const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
@ -981,73 +1029,52 @@ const rules = {
}
// Material Search & Population Logic
// ------------------------------------
// Material Search & Population Logic (已修改)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
const fetchMaterialSuggestions = (query: string, cb: (results: any[]) => void) => {
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try {
const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
searchMaterialBase(safeQuery).then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name, isHistory: false }))
cb(formatted)
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.data.has_next
} else {
hasNextPage.value = false
}
} catch (e) {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
const onMaterialClear = () => {
form.base_id = undefined
form.company_name = ''
form.material_name = ''
form.spec_model = ''
form.material_type = ''
form.category = ''
form.unit = ''
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
}
const onMaterialSelected = async (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.company_name = item.company_name // [新增]
form.material_name = item.name
form.spec_model = item.spec
form.material_type = item.type
form.category = item.category
form.unit = item.unit
// 获取该物料历史入库库位(新增独立接口)
try {
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
const onMaterialSelected = (item: any) => {
form.base_id = item.id
form.company_name = item.company_name
form.material_name = item.name
form.spec_model = item.spec
form.material_type = item.type
form.category = item.category
form.unit = item.unit
materialNameInput.value = item.name
// 切换物料时清空已选 BOM防止脏数据
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
// 获取该物料历史入库库位
request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
if (res.code === 200 && res.data?.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
}
}).catch(() => {})
}
// ------------------------------------
@ -1097,6 +1124,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions()
if (res.code === 200) {
categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
}
@ -1105,6 +1133,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 () => {
try {
@ -1172,7 +1224,6 @@ const handleCreate = () => {
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true
materialOptions.value = []
}
const handleUpdate = (row: any) => {
@ -1201,7 +1252,7 @@ const handleUpdate = (row: any) => {
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
materialNameInput.value = row.material_name
// 回显BOM
if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -1349,8 +1400,10 @@ const submitForm = async () => {
if (newItem) { ElMessage.info('发送打印...'); try { await executePrint({ ...newItem, copies: form.print_copies }); ElMessage.success(`指令已发送 (x${form.print_copies})`) } catch (e: any) { ElMessage.warning('打印失败') } }
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
visible.value = false; fetchData()
} catch(e:any) {
ElMessage.error(e.msg || '操作失败')
} catch(error:any) {
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false }
} else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1358,7 +1411,40 @@ const submitForm = async () => {
})
}
const handleDelete = async (row: any) => { try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch(e) { ElMessage.error('删除失败') } }
const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteProductInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" `,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku }
@ -1366,7 +1452,7 @@ const handlePrint = async (row: any) => {
}
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
materialNameInput.value = ''; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
}
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')

View File

@ -48,17 +48,17 @@
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select
v-model="queryParams.category"
<el-cascader
v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别"
class="filter-item-select"
clearable
filterable
style="width: 220px;"
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
/>
<el-select
v-model="queryParams.material_type"
@ -127,18 +127,32 @@
<template #reference>
<el-button :icon="Setting" circle class="circle-btn" />
</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">
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
<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-col>
</template>
</el-row>
<div class="col-group-title" style="margin-top:10px">生产与库存</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
<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-col>
</template>
</el-row>
</el-checkbox-group>
</el-popover>
@ -216,6 +230,7 @@
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
hide-on-click-modal
fit="cover"
lazy
>
@ -250,11 +265,7 @@
<el-icon><Printer/></el-icon> 打印
</el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default">删除</el-button>
</template>
</el-popconfirm>
<el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -277,7 +288,7 @@
width="min(1000px, 95vw)"
top="5vh"
destroy-on-close
:close-on-click-modal="!isUploading"
:close-on-click-modal="false"
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
class="stylish-dialog compact-layout"
@ -307,29 +318,19 @@
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
clearable
<el-autocomplete
v-model="materialNameInput"
:fetch-suggestions="fetchMaterialSuggestions"
:value-key="'name'"
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
:trigger-on-focus="true"
clearable
style="width: 100%"
@change="onMaterialSelected"
default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
@select="onMaterialSelected"
@clear="onMaterialClear"
>
<template #prefix><el-icon><Search /></el-icon></template>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<template #default="{ item }">
<div class="option-item">
<div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -343,11 +344,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</div>
</el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="12" style="display: flex; align-items: center;">
@ -468,7 +466,7 @@
<el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container" id="upload-arrival_photo">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -480,7 +478,7 @@
<el-col :span="24">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -514,11 +512,14 @@
v-model="form.bom_code"
filterable
remote
reserve-keyword="true"
clearable
placeholder="搜规格/编号"
:disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom"
:loading="bomSearchLoading"
@change="handleBomSelect"
default-first-option="true"
style="width: 100%"
>
<el-option
@ -592,15 +593,15 @@
</template>
</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="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
<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" :close-on-press-escape="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false"
/>
</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 v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -627,7 +628,7 @@
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {ElMessage, ElLoading} from 'element-plus'
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import {
@ -717,6 +718,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 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 companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false)
@ -765,11 +778,8 @@ const operatorOptions = ref([
{ label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' },
])
const materialNameInput = ref('')
const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关
const bomSearchLoading = ref(false)
@ -802,6 +812,8 @@ const warehouseOptions = ref<any[]>([])
const entryMode = ref('batch')
const modeLocked = ref(false)
// ================= 第一步:声明基础数据 =================
// 列定义
const baseColumns = [
{prop: 'company_name', label: '所属公司', sortable: true}, // [新增]
@ -838,14 +850,8 @@ const stockColumns = [
{prop: 'quality_report_link', label: '质量报告', minWidth: '100', sortable: false},
{prop: 'detail_link', label: '详情链接', minWidth: '100', sortable: false},
]
const allColumns = [...baseColumns, ...stockColumns]
// 初始化列显示状态(纯权限驱动,废除本地缓存)
const initColumnPermissions = () => {
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
}
const allColumns = [...baseColumns, ...stockColumns]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
@ -886,7 +892,15 @@ const permissionMap: Record<string, string> = {
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) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
@ -895,8 +909,51 @@ const hasColumnPermission = (prop: string) => {
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 visibleColumnProps = ref<string[]>([])
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
@ -932,9 +989,15 @@ watch(
// BOM Search Logic
// ------------------------------------
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
try {
const res: any = await searchBom(query)
const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
@ -980,70 +1043,58 @@ const handleManagerSelect = (item: any) => {
// ------------------------------------
// Material Search (Matches Buy.vue)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try {
const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.data.has_next
const res: any = await searchMaterialBase(safeQuery)
if (res.code === 200 && res.data) {
cb((res.data?.items || res.data || []).map((i: any) => ({ ...i, isHistory: false })))
} else {
hasNextPage.value = false
cb([])
}
} catch (e) {
searchPage.value -= 1
cb([])
} finally {
loadingMore.value = false
searchLoading.value = false
}
}
const onMaterialSelected = async (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.company_name = item.company_name // [新增]
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
checkHistoryAndSetMode(item.id)
const onMaterialClear = () => {
form.base_id = undefined
form.company_name = ''
form.material_name = ''
form.spec_model = ''
form.category = ''
form.unit = ''
form.material_type = ''
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
}
// 获取该物料历史入库库位(新增独立接口)
try {
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
const onMaterialSelected = async (item: any) => {
form.base_id = item.id
form.company_name = item.company_name
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
materialNameInput.value = item.name
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
}
@ -1190,6 +1241,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions()
if (res.code === 200) {
categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
}
@ -1198,6 +1250,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 () => {
try {
@ -1288,6 +1364,7 @@ const handleUpdate = (row: any) => {
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
materialNameInput.value = row.material_name
// 回显BOM如果存在
if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -1429,8 +1506,10 @@ const submitForm = async () => {
}
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
await fetchData(); visible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '操作失败')
} catch (error: any) {
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false }
} else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1438,7 +1517,40 @@ const submitForm = async () => {
})
}
const handleDelete = async (row: any) => { try { await deleteSemiInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除半成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteSemiInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" 吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }
@ -1446,7 +1558,7 @@ const handlePrint = async (row: any) => {
}
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
materialOptions.value = []; materialNameInput.value = ''; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
Object.assign(form, {
id: undefined, base_id: undefined,
company_name: '', // [新增]

View File

@ -85,6 +85,8 @@
:title="dialogTitle"
width="700px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="resetDialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
@ -103,14 +105,14 @@
v-model="form.base_id"
filterable
remote
reserve-keyword
reserve-keyword="true"
placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
>
<el-option
v-for="item in materialOptions"
@ -269,6 +271,7 @@ const perPage = ref(20)
const total = ref(0)
const materialOptions = ref<any[]>([])
const searchKeyword = ref('')
const searchLoading = ref(false)
const searchForm = reactive({
@ -329,15 +332,31 @@ const handlePageChange = (val: number) => {
}
const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) {
handleSearchMaterial('')
}
if (!visible) return
// 防御性拦截 1用户已选过物料form.base_id 有值)
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
// 否则会清空 searchKeyword 和 materialOptions破坏用户正在编辑的搜索结果。
if (form.base_id) return
// 防御性拦截 2已经有搜索关键字或已经有下拉数据
// 同样不要重置、不要再请求默认列表
if (searchKeyword.value || materialOptions.value.length > 0) return
handleSearchMaterial('')
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
searchKeyword.value = safeQuery
searchLoading.value = true
try {
const res = await searchMaterialBase(query)
const res = await searchMaterialBase(safeQuery)
if (res.code === 200) {
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults

View File

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