feat(production-master-data): add 异常不良管理功能

1. 新增设备类别API接口封装
2. 新增异常不良管理的CRUD、导入导出API
3. 添加异常不良管理页面路由与多语言配置
4. 新增文件工具类支持Excel读写下载
5. 实现完整的异常不良管理页面与导入弹窗
6. 新增功能测试流程文档
7. 安装xlsx依赖支持Excel操作
This commit is contained in:
sheng
2026-06-02 14:05:15 +08:00
parent a0192d9567
commit ddc715e17c
11 changed files with 1140 additions and 5 deletions

View File

@@ -0,0 +1,210 @@
# 功能测试流程文档 ——【异常不良管理】
> 本文档为【异常不良管理】功能迁移后的独立测试文档。测试人员请按以下流程逐步执行,对通过的用例在"通过"列打勾(`[x]`),未通过的用例在"问题记录"列描述具体现象(包括错误截图编号、操作步骤、实际/预期差异等)。
| 项目名称 | MES-UI生产主数据 → 产品模型 → 异常不良管理) |
| --- | --- |
| 文档版本 | v1.0 |
| 适用版本 | mes-ui 本次迁移版本 |
| 编写日期 | 2026-06-02 |
| 测试入口 | 菜单:生产主数据 → 产品模型 → 异常不良管理 |
| 关联文件 | [主页面](file:///d:/code/mes/mes-ui/src/views/production-master-data/product-model/product-ng-info/index.vue) · [导入组件](file:///d:/code/mes/mes-ui/src/views/production-master-data/product-model/product-ng-info/components/ImportDialog/index.vue) · [API](file:///d:/code/mes/mes-ui/src/api/production-master-data/product-ng-info.js) · [路由](file:///d:/code/mes/mes-ui/src/router/modules/production-master-data.js) · [文件工具](file:///d:/code/mes/mes-ui/src/utils/file.js) |
---
## 一、测试环境配置要求
| 项 | 要求 |
| --- | --- |
| 后台环境 | Webman + 已部署 `production_configuration/product_model/product_ng_info/` 路由接口;数据库中已创建 `product_ng_info``device_category` 数据表 |
| 前端环境 | `npm run serve` / `pnpm run serve` 启动 mes-ui 工程;浏览器推荐 Chrome 110+ |
| 登录账号 | 拥有「生产主数据 / 异常不良管理」菜单访问权限,且分配以下权限点:<br>· `…/product_ng_info/create`(新增 / 导入)<br>· `…/product_ng_info/edit`(编辑)<br>· `…/product_ng_info/delete`(删除 / 批量删除)<br>· `…/product_ng_info/export`(导出) |
| 网络要求 | 前后端网络互通;浏览器可访问 `VUE_APP_API` 环境变量对应域名 |
| 数据准备 | · 至少 3 条设备类别device_category数据<br>· 至少 5 条异常不良类别记录<br>· 准备一份合法格式的 Excel 导入文件(含 3 行导入数据)和一份缺少列的非法文件 |
| 浏览器工具 | 打开 DevToolsF12→ Network 与 Console便于抓取接口与异常 |
| 第三方库 | `xlsx` 已安装Excel 读写支持),确认 `node_modules/xlsx/` 目录存在 |
---
## 二、测试前置条件
1. 已成功登录系统,登录账号具备上述所有权限点。
2. 侧边栏显示 **生产主数据 → 产品模型 → 异常不良管理** 菜单可点击进入。
3. 浏览器语言切换为「简体中文」,界面文字全部为中文,无 `key is not defined` 或英文缺失。
4. 列表默认展示至少 5 条记录(通过 `product_ng_info/list` 接口返回)。
5. 浏览器缩放比例 100%;分辨率建议 1440×900 及以上。
6. DevTools 关闭缓存Network → Disable cache确保加载最新前端资源。
---
## 三、测试用例
> **字段说明**
> - 设备类别 `device_category_id`:必选,从设备类别 API 动态加载
> - 类别 `type`:必选,下拉值「异常(ERR)/不良(NG)」
> - 异常不良编码 `number`:必填,长度 1~100
> - 异常不良名称 `explain`:必填,长度 1~100
> - 备注 `note`:选填,多行文本
> - 列表展示字段:复选框 / 设备类别 / 异常不良类别 / 异常不良编码 / 异常不良名称 / 备注 / 操作
### 3.1 列表展示与加载
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.1.1 | 进入「异常不良管理」菜单 | 列表正常加载,无 JS 报错;表头依次为:复选框、设备类别、异常不良类别、异常不良编码、异常不良名称、备注、操作 | | [ ] | |
| 3.1.2 | 观察列表加载过程 | 列表加载期间显示 loading 遮罩动画,加载完成后消失 | | [ ] | |
| 3.1.3 | 列表为空时(搜索条件无匹配结果) | 表格区域显示空状态占位(如"暂无数据"),无 JavaScript 报错 | | [ ] | |
| 3.1.4 | 分页组件:跳转到第 2 页 | 请求参数 `page_no=2` 正确,数据刷新 | | [ ] | |
| 3.1.5 | 修改每页条数(如从 10 改为 20 | `page_size=20`,列表重载 | | [ ] | |
| 3.1.6 | 列表行内操作列固定在右侧,横向滚动查看 | 操作列不随滚动条消失,固定可见 | | [ ] | |
### 3.2 搜索功能
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.2.1 | 点击「设备类别」下拉框 → 从列表中选择一项 → 点击「查询」 | 列表仅展示所选设备类别的记录 | | [ ] | |
| 3.2.2 | 在「查询类型」下拉框中选择「异常」→ 点击「查询」 | 列表仅展示 `type=ERR` 的记录 | | [ ] | |
| 3.2.3 | 在「查询类型」下拉框中选择「不良」→ 点击「查询」 | 列表仅展示 `type=NG` 的记录 | | [ ] | |
| 3.2.4 | 在「异常不良编码」输入框输入关键字 → 点击「查询」 | 列表仅展示编码包含该关键字的记录 | | [ ] | |
| 3.2.5 | 在「异常不良名称」输入框输入关键字 → 点击「查询」 | 列表仅展示名称包含该关键字的记录 | | [ ] | |
| 3.2.6 | 设备类别 + 类型 + 编码同时填写 → 点击「查询」 | 列表为三条件 AND 过滤结果 | | [ ] | |
| 3.2.7 | 输入查询条件后点击「重置」 | 所有输入框、下拉框恢复初始状态,列表恢复为全量数据 | | [ ] | |
| 3.2.8 | 在搜索输入框中按回车键 | 等同于点击「查询」,触发列表刷新 | | [ ] | |
| 3.2.9 | 设备类别下拉框首次展开时(即 focus 事件) | 自动调用 `/device_category/all` 加载选项;未展开时不再重复请求 | | [ ] | |
### 3.3 新增
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.3.1 | 点击工具栏「新增」按钮 | 弹出标题为「新增异常不良类别」的对话框,表单为空 | | [ ] | |
| 3.3.2 | 新增弹框打开时,设备类别下拉框自动加载可选项 | 设备类别下拉列表有数据,与搜索时的下拉来源一致 | | [ ] | |
| 3.3.3 | 不填任何字段,点击「确定」 | 表单校验:设备类别、类别、编码、名称四个字段下方均红字提示必填 | | [ ] | |
| 3.3.4 | 仅选择设备类别与类别,不填编码 → 点击「确定」 | 仅编码字段提示必填;名称字段提示必填 | | [ ] | |
| 3.3.5 | 编码输入 101 字符 → 点击「确定」 | 编码字段下方提示「长度在1到100个字符」 | | [ ] | |
| 3.3.6 | 名称输入 101 字符 → 点击「确定」 | 名称字段下方提示「长度在1到100个字符」 | | [ ] | |
| 3.3.7 | 正常填写:设备类别=任意、类别=异常、编码=ERR-01、名称=设备宕机、备注=生产设备意外停机 → 点击「确定」 | 弹出"操作成功"提示,对话框自动关闭,列表自动刷新并出现该条记录 | | [ ] | |
| 3.3.8 | 新增弹框点击「取消」或右上角 × | 对话框关闭,列表未新增任何记录 | | [ ] | |
| 3.3.9 | 使用无 `product_ng_info/create` 权限的账号访问 | 工具栏不显示「新增」按钮 | | [ ] | |
| 3.3.10 | 后端返回「编码已存在」业务错误 | 页面友好提示错误原因,弹框不关闭(可修正后重试) | | [ ] | |
### 3.4 编辑
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.4.1 | 列表行点击「编辑」 | 弹出标题为「编辑异常不良类别」的对话框,表单字段回显该行当前数据 | | [ ] | |
| 3.4.2 | 清空编码后「确定」 | 提示编码必填 | | [ ] | |
| 3.4.3 | 修改名称为新值,备注保留为空 → 「确定」 | 操作成功,列表对应行名称更新,备注为空 | | [ ] | |
| 3.4.4 | 修改编码(与已有数据重复)→ 「确定」 | 后端返回重复提示,页面友好显示不崩溃 | | [ ] | |
| 3.4.5 | 编辑对话中点击「取消」 | 对话框关闭,列表数据未变化 | | [ ] | |
| 3.4.6 | 无 `product_ng_info/edit` 权限的账号 | 行内不显示「编辑」按钮 | | [ ] | |
| 3.4.7 | 两个标签页同时打开同一条编辑 → A 保存后 B 再次保存 | 后端返回最新数据的验证结果,不产生脏写 | | [ ] | |
### 3.5 删除(行内删除)
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.5.1 | 列表行点击「删除」 | 弹出确认框,提示「确定要执行该操作吗?」 | | [ ] | |
| 3.5.2 | 确认框点击「取消」 | 关闭确认框,数据未删除 | | [ ] | |
| 3.5.3 | 确认框点击「确定」 | 操作成功,列表自动移除该记录 | | [ ] | |
| 3.5.4 | 删除最后一页唯一一条数据 | 列表自动回退到上一页(或空状态),不出现空白页 | | [ ] | |
| 3.5.5 | 删除已被其他业务引用的记录 | 后端返回业务级错误提示,数据保留,页面不崩溃 | | [ ] | |
| 3.5.6 | 无 `product_ng_info/delete` 权限的账号 | 行内不显示「删除」按钮 | | [ ] | |
| 3.5.7 | 网络断开时点击「删除」 | 提示网络异常,数据未被误删 | | [ ] | |
### 3.6 批量删除
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.6.1 | 不勾选任何复选框,点击「批量删除」 | 提示"请先选择数据" | | [ ] | |
| 3.6.2 | 勾选 2~3 行记录 → 点击「批量删除」 | 弹出确认框「确定要删除所选异常不良类别吗?」 | | [ ] | |
| 3.6.3 | 确认框点击「确定」 | 选中的记录全部从列表消失,操作成功提示 | | [ ] | |
| 3.6.4 | 确认框点击「取消」 | 数据保留,勾选状态保留 | | [ ] | |
| 3.6.5 | 选中的行中包含已被引用的数据 | 后端返回针对性的业务错误提示,未删除任何数据 | | [ ] | |
| 3.6.6 | 勾选全选(表头复选框)→ 批量删除 | 所有可见页数据被选中并删除 | | [ ] | |
### 3.7 Excel 导入
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.7.1 | 点击工具栏「导入」按钮 | 弹出标题为「导入异常不良数据」的对话框,上方显示黄色警告提示 | | [ ] | |
| 3.7.2 | 点击「下载模板」按钮 | 浏览器下载 Excel 文件(文件名为「异常不良数据导入模版.xlsx」内容包含表头 | | [ ] | |
| 3.7.3 | 点击「选择文件」→ 选择非 Excel 文件(如 .txt | 提示"上传文件格式错误" | | [ ] | |
| 3.7.4 | 点击「选择文件」→ 选择列缺失的 Excel缺少"异常不良编码"列) | 提示"文件列缺失: 异常不良编码" | | [ ] | |
| 3.7.5 | 选择合法 Excel 文件(含 3 行数据) | 预览表格展示 3 行数据,列与 Excel 内容一致 | | [ ] | |
| 3.7.6 | 选择合法 Excel 后,再次选择另一个文件 | 上一个文件被替换,预览表格刷新为新文件数据 | | [ ] | |
| 3.7.7 | 预览数据无误后点击「确定」 | 提示"操作成功",弹框关闭,列表刷新,新数据出现在列表中 | | [ ] | |
| 3.7.8 | 未选择文件(预览表格为空)点击「确定」 | 提示"请先导入数据" | | [ ] | |
| 3.7.9 | 导入过程中点击「取消」或关闭弹框 | 操作中断,已读取的预览数据被清空 | | [ ] | |
| 3.7.10 | 无 `product_ng_info/create` 权限的账号 | 工具栏不显示「导入」按钮 | | [ ] | |
| 3.7.11 | 导入的 Excel 中设备类别名称在后端无法匹配 | 后端返回相应错误提示,列表不新增 | | [ ] | |
### 3.8 Excel 导出
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.8.1 | 点击工具栏「导出」按钮 | 弹出确认框「确定要导出当前查询结果吗?」 | | [ ] | |
| 3.8.2 | 确认框点击「取消」 | 提示"操作已取消",未触发任何请求 | | [ ] | |
| 3.8.3 | 确认框点击「确定」 | 提示"创建导出任务成功",自动跳转至任务管理页面 | | [ ] | |
| 3.8.4 | 在查询条件中筛选后再点击「导出」 | 后端收到的请求包含当前搜索条件 `device_category_id` / `type` / `number` / `explain` | | [ ] | |
| 3.8.5 | 无 `product_ng_info/export` 权限的账号 | 工具栏不显示「导出」按钮 | | [ ] | |
### 3.9 权限与国际化
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.9.1 | 切换为仅有「查询」权限的账号 | 仅能查看列表与查询,无新增/编辑/删除/导入/导出按钮 | | [ ] | |
| 3.9.2 | 切换为中文环境 → 英文环境 → 刷新页面 | 表头、按钮、弹框标题、验证提示语全部为英文 | | [ ] | |
| 3.9.3 | 中文下打开新增弹框 → 切换至英文 | 弹框标题、表单 label 立即切换为英文(无需关闭弹框) | | [ ] | |
| 3.9.4 | 英文环境下切换回中文 | 所有文案恢复中文 | | [ ] | |
| 3.9.5 | 英文下执行新增/编辑/导入功能,成功/失败提示 | 提示语为英文,含义正确 | | [ ] | |
### 3.10 异常与边界
| 用例编号 | 操作步骤 | 预期结果 | 实际结果 | 通过 | 问题记录 |
| --- | --- | --- | --- | --- | --- |
| 3.10.1 | 后端 `/list` 接口返回 500 错误 | 列表显示空状态或错误提示用 Message 弹窗展示,页面不白屏 | | [ ] | |
| 3.10.2 | 后端返回字段缺失(如某行缺少 `device_category` | 缺失字段列显示为空,不抛 JS 异常 | | [ ] | |
| 3.10.3 | 备注字段输入 5000 字符并保存 | 提交成功,列表备注列可滚动展示(建议使用省略号截断) | | [ ] | |
| 3.10.4 | 新增/编辑弹框打开后按 ESC 键 | 弹框关闭,表单状态被重置 | | [ ] | |
| 3.10.5 | 列表横向宽度超过浏览器视口 | 操作列固定在右侧,表格支持横向滚动 | | [ ] | |
| 3.10.6 | DevTools Console 全程监控 | 无 `Vue warn`、无未捕获 Promise 异常、无 i18n key 缺失警告 | | [ ] | |
| 3.10.7 | 快速连续点击「新增」→ 「确定」 | 提交按钮 loading 状态阻止重复提交 | | [ ] | |
| 3.10.8 | 设备类别 API 请求失败时展开搜索下拉 | 下拉框为空,页面不崩溃;重试正常 | | [ ] | |
---
## 四、测试结果汇总
| 用例总数 | 通过 | 失败 | 阻塞 | 通过率 |
| --- | --- | --- | --- | --- |
| | | | | |
---
## 五、问题记录区
| 编号 | 用例编号 | 复现步骤 | 实际结果 | 严重程度 | 处理人 | 状态 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | | | | | | | |
| 2 | | | | | | | |
| 3 | | | | | | | |
| 4 | | | | | | | |
| 5 | | | | | | | |
---
## 六、测试结论
| 项目 | 结论 |
| --- | --- |
| 功能完整性 | ☐ 满足 ☐ 部分缺失 ☐ 不满足 |
| 性能表现 | ☐ 良好 ☐ 一般 ☐ 差 |
| 权限控制 | ☐ 正确 ☐ 存在漏洞 |
| 国际化 | ☐ 完整 ☐ 部分缺失 ☐ 缺失 |
| 导入导出 | ☐ 正常 ☐ 部分异常 ☐ 不可用 |
| 是否可发布 | ☐ 是 ☐ 否(请说明阻塞问题) |
测试人员签字__________________ 日期__________
---
*本测试流程文档为【异常不良管理】功能迁移版本专用,请独立归档保存。*

View File

@@ -46,7 +46,8 @@
"vue-router": "^3.6.2",
"vue-splitpane": "^1.0.6",
"vue-ueditor-wrap": "^2.5.6",
"vuex": "^3.6.2"
"vuex": "^3.6.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@d2-projects/vue-filename-injector": "^1.1.1",

48
pnpm-lock.yaml generated
View File

@@ -116,6 +116,9 @@ importers:
vuex:
specifier: ^3.6.2
version: 3.6.2(vue@2.7.16)
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@d2-projects/vue-filename-injector':
specifier: ^1.1.1
@@ -2220,6 +2223,10 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
collection-visit@1.0.0:
resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
engines: {node: '>=0.10.0'}
@@ -6228,6 +6235,10 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
sshpk@1.18.0:
resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==}
engines: {node: '>=0.10.0'}
@@ -7046,10 +7057,18 @@ packages:
engines: {node: '>= 8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
worker-farm@1.7.0:
resolution: {integrity: sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==}
@@ -7118,6 +7137,11 @@ packages:
engines: {node: '>=0.8'}
hasBin: true
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@3.0.0:
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
@@ -8180,9 +8204,7 @@ snapshots:
source-map: 0.6.1
string-length: 2.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@jest/source-map@24.9.0':
dependencies:
@@ -10016,6 +10038,8 @@ snapshots:
commander: 2.14.1
exit-on-epipe: 1.0.1
codepage@1.15.0: {}
collection-visit@1.0.0:
dependencies:
map-visit: 1.0.0
@@ -12437,9 +12461,7 @@ snapshots:
pretty-format: 24.9.0
throat: 4.1.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
jest-leak-detector@24.9.0:
dependencies:
@@ -14683,6 +14705,10 @@ snapshots:
dependencies:
frac: 1.1.2
ssf@0.11.2:
dependencies:
frac: 1.1.2
sshpk@1.18.0:
dependencies:
asn1: 0.2.6
@@ -15789,8 +15815,12 @@ snapshots:
dependencies:
isexe: 2.0.0
wmf@1.0.2: {}
word-wrap@1.2.5: {}
word@0.3.0: {}
worker-farm@1.7.0:
dependencies:
errno: 0.1.8
@@ -15851,6 +15881,16 @@ snapshots:
exit-on-epipe: 1.0.1
ssf: 0.10.3
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@3.0.0: {}
xmlchars@2.2.0: {}

View File

@@ -0,0 +1,19 @@
import { request } from '@/api/_service'
const BASE = 'production_configuration/device_model/device_category/'
function apiParams (method, data = {}) {
return {
method: `production_configuration_device_model_device_category_${method}`,
platform: 'background',
...data
}
}
export function getDeviceCategoryAll (data) {
return request({
url: BASE + 'all',
method: 'get',
params: apiParams('all', data)
})
}

View File

@@ -0,0 +1,76 @@
import { request } from '@/api/_service'
const BASE = 'production_configuration/product_model/product_ng_info/'
function apiParams (method, data = {}) {
return {
method: `production_configuration_product_model_product_ng_info_${method}`,
platform: 'background',
...data
}
}
export function getProductNgInfoAll (data) {
return request({
url: BASE + 'all',
method: 'get',
params: { ...data }
})
}
export function getProductNgInfoList (data) {
return request({
url: BASE + 'list',
method: 'get',
params: { ...data }
})
}
export function createProductNgInfo (data) {
return request({
url: BASE + 'create',
method: 'post',
data: apiParams('create', data)
})
}
export function editProductNgInfo (data) {
return request({
url: BASE + 'edit',
method: 'put',
data: apiParams('edit', data)
})
}
export function deleteProductNgInfo (data) {
return request({
url: BASE + 'delete',
method: 'delete',
data: apiParams('delete', data)
})
}
export function getImportTemplate (data) {
return request({
url: BASE + 'get_import_template',
method: 'post',
responseType: 'blob',
data: apiParams('get_import_template', data)
})
}
export function productNgInfoImport (data) {
return request({
url: BASE + 'import',
method: 'post',
data: apiParams('import', data)
})
}
export function productNgInfoExportTask (data) {
return request({
url: BASE + 'product_ng_info_export_task',
method: 'post',
data: apiParams('product_ng_info_export_task', data)
})
}

View File

@@ -248,6 +248,60 @@
"help": "Unit is used to maintain material measurement units (e.g. piece, box, kg)"
}
},
"product_model": {
"product_ng_info": {
"search": "Search",
"reset": "Reset",
"device_category": "Device Category",
"select_device_category": "Please select device category",
"query_type": "Query Type",
"select_query_type": "Please select query type",
"type_error": "Error",
"type_ng": "NG",
"ng_code": "Error/NG Code",
"enter_ng_code": "Please enter error/NG code",
"ng_name": "Error/NG Name",
"enter_ng_name": "Please enter error/NG name",
"type": "Category",
"select_type": "Please select category",
"exception_ng_category": "Error/NG Category",
"remark": "Remark",
"enter_remark": "Please enter remark",
"remark_length": "Length 1 to 100 characters",
"operation": "Action",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"batch_delete": "Batch Delete",
"import": "Import",
"export": "Export",
"operation_success": "Operation succeeded",
"add_exception_ng_category": "Add Error/NG Category",
"edit_exception_ng_category": "Edit Error/NG Category",
"cancel": "Cancel",
"confirm": "Confirm",
"prompt": "Prompt",
"confirm_delete": "Are you sure to perform this operation?",
"confirm_batch_delete": "Are you sure to delete the selected categories?",
"please_select_data": "Please select data first",
"validation_fail": "Validation failed",
"export_confirm_tip": "Are you sure to export the current query results?",
"operation_cancelled": "Operation cancelled",
"create_download_task_success": "Export task created successfully",
"import_exception_ng_data": "Import Error/NG Data",
"import_file_format_tip": "Please import file in template format",
"import_table": "Import Table",
"select_file": "Select File",
"download_template": "Download Template",
"preview": "Preview",
"import_template_name": "Error/NG data import template",
"file_not_exist": "File does not exist",
"upload_format_error": "Upload file format error",
"file_column_missing": "File column missing: {title}",
"please_import_data": "Please import data first",
"help": "Error/NG management is used to maintain equipment error types and product NG types"
}
},
"product_management": {
"product_list": {
"search": "Search",

View File

@@ -248,6 +248,60 @@
"help": "计量单位用于维护物料所用单位如个、箱、kg"
}
},
"product_model": {
"product_ng_info": {
"search": "查询",
"reset": "重置",
"device_category": "设备类别",
"select_device_category": "请选择设备类别",
"query_type": "查询类型",
"select_query_type": "请选择查询类型",
"type_error": "异常",
"type_ng": "不良",
"ng_code": "异常不良编码",
"enter_ng_code": "请输入异常不良编码",
"ng_name": "异常不良名称",
"enter_ng_name": "请输入异常不良名称",
"type": "类别",
"select_type": "请选择类别",
"exception_ng_category": "异常不良类别",
"remark": "备注",
"enter_remark": "请输入备注",
"remark_length": "长度在1到100个字符",
"operation": "操作",
"add": "新 增",
"edit": "编 辑",
"delete": "删 除",
"batch_delete": "批量删除",
"import": "导 入",
"export": "导 出",
"operation_success": "操作成功",
"add_exception_ng_category": "新增异常不良类别",
"edit_exception_ng_category": "编辑异常不良类别",
"cancel": "取消",
"confirm": "确定",
"prompt": "提示",
"confirm_delete": "确定要执行该操作吗?",
"confirm_batch_delete": "确定要删除所选异常不良类别吗?",
"please_select_data": "请先选择数据",
"validation_fail": "校验失败",
"export_confirm_tip": "确定要导出当前查询结果吗?",
"operation_cancelled": "操作已取消",
"create_download_task_success": "创建导出任务成功",
"import_exception_ng_data": "导入异常不良数据",
"import_file_format_tip": "请按模板格式导入文件",
"import_table": "导入列表",
"select_file": "选择文件",
"download_template": "下载模板",
"preview": "预览",
"import_template_name": "异常不良数据导入模版",
"file_not_exist": "文件不存在",
"upload_format_error": "上传文件格式错误",
"file_column_missing": "文件列缺失: {title}",
"please_import_data": "请先导入数据",
"help": "异常不良管理用于维护设备的异常种类和产品的不良种类信息"
}
},
"product_management": {
"product_list": {
"search": "查询",

View File

@@ -61,6 +61,12 @@ export default {
name: `${pre}material_model-material_unit`,
meta: { ...meta, cache: true, title: '计量单位' },
component: _import('production-master-data/material-model/material-unit')
},
{
path: 'product_model/product_ng_info',
name: `${pre}product_model-product_ng_info`,
meta: { ...meta, cache: true, title: '异常不良管理' },
component: _import('production-master-data/product-model/product-ng-info')
}
])('production_configuration-')
}

41
src/utils/file.js Normal file
View File

@@ -0,0 +1,41 @@
import * as XLSX from 'xlsx'
export function downloadRename (blob, fileType, filename) {
const typesMap = {
xlsx: 'application/vnd.ms-excel',
xls: 'application/vnd.ms-excel',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
csv: 'text/csv'
}
const type = typesMap[fileType] || ''
const newBlob = new Blob([blob], { type })
const href = URL.createObjectURL(newBlob)
const a = document.createElement('a')
a.href = href
a.download = filename + '.' + fileType
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(href)
}
export function readExcel (file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsBinaryString(file)
reader.onload = ev => {
try {
const data = ev.target.result
const workbook = XLSX.read(data, { type: 'binary' })
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
const list = XLSX.utils.sheet_to_json(firstSheet)
resolve(list)
} catch (e) {
reject(e)
}
}
reader.onerror = () => reject(new Error('文件读取失败'))
})
}

View File

@@ -0,0 +1,175 @@
<template>
<el-dialog
:visible.sync="visibleProxy"
:title="$t(key('import_exception_ng_data'))"
:width="width"
:close-on-click-modal="false"
@close="onClose"
>
<div>
<el-alert
:title="$t(key('import_file_format_tip'))"
:closable="false"
type="warning"
/>
<el-form ref="form" label-width="100px" style="margin-top:10px">
<el-form-item :label="$t(key('import_table'))">
<el-upload
action=""
:multiple="false"
:show-file-list="true"
:file-list="importFileList"
accept=".xls,.xlsx"
:http-request="onUpload"
>
<el-button size="mini" type="success">
{{ $t(key('select_file')) }}
</el-button>
<el-button
style="margin-left:10px"
size="mini"
type="primary"
:loading="downloadLoading"
@click="onDownloadTemplate"
>
{{ $t(key('download_template')) }}
</el-button>
</el-upload>
</el-form-item>
<el-form-item :label="$t(key('preview'))">
<el-table
v-loading="previewLoading"
:data="previewList"
height="350"
border
size="mini"
>
<el-table-column type="selection" width="55" />
<el-table-column :label="$t(key('device_category'))" prop="device_category_name" min-width="100" />
<el-table-column :label="$t(key('exception_ng_category'))" prop="type" min-width="100" />
<el-table-column :label="$t(key('ng_code'))" prop="number" min-width="100" />
<el-table-column :label="$t(key('ng_name'))" prop="explain" min-width="120" />
<el-table-column :label="$t(key('remark'))" prop="note" min-width="120" />
</el-table>
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button size="mini" @click="visibleProxy = false">
{{ $t(key('cancel')) }}
</el-button>
<el-button type="primary" size="mini" :loading="submitting" @click="onSubmit">
{{ $t(key('confirm')) }}
</el-button>
</div>
</el-dialog>
</template>
<script>
import { i18nMixin } from '@/composables/useI18n'
import { getImportTemplate, productNgInfoImport } from '@/api/production-master-data/product-ng-info'
import { downloadRename, readExcel } from '@/utils/file'
const EXPECTED_COLUMNS = ['设备类别', '异常不良类别', '异常不良编码', '异常不良名称', '备注']
export default {
name: 'ProductNgInfoImportDialog',
mixins: [i18nMixin('page.production_master_data.product_model.product_ng_info')],
props: {
visible: Boolean,
width: { type: String, default: '50%' }
},
data () {
return {
submitting: false,
downloadLoading: false,
previewLoading: false,
importFileList: [],
previewList: []
}
},
computed: {
visibleProxy: {
get () { return this.visible },
set (val) { this.$emit('update:visible', val) }
}
},
watch: {
visible (val) {
if (val) this.reset()
}
},
methods: {
reset () {
this.importFileList = []
this.previewList = []
this.submitting = false
this.previewLoading = false
},
onDownloadTemplate () {
this.downloadLoading = true
getImportTemplate({})
.then(res => { downloadRename(res, 'xlsx', this.key('import_template_name')) })
.finally(() => { this.downloadLoading = false })
},
onUpload (e) {
const file = e.file
if (!file) {
this.$message.error(this.$t(this.key('file_not_exist')))
return
}
const lower = file.name.toLowerCase()
if (!/\.(xls|xlsx)$/.test(lower)) {
this.$message.error(this.$t(this.key('upload_format_error')))
return
}
this.importFileList = [file]
this.previewLoading = true
readExcel(file)
.then(res => {
if (!res || !res.length) return
const firstRow = res[0]
for (const col of EXPECTED_COLUMNS) {
if (!Object.prototype.hasOwnProperty.call(firstRow, col)) {
this.$message.error(this.$t(this.key('file_column_missing'), { title: col }))
return
}
}
this.previewList = res.map(row => ({
device_category_name: row['设备类别'],
type: row['异常不良类别'],
number: row['异常不良编码'],
explain: row['异常不良名称'],
note: row['备注']
}))
})
.catch(err => {
this.$message.error(err.message || this.$t(this.key('upload_format_error')))
})
.finally(() => { this.previewLoading = false })
},
onSubmit () {
if (!this.previewList.length) {
this.$message.error(this.$t(this.key('please_import_data')))
return
}
this.submitting = true
productNgInfoImport({
import_data: JSON.stringify(this.previewList)
})
.then(() => {
this.$message.success(this.$t(this.key('operation_success')))
this.visibleProxy = false
this.$emit('saved')
})
.finally(() => { this.submitting = false })
},
onClose () {
this.reset()
}
}
}
</script>

View File

@@ -0,0 +1,459 @@
<template>
<d2-container>
<template #header>
<div class="search-bar">
<el-form :inline="true" size="mini">
<el-form-item :label="$t(key('device_category'))">
<el-select
v-model="search.device_category_id"
:placeholder="$t(key('select_device_category'))"
clearable
filterable
style="width:200px"
@focus="loadDeviceCategories"
>
<el-option
v-for="item in deviceCategoryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t(key('query_type'))">
<el-select
v-model="search.type"
:placeholder="$t(key('select_query_type'))"
clearable
style="width:200px"
>
<el-option :label="$t(key('type_error'))" value="ERR" />
<el-option :label="$t(key('type_ng'))" value="NG" />
</el-select>
</el-form-item>
<el-form-item :label="$t(key('ng_code'))">
<el-input
v-model="search.number"
:placeholder="$t(key('enter_ng_code'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item :label="$t(key('ng_name'))">
<el-input
v-model="search.explain"
:placeholder="$t(key('enter_ng_name'))"
clearable
style="width:200px"
@keyup.enter.native="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="onSearch">
{{ $t(key('search')) }}
</el-button>
<el-button icon="el-icon-refresh" @click="onReset">
{{ $t(key('reset')) }}
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<page-table
ref="pageTable"
:columns="columns"
:data="tableData"
:loading="loading"
:toolbar-buttons="toolbarButtons"
:row-buttons="rowButtons"
:pagination="pagination"
help-url="/help/product-ng-info"
:help-text="$t(ckey('help'))"
auto-height
@page-change="onPageChange"
@selection-change="onSelect"
/>
<page-dialog-form
ref="dialogForm"
:visible.sync="dialogVisible"
:title="dialogTitle"
:width="'35%'"
:form-cols="formCols"
:form-data="formData"
:rules="rules"
:label-width="'120px'"
:submitting="submitting"
:confirm-text="key('confirm')"
:cancel-text="key('cancel')"
@submit="onDialogSubmit"
@close="onDialogClose"
/>
<product-ng-info-import-dialog
:visible.sync="importVisible"
@saved="onImportSaved"
/>
</d2-container>
</template>
<script>
import { useTableColumns } from '@/composables/useTableColumns'
import { useTableButtons } from '@/composables/useTableButtons'
import { i18nMixin } from '@/composables/useI18n'
import { confirmMixin } from '@/composables/useConfirmHandle'
import {
getProductNgInfoList,
createProductNgInfo,
editProductNgInfo,
deleteProductNgInfo,
productNgInfoExportTask
} from '@/api/production-master-data/product-ng-info'
import { getDeviceCategoryAll } from '@/api/production-master-data/device-category'
import PageTable from '@/components/page-table'
import PageDialogForm from '@/components/page-dialog-form'
import ProductNgInfoImportDialog from './components/ImportDialog/index.vue'
export default {
name: 'production-master-data-product-ng-info',
components: { PageTable, PageDialogForm, ProductNgInfoImportDialog },
mixins: [i18nMixin('page.production_master_data.product_model.product_ng_info'), confirmMixin],
data () {
return {
loading: false,
submitting: false,
tableData: [],
selectedRows: [],
dialogVisible: false,
dialogTitle: '',
editId: null,
handleType: 'create',
importVisible: false,
search: { device_category_id: '', type: '', number: '', explain: '' },
pagination: { current: 1, size: 10, total: 0 },
deviceCategoryOptions: [],
formData: { device_category_id: '', type: '', number: '', explain: '', note: '' },
rules: {
device_category_id: [
{ required: true, message: this.key('select_device_category'), trigger: 'change' }
],
type: [
{ required: true, message: this.key('select_type'), trigger: 'change' }
],
number: [
{ required: true, message: this.key('enter_ng_code'), trigger: 'blur' },
{ min: 1, max: 100, message: this.key('remark_length'), trigger: 'blur' }
],
explain: [
{ required: true, message: this.key('enter_ng_name'), trigger: 'blur' },
{ min: 1, max: 100, message: this.key('remark_length'), trigger: 'blur' }
]
},
formCols: [],
columns: [],
toolbarButtons: [],
rowButtons: []
}
},
created () {
this.formCols = [
[
{
type: 'select',
prop: 'device_category_id',
label: this.key('device_category'),
placeholder: this.key('select_device_category'),
clearable: true,
style: { width: '90%' },
options: [],
keys: { label: 'name', value: 'id' },
onFocus: this.onFormDeviceCategoryFocus
}
],
[
{
type: 'select',
prop: 'type',
label: this.key('type'),
placeholder: this.key('select_type'),
clearable: true,
style: { width: '90%' },
options: [
{ name: this.key('type_error'), code: 'ERR' },
{ name: this.key('type_ng'), code: 'NG' }
],
keys: { label: 'name', value: 'code' }
}
],
[
{
type: 'input',
prop: 'number',
label: this.key('ng_code'),
placeholder: this.key('enter_ng_code'),
clearable: true,
style: { width: '90%' }
}
],
[
{
type: 'input',
prop: 'explain',
label: this.key('ng_name'),
placeholder: this.key('enter_ng_name'),
clearable: true,
style: { width: '90%' }
}
],
[
{
type: 'input',
inputType: 'textarea',
prop: 'note',
label: this.key('remark'),
placeholder: this.key('enter_remark'),
autosize: { minRows: 2, maxRows: 6 },
clearable: true,
style: { width: '90%' }
}
]
]
this.columns = useTableColumns([
{ type: 'selection', width: 55 },
{ prop: 'device_category_name', label: this.key('device_category'), minWidth: 140 },
{ prop: 'type', label: this.key('exception_ng_category'), minWidth: 120 },
{ prop: 'number', label: this.key('ng_code'), minWidth: 140 },
{ prop: 'explain', label: this.key('ng_name'), minWidth: 140 },
{ prop: 'note', label: this.key('remark'), minWidth: 200 },
{ prop: '_actions', label: this.key('operation'), width: 180, fixed: 'right' }
])
const btns = useTableButtons({
toolbar: [
{
key: 'add',
label: this.key('add'),
icon: 'el-icon-plus',
type: 'primary',
auth: '/production_configuration/product_model/product_ng_info/create',
onClick: this.openAdd
},
{
key: 'batch-delete',
label: this.key('batch_delete'),
icon: 'el-icon-delete',
type: 'danger',
auth: '/production_configuration/product_model/product_ng_info/delete',
onClick: this.handleBatchDelete
},
{
key: 'import',
label: this.key('import'),
icon: 'el-icon-upload2',
color: '#3CBA92',
auth: '/production_configuration/product_model/product_ng_info/create',
onClick: this.openImport
},
{
key: 'export',
label: this.key('export'),
icon: 'el-icon-download',
color: '#35C2EE',
auth: '/production_configuration/product_model/product_ng_info/export',
onClick: this.handleExport
}
],
row: [
{
key: 'edit',
label: this.key('edit'),
icon: 'el-icon-edit',
auth: '/production_configuration/product_model/product_ng_info/edit',
onClick: this.openEdit
},
{
key: 'delete',
label: this.key('delete'),
icon: 'el-icon-delete',
color: 'danger',
auth: '/production_configuration/product_model/product_ng_info/delete',
onClick: this.handleDelete
}
]
}, this.$permission)
this.toolbarButtons = btns.toolbarButtons
this.rowButtons = btns.rowButtons
this.fetchData()
},
methods: {
async fetchData () {
this.loading = true
try {
const res = await getProductNgInfoList({
...this.search,
page_no: this.pagination.current,
page_size: this.pagination.size
})
const data = Array.isArray(res) ? res : (res.data || {})
const list = Array.isArray(data) ? data : (data.data || [])
const total = Array.isArray(data) ? data.length : (data.count || 0)
this.tableData = list
this.pagination.total = total
} finally {
this.loading = false
}
},
onSearch () {
this.pagination.current = 1
this.fetchData()
},
onReset () {
this.search = { device_category_id: '', type: '', number: '', explain: '' }
this.pagination.current = 1
this.fetchData()
},
onPageChange (page) {
this.pagination.current = page.current
this.pagination.size = page.size
this.fetchData()
},
onSelect (rows) {
this.selectedRows = rows
},
async loadDeviceCategories () {
if (this.deviceCategoryOptions.length) return
try {
const res = await getDeviceCategoryAll({})
const list = Array.isArray(res) ? res : (res.data || [])
this.deviceCategoryOptions = Array.isArray(list) ? list : []
this.searchFormDeviceCategoryUpdate()
} catch { /* ignore */ }
},
searchFormDeviceCategoryUpdate () {
const catCol = this.formCols[0] && this.formCols[0][0]
if (catCol && catCol.type === 'select') {
catCol.options = this.deviceCategoryOptions
}
},
onFormDeviceCategoryFocus () {
this.loadDeviceCategories()
},
resetForm () {
this.formData = { device_category_id: '', type: '', number: '', explain: '', note: '' }
this.editId = null
this.searchFormDeviceCategoryUpdate()
},
openAdd () {
this.handleType = 'create'
this.dialogTitle = this.key('add_exception_ng_category')
this.loadDeviceCategories()
this.$nextTick(() => {
this.$refs.dialogForm && this.$refs.dialogForm.reset()
this.resetForm()
this.dialogVisible = true
})
},
openEdit (row) {
this.handleType = 'edit'
this.dialogTitle = this.key('edit_exception_ng_category')
this.editId = row.id
this.loadDeviceCategories()
this.formData = {
device_category_id: row.device_category_id || '',
type: row.type || '',
number: row.number || '',
explain: row.explain || '',
note: row.note || ''
}
this.dialogVisible = true
},
async onDialogSubmit () {
this.submitting = true
try {
if (this.handleType === 'create') {
await createProductNgInfo(this.formData)
} else {
await editProductNgInfo({ ...this.formData, id: this.editId })
}
this.$message.success(this.$t(this.key('operation_success')))
this.dialogVisible = false
this.fetchData()
} finally {
this.submitting = false
}
},
onDialogClose () {
this.resetForm()
},
async handleDelete (row) {
await this.$confirmAction(
{
message: this.key('confirm_delete'),
title: this.key('prompt')
},
() => deleteProductNgInfo({ id: [row.id] })
)
this.$message.success(this.$t(this.key('operation_success')))
this.pagination.current = Math.min(
this.pagination.current,
Math.ceil((this.pagination.total - 1) / this.pagination.size) || 1
)
this.fetchData()
},
async handleBatchDelete () {
if (!this.selectedRows.length) {
this.$message.error(this.$t(this.key('please_select_data')))
return
}
const ids = this.selectedRows.map(r => r.id)
await this.$confirmAction(
{
message: this.key('confirm_batch_delete'),
title: this.key('prompt')
},
() => deleteProductNgInfo({ id: ids })
)
this.$message.success(this.$t(this.key('operation_success')))
this.fetchData()
},
openImport () {
this.importVisible = true
},
onImportSaved () {
this.fetchData()
},
async handleExport () {
try {
await this.$confirm(
this.$t(this.key('export_confirm_tip')),
this.$t(this.key('prompt')),
{ confirmButtonText: this.$t(this.key('confirm')), cancelButtonText: this.$t(this.key('cancel')), type: 'warning', center: true }
)
} catch {
this.$message({ type: 'info', message: this.$t(this.key('operation_cancelled')) })
return
}
try {
await productNgInfoExportTask({
...this.search,
action: 'download'
})
this.$message.success(this.$t(this.key('create_download_task_success')))
this.$router.push({ name: 'task' })
} catch { /* handled by interceptor */ }
}
}
}
</script>
<style scoped>
.search-bar {
padding: 10px 0;
}
/deep/ .el-form-item--mini.el-form-item {
margin-bottom: 4px;
}
</style>