Compare commits

123 Commits
Author SHA1 Message Date
焦龙言 80a71b48ce 添加前后端筛选导出excel按钮权限 2026-10-10 11:39:58 +08:00
yely 3330592fbe fix(layout): 修复侧边栏嵌套子菜单折叠指示箭头错位问题 2026-10-09 21:26:22 +08:00
yely a8af1f542b 为基础资料维护页面增加导入导出 2026-10-09 21:13:43 +08:00
yely a62803ccf0 停止跟踪 Python 构建产物并忽略本地环境 2026-10-09 21:13:43 +08:00
焦龙言 ae662a4941 修正公司名称 2026-10-09 13:28:02 +08:00
焦龙言 d00c725320 更新dockercompose模版文件默认绑定地址为0.0.0.0 2026-10-09 09:38:55 +08:00
焦龙言 3dc2a308eb 开启测试模式加密传输 2026-10-09 08:52:25 +08:00
焦龙言 f3393d78fb 停止跟踪本地工具和日志并完善生产迁移交付 2026-10-08 17:40:30 +08:00
焦龙言 56e646bcc1 更新登录页及优化相关功能 2026-10-08 14:13:05 +08:00
焦龙言 9a14b692e3 更新登录页及优化相关功能 2026-10-08 13:57:35 +08:00
焦龙言 ecb254cb54 更新启动文件 2026-09-29 09:20:31 +08:00
焦龙言 a539b4eb71 调整部分前端布局和后端增删改逻辑 2026-09-28 18:29:10 +08:00
焦龙言 17d991a74a 调整部分前端布局和后端增删改逻辑 2026-09-26 23:11:05 +08:00
焦龙言 977e7f996e 文档:补充测试批次操作说明 2026-09-24 09:45:47 +08:00
焦龙言 d15b8e2f6c 移动端:增加测试批次模式隔离 2026-09-24 09:45:08 +08:00
焦龙言 886a4479c4 修复:拒绝纳入不存在的数据 2026-09-24 09:22:15 +08:00
焦龙言 02b87c9399 测试:同步移除物理删除权限契约 2026-09-24 08:54:43 +08:00
焦龙言 ae8c4c8c38 权限:移除业务物理删除入口 2026-09-24 08:13:16 +08:00
焦龙言 4706c4afeb 测试:覆盖批次授权并发状态 2026-09-23 17:45:54 +08:00
焦龙言 a5cc91a92a 修复:加固批次管理异步状态 2026-09-23 17:27:33 +08:00
焦龙言 8221a5162d 前端:增加批次管理与旧数据纳入 2026-09-23 16:54:22 +08:00
焦龙言 a7dc1a2a1e 修复:保障移动端批次模式可达 2026-09-23 16:24:03 +08:00
焦龙言 9ca95125b8 修复:完善测试数据模式回退 2026-09-23 16:10:33 +08:00
焦龙言 169d9889f3 前端:增加测试数据模式切换 2026-09-23 15:37:33 +08:00
焦龙言 1aaed57825 安全:增加测试批次原子清理 2026-09-23 14:52:07 +08:00
焦龙言 b11c457e61 修复:完善旧数据纳入事务读取 2026-09-23 13:48:49 +08:00
焦龙言 91cf862248 功能:增加旧测试数据闭包纳入 2026-09-23 13:15:59 +08:00
焦龙言 c97f6ed3bd 安全:隔离报表选项扫码与首页数据 2026-09-23 12:37:30 +08:00
焦龙言 5a76fe7521 安全:补齐业务批次归属校验 2026-09-23 11:49:35 +08:00
焦龙言 97b4dc8508 修复:补齐测试批次恢复锁定 2026-09-23 09:01:50 +08:00
焦龙言 f5df2150d8 功能:隔离五类测试业务链路 2026-09-23 08:27:14 +08:00
焦龙言 43fe17cc70 修复:明确旧删除接口固定拒绝 2026-09-22 19:00:38 +08:00
焦龙言 46eff756de 格式:整理主数据批次代码 2026-09-22 18:32:53 +08:00
焦龙言 b6f606d9f2 安全:隔离主数据并封闭单条删除 2026-09-22 18:16:30 +08:00
焦龙言 af5428d0e3 功能:增加测试批次生命周期管理 2026-09-22 16:19:32 +08:00
焦龙言 c82f5e42dd 修复:统一超长批次标识错误 2026-09-22 15:46:00 +08:00
焦龙言 e396fff790 修复:严格校验测试批次请求头 2026-09-22 15:30:28 +08:00
焦龙言 763ea1efcc 安全:增加测试批次上下文硬校验 2026-09-22 15:11:53 +08:00
焦龙言 17bfa2c5fe 数据:增加测试批次模型与迁移契约 2026-09-22 14:37:31 +08:00
焦龙言 40c56c6b33 计划:修正测试批次实施前置契约 2026-09-22 13:39:18 +08:00
焦龙言 9da9cd0a59 计划:制定测试批次隔离与清理实施方案 2026-09-22 13:25:47 +08:00
焦龙言 e690b52007 设计:新增测试数据批次清理方案 2026-09-22 12:05:52 +08:00
焦龙言 69c13284d8 计划:实施 PDA 业务操作功能 2026-09-20 17:15:47 +08:00
焦龙言 f2629ccf14 设计:补全 PDA 业务操作功能 2026-09-20 16:24:07 +08:00
焦龙言 3edba99d2d 调整部分前端布局和后端增删改逻辑 2026-09-20 10:38:10 +08:00
焦龙言 acddc8a237 同步业务菜单现有图标配置 2026-07-28 09:39:54 +08:00
焦龙言 b4822fef12 修复业务菜单图标并固化菌种编码模糊查询 2026-07-28 08:43:31 +08:00
焦龙言 9ef214f040 测试:封闭菜单补丁原子性回归 2026-07-27 21:41:12 +08:00
焦龙言 b608a166c3 测试:加固菜单补丁门禁契约 2026-07-27 21:17:28 +08:00
焦龙言 a1613f8780 迁移:新增业务菜单层级补丁 2026-07-27 21:02:48 +08:00
焦龙言 db5755586d 测试:加强一级菜单稳定字段契约 2026-07-27 20:41:20 +08:00
焦龙言 619f3d5d63 调整:提升业务菜单为一级目录 2026-07-27 20:30:18 +08:00
焦龙言 9a9c57578d 计划:实施业务菜单一级展示 2026-07-27 20:19:53 +08:00
焦龙言 23b3cc3821 设计:定义业务菜单一级展示 2026-07-27 20:05:26 +08:00
焦龙言 8a5a8d976e 文档:记录本地数据库执行结果 2026-07-27 19:45:48 +08:00
焦龙言 64ed087f5c 构建:同步前端业务依赖 2026-07-27 19:24:30 +08:00
焦龙言 9a32f1a897 修复:保障菜单补丁原子执行 2026-07-27 19:18:23 +08:00
焦龙言 b6f3b1e1f0 修复:限定幂等键精确业务域 2026-07-27 18:54:50 +08:00
焦龙言 4a321098f8 修复:收紧托盘业务权限与数据契约 2026-07-27 17:40:24 +08:00
焦龙言 9b1a807098 文档:补充托盘业务部署说明 2026-07-27 16:09:58 +08:00
焦龙言 684dd451bb 格式:统一使用业务代码 Ruff 格式 2026-07-27 15:59:09 +08:00
焦龙言 37c94b6713 测试:限定前端权限门禁接收者 2026-07-27 15:45:45 +08:00
焦龙言 9bdafd3bac 测试:收紧前端权限与组件契约 2026-07-27 15:36:30 +08:00
焦龙言 6f751486e4 测试:拒绝非约定前端权限门禁 2026-07-27 15:11:53 +08:00
焦龙言 33d73ca0ef 测试:校验菜单接口权限一致性 2026-07-27 14:59:36 +08:00
焦龙言 9b5f479f0d 修复:校验 MySQL 菜单固定ID冲突 2026-07-27 14:24:30 +08:00
焦龙言 3d1b64971f 测试:加强 PostgreSQL 时间类型契约 2026-07-27 13:42:34 +08:00
焦龙言 7a786800bd 修复:保留 PostgreSQL 时间微秒精度 2026-07-27 13:33:08 +08:00
焦龙言 b858340890 功能:新增托盘业务数据库脚本 2026-07-27 13:12:19 +08:00
焦龙言 626e15fe89 修复:移除发货报表无效状态筛选 2026-07-27 12:25:24 +08:00
焦龙言 2ab075f095 修复:补全托盘追溯位置回退 2026-07-27 11:50:37 +08:00
焦龙言 8361c11c4c 功能:新增托盘业务报表页面 2026-07-27 11:14:36 +08:00
焦龙言 85faef213e 修复:按发货状态读取当前余量 2026-07-27 10:50:25 +08:00
焦龙言 4340d03a70 修复:完善装填状态与确认权限 2026-07-27 10:12:05 +08:00
焦龙言 a223971a3c 修复:完善业务页面异步与扫码状态 2026-07-27 09:39:53 +08:00
焦龙言 e4477f321c 修复:补充发货草稿当前余量 2026-07-27 09:05:04 +08:00
焦龙言 d5da207206 功能:新增托盘业务操作页面 2026-07-27 08:31:31 +08:00
焦龙言 e5006c6e43 修复:返回使用后的余量与状态 2026-07-27 06:27:29 +08:00
焦龙言 d0fa20afe8 修复:完善基础资料页异步与提交保护 2026-07-27 06:07:17 +08:00
焦龙言 2d2b3cf6c6 修复:防止异步响应覆盖与打印资源泄漏 2026-07-27 05:44:20 +08:00
焦龙言 137b2934f7 功能:新增业务基础资料页面 2026-07-27 05:27:20 +08:00
焦龙言 246e69f5c8 补强主数据可删除性查询测试 2026-07-27 04:51:29 +08:00
焦龙言 33f29df40e 补充主数据删除能力契约 2026-07-27 04:34:10 +08:00
焦龙言 d1f0b9d48b 修复:增强扫码与接口契约校验 2026-07-27 04:00:40 +08:00
焦龙言 1f23d92ffb 功能:新增业务接口与扫码组件 2026-07-27 03:17:52 +08:00
焦龙言 c08b5a5d3d 测试:补强业务权限契约覆盖范围 2026-07-27 02:42:24 +08:00
焦龙言 dc190f1162 测试:固化业务接口权限契约 2026-07-27 02:25:39 +08:00
焦龙言 6b97bb9f49 修复:完善报表查询校验 2026-07-27 02:04:45 +08:00
焦龙言 dfd720359a 功能:新增托盘业务报表接口 2026-07-27 01:27:16 +08:00
焦龙言 c9180b3832 修复:统一退回锁序并避免提交后访问 2026-07-27 00:49:29 +08:00
焦龙言 80c2288122 修复:支持已确认退回的并发幂等 2026-07-27 00:23:04 +08:00
焦龙言 12260fdf83 修复:完善退回并发幂等与信息隐藏 2026-07-27 00:08:57 +08:00
焦龙言 e58e110dd1 功能:实现托盘两阶段退回 2026-07-26 23:33:01 +08:00
焦龙言 cda87a4d65 修复:消除使用幂等缺失键间隙锁 2026-07-26 22:34:46 +08:00
焦龙言 33228faffc 修复:补强使用并发幂等复核 2026-07-26 21:43:55 +08:00
焦龙言 bc774c74e6 功能:实现客户现场使用记录 2026-07-26 21:14:01 +08:00
焦龙言 3076194328 修复:统一发货路由错误隐藏 2026-07-26 20:26:59 +08:00
焦龙言 dc28cf6df0 修复:强化发货权限与幂等一致性 2026-07-26 20:06:35 +08:00
焦龙言 7317631a13 功能:实现多托盘发货出库 2026-07-26 19:11:52 +08:00
焦龙言 66890ca63c 修复:装填前复核仓库权限 2026-07-26 18:16:34 +08:00
焦龙言 f6e994d95f 修复:强化装填事务一致性 2026-07-26 17:56:58 +08:00
焦龙言 18abe861f9 功能:实现单托盘装填 2026-07-26 17:09:17 +08:00
焦龙言 2e616ab6e0 修复:收紧入库数据范围校验 2026-07-26 16:14:27 +08:00
焦龙言 6f22cf0282 功能:实现空托盘首次入库 2026-07-26 15:19:25 +08:00
焦龙言 317a0fbf68 修复:避免首事件间隙锁死锁 2026-07-26 14:24:55 +08:00
焦龙言 6c2a5fa344 修复:强化库存事务并发与日期边界 2026-07-26 13:54:42 +08:00
焦龙言 85905d85ca 功能:新增托盘库存事务基础 2026-07-26 13:18:56 +08:00
焦龙言 2e35da13cc 修复:限制托盘分页大小 2026-07-26 12:51:32 +08:00
焦龙言 aba8a96cb8 修复:消除托盘与用户分配查询竞态 2026-07-26 12:36:00 +08:00
焦龙言 6851499be2 功能:新增托盘二维码与用户分配 2026-07-26 11:48:22 +08:00
焦龙言 f3eda856b0 功能:新增业务基础资料接口 2026-07-26 10:41:14 +08:00
焦龙言 f5d47ad2bb 测试:强化数据权限查询谓词校验 2026-07-26 10:02:45 +08:00
焦龙言 83a834f87e 功能:新增仓库与客户现场数据权限 2026-07-26 09:42:12 +08:00
焦龙言 36e60af46b 修复:收紧托盘业务包数校验 2026-07-24 21:29:20 +08:00
焦龙言 f747a80a2a 功能:新增托盘业务接口模型 2026-07-24 21:07:43 +08:00
焦龙言 eab725a541 修复:校正业务模型审计默认值 2026-07-24 20:45:10 +08:00
焦龙言 f814f916a5 测试:强化业务模型元数据契约 2026-07-24 20:25:02 +08:00
焦龙言 f6e9566f70 功能:新增托盘业务数据模型 2026-07-24 20:09:36 +08:00
焦龙言 cb3bf5bf4c 修复:校验托盘状态类型 2026-07-24 19:53:04 +08:00
焦龙言 bab09f4eb3 功能:新增托盘状态机 2026-07-24 19:37:46 +08:00
焦龙言 5209911dd8 重构:移除 AI 管理模块 2026-07-24 19:06:55 +08:00
焦龙言 a509dada79 文档:新增托盘业务实施计划 2026-07-24 18:00:27 +08:00
焦龙言 ce6846f920 文档:新增托盘业务系统设计 2026-07-24 16:58:26 +08:00
346 changed files with 74280 additions and 7762 deletions
+23
View File
@@ -0,0 +1,23 @@
# 复制为根目录 .env 后再修改;根目录 .env 已加入 Git 忽略。
# 对外绑定地址和端口(默认仅绑定本机;局域网访问时将 COMPOSE_BIND_IP 改为 0.0.0.0)
COMPOSE_BIND_IP=0.0.0.0
FRONTEND_HOST_PORT=8080
BACKEND_HOST_PORT=9099
MYSQL_HOST_PORT=13307
# MySQL
MYSQL_DATABASE=shierfeng-fastapi
MYSQL_ROOT_PASSWORD=change-this-root-password
DB_USERNAME=shierfeng
DB_PASSWORD=change-this-app-password
# Redis 与登录令牌
REDIS_DATABASE=2
JWT_SECRET_KEY=replace-with-a-long-random-secret
# 本地 Compose 默认关闭传输层加密;正式环境启用时必须另行注入正式密钥。
TRANSPORT_CRYPTO_ENABLED=false
TRANSPORT_CRYPTO_MODE=off
TZ=Asia/Shanghai
+25
View File
@@ -0,0 +1,25 @@
/.env
# 本机工具、工作记录和运行日志:保留本地文件,不进入版本库。
/.idea/
/.pnpm-store/
/.superpowers/
/docs/
/logs/
/shierfeng-fastapi-backend/logs/
# Python 运行及检查缓存。
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
# 本地依赖;前端和 APP 构建产物继续由各自 .gitignore 管理。
node_modules/
/.venv/
/venv/
/shierfeng/
# Python 安装与构建产物,源码以 cli/ 为准。
*.egg-info/
/shierfeng-fastapi-backend/build/
-10
View File
@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.10 (ShiErFeng)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-25
View File
@@ -1,25 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
<option value="N803" />
<option value="N802" />
<option value="N801" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="list.to_numpy" />
<option value="str.__or__" />
<option value="str.like" />
<option value="nacos.client" />
</list>
</option>
</inspection_tool>
</profile>
</component>
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.11 (TavernProgram)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (ShiErFeng)" project-jdk-type="Python SDK" />
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ShiErFeng.iml" filepath="$PROJECT_DIR$/.idea/ShiErFeng.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+2
View File
@@ -12,6 +12,7 @@
2.新增移动端模块 (#73)。
3.新增多worker运行支持 (#76)。
4.应用新增演示模式 (#78)。
5.新增托盘流转业务,覆盖基础资料、仓库入库至退回仓库的全流程、只读报表及 Android PDA 浏览器扫码操作。
### BUG修复
@@ -23,6 +24,7 @@
### 代码重构
1.移除对python3.9的支持 (#67)。
2.移除AI管理模块。
### 代码优化
+10 -17
View File
@@ -1,21 +1,14 @@
MIT License
# ShiErFeng 专有版权声明
Copyright (c) 2024 insistence
Copyright (c) 2024-2026 Sylvan International Biotechnology Co., Ltd.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
除 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md) 或具体文件另有说明的
第三方代码外,本仓库中的源代码、业务逻辑、界面设计、文档及其他原创内容
均为施尔丰国际生物科技有限公司的专有内容。
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
未经版权持有人书面许可,不得复制、修改、分发、再许可或将上述原创内容
用于其他项目或商业用途。
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
本文件不改变第三方代码所适用的原始许可证。RuoYi 来源代码的版权声明和
MIT License 请参阅 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md)。
+46 -19
View File
@@ -8,7 +8,7 @@
* 前端采用Vue3、Element Plus。
* 移动端采用uni-app、Vue3、Vite,内置tailwindcss。
* 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。
* 后端采用FastAPI、sqlalchemy、MySQL、Redis、OAuth2 & Jwt。
* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。
* 支持加载动态权限菜单,多方式轻松权限控制。
* 纯Python版本:
@@ -35,12 +35,32 @@
16. 在线构建器:拖动表单元素生成相应的HTML代码。
17. 系统接口:根据业务代码自动生成相关的api接口文档。
18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。
19. AI管理:提供AI模型管理和AI对话功能。
## 托盘流转业务
本轮新增托盘全生命周期流转管理:
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
* Android PDA 可通过基于 uni-app 的 H5 页面使用,并支持 HID 扫码枪扫码后回车提交。
本轮交付包含 PDA H5 页面,但暂不打包 APK 或其他原生安装包,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./shierfeng-fastapi-backend/sql/pallet-workflow-deployment.md)。
测试数据批次的创建、授权、历史数据纳入和清理流程请阅读[测试数据批次操作说明](./shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。
数据库初始化与升级入口请阅读 [SQL 使用说明](./shierfeng-fastapi-backend/sql/README.md)。新空库直接使用已包含隧道、物料日期、收货和 FIFO 结构的初始化 SQL;已有生产库不得运行全量初始化,请按[生产 Compose 增量迁移说明](./shierfeng-fastapi-backend/sql/production-compose-material-flow-migration-20261008.md)核对适用条件后执行。
### Git 跟踪范围
根目录 `.idea/`、`.pnpm-store/`、`.superpowers/`、`docs/`、`logs/` 及后端 `logs/` 仅保留本地,不再提交;Python 缓存及本地依赖同样忽略。必要的业务部署与数据库迁移说明随后端 `sql/` 目录维护。代码、迁移 SQL、测试和必要部署配置仍正常跟踪。
其他已有克隆首次拉取本次清理提交前,应先备份这些目录中需要保留的文件:Git 可能删除此前被跟踪的副本。此次清理不重写历史,旧提交中的文件仍然存在。
## 在线体验
* *账号:admin*
* *密码:admin123*
* *密码:请通过授权渠道获取,不在仓库中记录*
* 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
## 项目开发及发布相关
@@ -100,17 +120,15 @@ pnpm dev:mp-weixin
# 进入后端目录
cd shierfeng-fastapi-backend
# 如果使用的是MySQL数据库,请执行以下命令安装项目依赖环境
# 安装项目依赖环境(统一使用 MySQL)
pip3 install -r requirements.txt
# 如果使用的是PostgreSQL数据库,请执行以下命令安装项目依赖环境
pip3 install -r requirements-pg.txt
# 配置环境
在.env.dev文件中配置开发环境的数据库和redis
# 运行sql文件
1.新建数据库shierfeng-fastapi(默认,可修改)
2.如果使用的是MySQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi.sql;如果使用的是PostgreSQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi-pg.sql
# 运行 SQL 文件
1. 新建数据库 shierfeng-fastapi(默认,可修改)
2. 使用命令或数据库连接工具运行 sql 文件夹下的 shierfeng-fastapi.sql
# 运行后端
shierfeng app run --env=dev
@@ -121,12 +139,12 @@ shierfeng app run --env=dev
#### 访问
```bash
# 默认账号密码
# 本地账号
账号:admin
密码:admin123
密码:请在目标环境安全配置中设置后手工输入,不在仓库中记录
# 浏览器访问
地址:http://localhost:80
地址:http://localhost:8080
```
### 发布
@@ -153,16 +171,25 @@ shierfeng app run --env=prod
### Docker Compose部署方式
> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化
> ⚠️ **说明:** 根目录 Compose 使用 MySQL 和 Redis 命名卷持久化数据。初始化 SQL 只会在 MySQL 数据目录首次为空时执行。
#### MySQL版本
#### MySQL 全栈版本
```bash
docker compose -f shierfeng-fastapi-test/docker-compose.test.my.yml up -d --build
# 首次使用时创建本地配置文件
cp .env.docker.example .env
# 编辑 .env,至少修改 MYSQL_ROOT_PASSWORD、DB_PASSWORD 和 JWT_SECRET_KEY
# 启动前只做静态配置校验
docker compose config --quiet
# 构建并启动前端、后端、MySQL、Redis
docker compose up -d --build
# 查看服务状态
docker compose ps
```
#### PostgreSQL版本
浏览器访问:http://localhost:8080;后端直连接口为 `http://localhost:9099`;宿主机连接 MySQL 时使用 `127.0.0.1:13307`。需要局域网访问时,将根目录 `.env` 中的 `COMPOSE_BIND_IP` 改为 `0.0.0.0`,再使用宿主机局域网地址访问前端和后端端口。
```bash
docker compose -f shierfeng-fastapi-test/docker-compose.test.pg.yml up -d --build
```
已有数据库不能通过全量初始化脚本升级。请先备份并核对目标库,再按照 `shierfeng-fastapi-backend/sql/patches/` 下对应的 MySQL 增量补丁和部署文档执行;不要通过删除 Compose 数据卷来代替迁移。
+32
View File
@@ -0,0 +1,32 @@
# 第三方软件声明
本文件用于说明本项目中涉及的第三方来源代码及其适用许可证。本项目自身的业务代码、界面设计、文档和品牌内容仍以仓库根目录 [LICENSE](./LICENSE) 中的专有版权声明为准。
## RuoYi-Vue3-FastAPI
本项目 Web 前端部分参考并包含来源于 [RuoYi-Vue3-FastAPI](https://github.com/insistence/RuoYi-Vue3-FastAPI) 的代码及其衍生部分。
来源项目保留以下版权声明和 MIT 许可证文本:
```text
The MIT License (MIT)
Copyright (c) 2018 RuoYi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
+135
View File
@@ -0,0 +1,135 @@
services:
shierfeng-mysql:
image: mysql:8.0
container_name: shierfeng-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请在 .env 中设置 MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-shierfeng-fastapi}
MYSQL_USER: ${DB_USERNAME:-shierfeng}
MYSQL_PASSWORD: ${DB_PASSWORD:?请在 .env 中设置 DB_PASSWORD}
TZ: ${TZ:-Asia/Shanghai}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_general_ci
- --skip-character-set-client-handshake=1
volumes:
- mysql-data:/var/lib/mysql
- ./shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:/docker-entrypoint-initdb.d/01-shierfeng-fastapi.sql:ro
ports:
- ${COMPOSE_BIND_IP:-127.0.0.1}:${MYSQL_HOST_PORT:-13307}:3306
healthcheck:
test:
- CMD-SHELL
- >-
mysqladmin ping -h 127.0.0.1 -uroot
-p"$${MYSQL_ROOT_PASSWORD}" --silent
interval: 5s
timeout: 10s
retries: 30
start_period: 20s
networks:
- shierfeng-network
shierfeng-redis:
image: redis:7-alpine
container_name: shierfeng-redis
restart: unless-stopped
command:
- redis-server
- --appendonly
- 'yes'
- --save
- '60'
- '1000'
volumes:
- redis-data:/data
healthcheck:
test: [CMD, redis-cli, ping]
interval: 5s
timeout: 5s
retries: 30
start_period: 5s
networks:
- shierfeng-network
shierfeng-backend-my:
build:
context: ./shierfeng-fastapi-backend
dockerfile: Dockerfile.my
image: shierfeng-backend-my:latest
container_name: shierfeng-backend-my
restart: unless-stopped
environment:
APP_ENV: dockermy
APP_HOST: 0.0.0.0
APP_PORT: '9099'
APP_ROOT_PATH: /docker-api
APP_RELOAD: 'false'
APP_WORKERS: '1'
APP_DEMO_MODE: 'false'
APP_DISABLE_SWAGGER: 'true'
APP_DISABLE_REDOC: 'true'
DB_TYPE: mysql
DB_HOST: shierfeng-mysql
DB_PORT: '3306'
DB_USERNAME: ${DB_USERNAME:-shierfeng}
DB_PASSWORD: ${DB_PASSWORD:?请在 .env 中设置 DB_PASSWORD}
DB_DATABASE: ${MYSQL_DATABASE:-shierfeng-fastapi}
DB_ECHO: 'false'
REDIS_HOST: shierfeng-redis
REDIS_PORT: '6379'
REDIS_USERNAME: ${REDIS_USERNAME:-}
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
REDIS_DATABASE: ${REDIS_DATABASE:-2}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?请在 .env 中设置 JWT_SECRET_KEY}
TRANSPORT_CRYPTO_ENABLED: ${TRANSPORT_CRYPTO_ENABLED:-false}
TRANSPORT_CRYPTO_MODE: ${TRANSPORT_CRYPTO_MODE:-off}
LOG_FILE_ENABLED: 'false'
LOGURU_STDOUT: 'true'
expose:
- '9099'
ports:
- ${COMPOSE_BIND_IP:-127.0.0.1}:${BACKEND_HOST_PORT:-9099}:9099
depends_on:
shierfeng-mysql:
condition: service_healthy
shierfeng-redis:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- >-
python -c "import socket; s=socket.create_connection(('127.0.0.1', 9099), 2); s.close()"
interval: 10s
timeout: 5s
retries: 30
start_period: 20s
networks:
- shierfeng-network
shierfeng-frontend:
build:
context: ./shierfeng-fastapi-frontend
dockerfile: Dockerfile
image: shierfeng-frontend:latest
container_name: shierfeng-frontend
restart: unless-stopped
ports:
- ${COMPOSE_BIND_IP:-127.0.0.1}:${FRONTEND_HOST_PORT:-8080}:80
volumes:
- ./shierfeng-fastapi-frontend/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
shierfeng-backend-my:
condition: service_healthy
networks:
- shierfeng-network
volumes:
mysql-data:
redis-data:
networks:
shierfeng-network:
name: shierfeng-network
driver: bridge
@@ -0,0 +1,11 @@
{
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "device",
"packageName" : "",
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}
+13 -1
View File
@@ -1,4 +1,4 @@
# ShiErFeng 移动端
# 智链云枢管理系统移动端
基于 `uni-app` 的 `vite` + `vue3` + `tailwindcss` 开发。
@@ -23,6 +23,18 @@
>
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
### 配置 PDA 后端地址
PDA 默认请求本机 `http://localhost:9099`,仅适用于后端运行在同一设备的本地开发。部署到独立 PDA 或局域网服务器时,在构建环境注入 `VITE_APP_BASE_API`:
```bash
VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
```
不要把包含真实凭据的环境文件或地址配置提交到仓库。
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
### vscode
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/static/favicon.ico" />
<script>
var coverSupport =
'CSS' in window &&
+1
View File
@@ -3,6 +3,7 @@
"version": "1.9.0",
"packageManager": "pnpm@10.28.1",
"scripts": {
"test:business": "node --test tests/*.test.mjs",
"dev": "uni -p mp-weixin",
"build": "npm run build:mp-weixin",
"dev:app": "uni -p app",
@@ -0,0 +1,8 @@
import request from "@/utils/request";
export function listScanExceptions() {
return request({
url: "/business/dashboard/scan-exceptions",
method: "get",
});
}
@@ -0,0 +1,38 @@
import request from "@/utils/request";
export function listFilling(query) {
return request({
url: "/business/filling/list",
method: "get",
params: query,
});
}
export function getFillingStrainOptions() {
return request({
url: "/business/filling/strain-options",
method: "get",
});
}
export function scanFillingPallet(qrToken) {
return request({
url: "/business/filling/scan/" + encodeURIComponent(qrToken),
method: "get",
});
}
export function getFilling(fillingId) {
return request({
url: "/business/filling/" + fillingId,
method: "get",
});
}
export function addFilling(data) {
return request({
url: "/business/filling",
method: "post",
data,
});
}
@@ -0,0 +1,56 @@
import request from "@/utils/request";
export function listInbound(query) {
return request({
url: "/business/inbound/list",
method: "get",
params: query,
});
}
export function getInboundWarehouseOptions() {
return request({
url: "/business/inbound/warehouse-options",
method: "get",
});
}
export function scanInboundPallet(qrToken, query) {
return request({
url: "/business/inbound/scan/" + encodeURIComponent(qrToken),
method: "get",
params: query,
});
}
export function getInbound(inboundId) {
return request({
url: "/business/inbound/" + inboundId,
method: "get",
});
}
export function addInbound(data) {
return request({
url: "/business/inbound",
method: "post",
data,
});
}
export function updateInbound(data) {
return request({
url: "/business/inbound",
method: "put",
data,
});
}
export function confirmInbound(inboundId, data) {
return request({
url: "/business/inbound/" + inboundId + "/confirm",
method: "put",
data,
headers: { repeatSubmit: false },
});
}
@@ -0,0 +1,47 @@
import request from "@/utils/request";
export function listReturn(query) {
return request({
url: "/business/return/list",
method: "get",
params: query,
});
}
export function getReturnWarehouseOptions() {
return request({
url: "/business/return/warehouse-options",
method: "get",
});
}
export function scanReturnPallet(qrToken, query) {
return request({
url: "/business/return/scan/" + encodeURIComponent(qrToken),
method: "get",
params: query,
});
}
export function requestReturn(data) {
return request({
url: "/business/return/request",
method: "post",
data,
});
}
export function getReturn(returnId) {
return request({
url: "/business/return/" + returnId,
method: "get",
});
}
export function confirmReturn(returnId, data) {
return request({
url: "/business/return/" + returnId + "/confirm",
method: "put",
data,
});
}
@@ -0,0 +1,62 @@
import request from "@/utils/request";
export function listShipment(query) {
return request({
url: "/business/shipment/list",
method: "get",
params: query,
});
}
export function getShipmentWarehouseOptions() {
return request({
url: "/business/shipment/warehouse-options",
method: "get",
});
}
export function getShipmentSiteOptions() {
return request({
url: "/business/shipment/site-options",
method: "get",
});
}
export function scanShipmentPallet(qrToken, query) {
return request({
url: "/business/shipment/scan/" + encodeURIComponent(qrToken),
method: "get",
params: query,
});
}
export function getShipment(shipmentId) {
return request({
url: "/business/shipment/" + shipmentId,
method: "get",
});
}
export function addShipment(data) {
return request({
url: "/business/shipment",
method: "post",
data,
});
}
export function updateShipment(data) {
return request({
url: "/business/shipment",
method: "put",
data,
});
}
export function confirmShipment(shipmentId, data) {
return request({
url: "/business/shipment/" + shipmentId + "/confirm",
method: "put",
data,
});
}
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
export function listShipmentReceipts(query) {
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
}
export function getShipmentReceipt(shipmentId) {
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
}
export function scanShipmentReceipt(shipmentId, identifier) {
return request({
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
method: 'get'
})
}
export function confirmShipmentReceipt(shipmentId, data) {
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
}
@@ -0,0 +1,63 @@
import request from "@/utils/request";
export function listUsage(query) {
return request({
url: "/business/usage/list",
method: "get",
params: query,
});
}
export function getUsageLineOptions(siteId) {
return request({
url: "/business/usage/line-options",
method: "get",
params: { siteId },
});
}
export function getUsageTunnelOptions(siteId) {
return request({
url: "/business/usage/tunnel-options",
method: "get",
params: { siteId },
});
}
export function enterUsageTunnel(data) {
return request({
url: "/business/usage/enter-tunnel",
method: "post",
data,
});
}
export function scanUsagePallet(qrToken) {
return request({
url: "/business/usage/scan/" + encodeURIComponent(qrToken),
method: "get",
});
}
export function getUsageFifoRecommendation(qrToken, tunnelId) {
return request({
url: "/business/usage-fifo/recommendation",
method: "get",
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) },
});
}
export function getUsage(usageId) {
return request({
url: "/business/usage/" + usageId,
method: "get",
});
}
export function addUsage(data) {
return request({
url: "/business/usage",
method: "post",
data,
});
}
@@ -0,0 +1,9 @@
import request from "@/utils/request";
export function listPalletInventory(query) {
return request({
url: "/report/pallet-inventory/list",
method: "get",
params: query,
});
}
@@ -0,0 +1,9 @@
import request from "@/utils/request";
export function listShipmentReport(query) {
return request({
url: "/report/shipment/list",
method: "get",
params: query,
});
}
@@ -0,0 +1,9 @@
import request from "@/utils/request";
// PDA 只读取当前登录用户被授权且仍处于 ACTIVE 状态的批次。
export function listAvailableTestBatches() {
return request({
url: "/system/test-batch/options",
method: "get",
});
}
@@ -0,0 +1,115 @@
<template>
<view class="w-full">
<view class="flex items-center rounded-xl bg-white p-2 shadow-sm">
<input
v-model="scanValue"
class="h-11 min-w-0 flex-1 px-3 text-sm text-gray-700"
type="text"
confirm-type="done"
maxlength="128"
:focus="focusRequested"
:disabled="disabled || loading"
placeholder="扫描二维码或输入托盘号后按回车"
@confirm="handleSubmit"
@input="clearError"
/>
<button
class="ml-2 h-10 rounded-lg bg-blue-500 px-3 text-xs text-white active:opacity-80 disabled:bg-gray-300"
:disabled="disabled || loading"
@click="handleSubmit"
>
{{ loading ? "校验中" : "提交扫描" }}
</button>
</view>
<text v-if="errorMessage" class="mt-2 block text-xs text-red-500">
{{ errorMessage }}
</text>
</view>
</template>
<script setup>
import { nextTick, onMounted, ref, watch } from "vue";
import { isDuplicateScan, normalizeScanValue } from "@/utils/palletScan";
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
duplicateWindowMs: {
type: Number,
default: 500,
},
});
const emit = defineEmits(["scan", "invalid", "duplicate"]);
const scanValue = ref("");
const errorMessage = ref("");
const focusRequested = ref(false);
let lastScanValue = "";
let lastScanAt = 0;
function focusInput() {
if (props.disabled || props.loading) return;
focusRequested.value = false;
nextTick(() => {
if (!props.disabled && !props.loading) {
focusRequested.value = true;
}
});
}
function clearError() {
errorMessage.value = "";
}
function handleSubmit() {
if (props.disabled || props.loading) return;
let normalizedValue;
try {
normalizedValue = normalizeScanValue(scanValue.value);
} catch (error) {
errorMessage.value = error.message;
emit("invalid", error.message);
focusInput();
return;
}
const scannedAt = Date.now();
if (
isDuplicateScan(
normalizedValue,
lastScanValue,
scannedAt,
lastScanAt,
props.duplicateWindowMs,
)
) {
scanValue.value = "";
emit("duplicate", normalizedValue);
focusInput();
return;
}
lastScanValue = normalizedValue;
lastScanAt = scannedAt;
scanValue.value = "";
errorMessage.value = "";
emit("scan", normalizedValue);
focusInput();
}
onMounted(focusInput);
watch(
() => [props.disabled, props.loading],
([disabled, loading]) => {
if (!disabled && !loading) focusInput();
},
);
</script>
@@ -0,0 +1,302 @@
<template>
<view v-if="canReceive" class="space-y-4">
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">收货单</text>
<picker :range="receiptStateOptions" range-key="label" :value="receiptStateIndex" @change="changeReceiptState">
<view class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ receiptStateOptions[receiptStateIndex].label }}</view>
</picker>
<input v-model="queryParams.shipmentNo" class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm" maxlength="64" placeholder="输入原发货单号" confirm-type="search" @confirm="handleQuery" />
<button size="mini" class="rounded-lg bg-blue-50 text-blue-600" :disabled="loading" @click="handleQuery">查询收货</button>
<text v-if="listError" class="mt-3 block text-xs text-red-500">{{ listError }}</text>
<text v-if="loading" class="mt-3 block text-xs text-gray-400">加载中...</text>
<text v-else-if="!receiptList.length" class="mt-3 block text-xs text-gray-400">暂无收货单</text>
<view v-for="row in receiptList" :key="row.shipmentId" class="mt-3 rounded-xl border border-gray-100 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ row.shipmentNo }}</text>
<text class="text-xs text-orange-600">{{ receiptStateLabel(row.receiptState) }}</text>
</view>
<text class="mt-2 block text-xs text-gray-500">仓库 #{{ row.sourceWarehouseId }} → 现场 #{{ row.targetSiteId }}</text>
<text v-if="row.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ row.receivedAt || '未登记' }}</text>
<button size="mini" class="mt-3 rounded-lg bg-orange-50 text-orange-600" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</button>
</view>
<view class="mt-3 flex items-center justify-between gap-2">
<button size="mini" :disabled="loading || queryParams.pageNum <= 1" @click="changePage(-1)">上一页</button>
<text class="text-xs text-gray-500">第 {{ queryParams.pageNum }} 页 · 共 {{ total }} 单</text>
<button size="mini" :disabled="loading || queryParams.pageNum * queryParams.pageSize >= total" @click="changePage(1)">下一页</button>
</view>
</view>
<view v-if="activeShipmentId !== null" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">收货核对</text>
<button size="mini" class="text-xs text-gray-500" @click="closeReceipt">关闭</button>
</view>
<text v-if="detailLoading" class="block text-xs text-gray-400">加载原单明细...</text>
<text v-if="detailError" class="mb-3 block text-xs text-red-500">{{ detailError }}</text>
<button v-if="detailError" size="mini" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</button>
<template v-if="receiptDetail">
<text class="block text-sm font-medium text-gray-700">{{ receiptDetail.shipmentNo }} · {{ receiptStateLabel(receiptDetail.receiptState) }}</text>
<text class="mt-1 block text-xs text-gray-500">目标现场 #{{ receiptDetail.targetSiteId }}</text>
<text v-if="receiptDetail.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ receiptDetail.receivedAt || '未登记' }}</text>
<text v-if="receiptDetail.receiptState === 'LEGACY'" class="mt-3 block text-xs text-gray-500">历史未登记收货:仅供查询原单明细,不补造收货时间。</text>
<view v-if="receiptDetail.receiptState === 'PENDING'" class="mt-3">
<PalletScanInput :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
<text class="mt-2 block text-xs text-gray-500">逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托,全部核对后才可确认。</text>
</view>
<text v-if="scanError" class="mt-3 block text-xs text-red-500">{{ scanError }}</text>
<text v-if="!receiptItems.length" class="mt-3 block text-xs text-red-500">原单无托盘明细,无法确认收货</text>
<view v-for="item in receiptItems" :key="item.palletId" class="mt-3 rounded-xl bg-gray-50 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ item.palletCode }}</text>
<text v-if="receiptDetail.receiptState === 'PENDING'" class="text-xs text-orange-600">{{ scannedPalletIds.includes(item.palletId) ? '已核对' : '待扫描' }}</text>
</view>
<text class="mt-2 block text-xs text-gray-600">品种:{{ item.strainName || '未登记' }} · 原发货 {{ item.packageQty }} 包</text>
<text class="mt-1 block text-xs text-gray-500">批号:{{ item.batchNo || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">生产日期:{{ item.productionDate || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">失效日期:{{ item.expiryDate || '未登记' }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING' && scannedPalletIds.includes(item.palletId)" size="mini" class="mt-2 text-xs text-red-500" :disabled="!canScan || scanLoading" @click="removeScannedPallet(item.palletId)">撤销核对</button>
</view>
<text v-if="confirmError" class="mt-3 block text-xs text-red-500">{{ confirmError }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING'" class="mt-4 rounded-xl bg-orange-500 py-3 text-sm text-white disabled:bg-gray-300" :disabled="!canConfirmReceipt" :loading="confirmLoading" @click="confirmReceipt">确认收货</button>
</template>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
import PalletScanInput from '@/components/business/PalletScanInput.vue'
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
import { createIdempotencyKey } from '@/utils/palletScan'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['received'])
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
const receiptList = ref([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const activeShipmentId = ref(null)
const receiptDetail = ref(null)
const detailLoading = ref(false)
const detailError = ref('')
const scannedPalletIds = ref([])
const scanLoading = ref(false)
const scanError = ref('')
const confirmLoading = ref(false)
const confirmError = ref('')
const receiptAccepted = ref(false)
let session = 0
let listGeneration = 0
let suspended = false
let idempotencyKey = ''
const receiptItems = computed(() => receiptDetail.value?.items || [])
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
function receiptStateLabel(state) {
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback
}
function isCurrent(currentSession) {
return currentSession === session && !suspended && canReceive.value
}
async function loadReceipts() {
if (!canReceive.value || suspended) return
const generation = ++listGeneration
loading.value = true
listError.value = ''
try {
const response = await listShipmentReceipts({ ...queryParams })
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = Array.isArray(response.rows) ? response.rows : []
total.value = response.total || 0
} catch (error) {
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = []
total.value = 0
listError.value = errorMessage(error, '收货列表加载失败,请重试')
} finally {
if (generation === listGeneration) loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
return loadReceipts()
}
function closeReceipt() {
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
session += 1
activeShipmentId.value = null
receiptDetail.value = null
scannedPalletIds.value = []
detailLoading.value = false
scanLoading.value = false
confirmLoading.value = false
receiptAccepted.value = false
detailError.value = ''
scanError.value = ''
confirmError.value = ''
idempotencyKey = ''
}
async function loadReceiptDetail(shipmentId, currentSession) {
detailLoading.value = true
detailError.value = ''
try {
const response = await getShipmentReceipt(shipmentId)
if (!isCurrent(currentSession)) return
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
throw new Error('收货详情与当前单据不一致,请重新打开')
}
receiptDetail.value = response.data
} catch (error) {
if (!isCurrent(currentSession)) return
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
} finally {
if (isCurrent(currentSession)) detailLoading.value = false
}
}
async function openReceipt(row) {
if (!canReceive.value || suspended || row.shipmentId == null) return
closeReceipt()
activeShipmentId.value = row.shipmentId
await loadReceiptDetail(row.shipmentId, session)
}
function retryDetail() {
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
return loadReceiptDetail(activeShipmentId.value, session)
}
async function handleReceiptScan(identifier) {
if (!canScan.value || scanLoading.value) return
const value = String(identifier ?? '').trim()
if (!value) {
scanError.value = '请扫描二维码或输入托盘号'
return
}
const currentSession = session
scanLoading.value = true
scanError.value = ''
try {
const response = await scanShipmentReceipt(activeShipmentId.value, value)
if (!isCurrent(currentSession)) return
const scanned = response.data
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
scanError.value = '该托盘或装填记录不属于当前发货单'
return
}
if (scannedPalletIds.value.includes(original.palletId)) {
scanError.value = '该托盘已核对,请勿重复扫描'
return
}
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
scannedPalletIds.value.push(original.palletId)
idempotencyKey = ''
confirmError.value = ''
} catch (error) {
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
} finally {
if (isCurrent(currentSession)) scanLoading.value = false
}
}
function removeScannedPallet(palletId) {
if (!canScan.value || scanLoading.value) return
if (!scannedPalletIds.value.includes(palletId)) return
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
idempotencyKey = ''
confirmError.value = ''
}
async function confirmReceipt() {
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
if (!canConfirmReceipt.value) {
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
return
}
const currentSession = session
const shipmentId = activeShipmentId.value
confirmLoading.value = true
confirmError.value = ''
try {
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
const response = await confirmShipmentReceipt(shipmentId, {
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
})
if (!isCurrent(currentSession)) return
receiptAccepted.value = true
proxy.$modal.msgSuccess(response.msg || '收货成功')
emit('received')
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
} catch (error) {
if (isCurrent(currentSession)) {
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
}
} finally {
if (isCurrent(currentSession)) confirmLoading.value = false
}
}
function suspendPanel() {
suspended = true
listGeneration += 1
loading.value = false
closeReceipt()
}
function resumePanel() {
if (!suspended) return
suspended = false
loadReceipts()
}
onMounted(loadReceipts)
onBeforeUnmount(suspendPanel)
const receiptStateOptions = [
{ label: '待收货', value: 'PENDING' },
{ label: '已收货', value: 'RECEIVED' },
{ label: '历史未登记收货', value: 'LEGACY' },
{ label: '全部', value: undefined }
]
const receiptStateIndex = computed(() => receiptStateOptions.findIndex(item => item.value === queryParams.receiptState))
function changeReceiptState(event) {
const option = receiptStateOptions[Number(event.detail.value)]
if (!option) return
queryParams.receiptState = option.value
handleQuery()
}
function changePage(offset) {
const page = queryParams.pageNum + offset
if (loading.value || page < 1 || (page > 1 && (page - 1) * queryParams.pageSize >= total.value)) return
queryParams.pageNum = page
loadReceipts()
}
onShow(resumePanel)
onHide(suspendPanel)
onUnload(suspendPanel)
</script>
@@ -0,0 +1,78 @@
<template>
<view v-if="testBatchStore.isTestMode" class="test-batch-banner">
<view class="test-batch-icon">
<view class="i-mdi-flask-outline test-batch-icon-glyph"></view>
</view>
<view class="test-batch-copy">
<text class="test-batch-title">测试数据</text>
<text class="test-batch-value">
当前批次:{{ testBatchStore.currentBatchName || "已选测试批次" }}
</text>
</view>
</view>
</template>
<script setup>
import { useTestBatchStore } from "@/store";
const testBatchStore = useTestBatchStore();
</script>
<style lang="scss" scoped>
.test-batch-banner {
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: flex-start;
gap: 12rpx;
margin-top: 18rpx;
border: 1rpx solid #f1b8b4;
border-radius: 16rpx;
padding: 16rpx;
background: #fff5f4;
}
.test-batch-icon {
display: flex;
width: 52rpx;
height: 52rpx;
flex: 0 0 52rpx;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #d9534f;
color: #ffffff;
}
.test-batch-icon-glyph {
font-size: 28rpx;
}
.test-batch-copy {
min-width: 0;
flex: 1;
}
.test-batch-title,
.test-batch-value {
display: block;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.test-batch-title {
color: #b42318;
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
}
.test-batch-value {
margin-top: 5rpx;
color: #c2413b;
font-size: 22rpx;
line-height: 1.55;
}
</style>
@@ -0,0 +1,352 @@
<template>
<view class="test-batch-switch">
<view class="mode-switch-header">
<view class="mode-switch-copy">
<text class="mode-switch-title">数据模式</text>
<text class="mode-switch-description">
{{ testBatchStore.isTestMode ? "当前操作隔离在测试批次" : "当前使用正式数据" }}
</text>
</view>
<text
class="mode-switch-status"
:class="testBatchStore.isTestMode ? 'is-test' : 'is-formal'"
>
{{ testBatchStore.isTestMode ? "测试数据" : "正式数据" }}
</text>
</view>
<view class="mode-switch-controls">
<button
class="mode-switch-button"
:class="{ active: !testBatchStore.isTestMode }"
:disabled="loading"
size="mini"
@click="handleModeClick('formal')"
>
正式
</button>
<button
class="mode-switch-button"
:class="{ 'active-test': testBatchStore.isTestMode }"
:disabled="loading || !activeOptions.length"
size="mini"
@click="handleModeClick('test')"
>
测试
</button>
</view>
<view
v-if="testBatchStore.isTestMode || activeOptions.length"
class="test-batch-picker-row"
>
<text class="test-batch-picker-label-title">测试批次</text>
<picker
:range="activeOptions"
range-key="batchName"
:value="selectedBatchIndex"
:disabled="loading || !activeOptions.length"
@change="handleBatchChange"
>
<view class="test-batch-picker">
<text class="test-batch-picker-label">
{{ selectedBatch?.batchName || (loading ? "加载中..." : "暂无可用测试批次") }}
</text>
<view class="i-mdi-chevron-down test-batch-picker-arrow"></view>
</view>
</picker>
</view>
<view v-if="loadError" class="mode-switch-error">
<text class="mode-switch-error-text">{{ loadError }}</text>
<button
class="mode-switch-retry"
size="mini"
:disabled="loading"
@click="loadOptions"
>
重试
</button>
</view>
</view>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useTestBatchStore } from "@/store";
const testBatchStore = useTestBatchStore();
const loading = ref(false);
const loadError = ref("");
const activeOptions = computed(() =>
testBatchStore.options.filter((item) => item.status === "ACTIVE"),
);
const selectedBatch = computed(() =>
activeOptions.value.find(
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
),
);
const selectedBatchIndex = computed(() => {
const index = activeOptions.value.findIndex(
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
);
return index >= 0 ? index : 0;
});
function errorMessage(error) {
return error?.response?.data?.msg || error?.message || "测试批次加载失败,请稍后重试";
}
async function loadOptions() {
loading.value = true;
loadError.value = "";
try {
await testBatchStore.refreshOptions();
} catch (error) {
if (error?.reason !== "TEST_BATCH_UNAVAILABLE") {
loadError.value = errorMessage(error);
}
} finally {
loading.value = false;
}
}
function confirmModeSwitch() {
return new Promise((resolve) => {
uni.showModal({
title: "切换数据模式",
content: "切换后会清空当前页面未提交的扫码和表单内容,确定继续吗?",
cancelText: "取消",
confirmText: "确定",
success: (result) => resolve(result.confirm === true),
fail: () => resolve(false),
});
});
}
async function handleModeClick(mode) {
if (loading.value) return;
if (mode === "formal") {
if (!testBatchStore.isTestMode || !(await confirmModeSwitch())) return;
testBatchStore.leaveTestMode();
uni.reLaunch({ url: "/pages/work/index" });
return;
}
if (testBatchStore.isTestMode) return;
const batch = activeOptions.value[0];
if (!batch) {
uni.showToast({ title: "暂无可用测试批次", icon: "none" });
return;
}
if (!(await confirmModeSwitch())) return;
try {
testBatchStore.selectBatch(batch);
uni.reLaunch({ url: "/pages/work/index" });
} catch (error) {
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
}
}
async function handleBatchChange(event) {
const batch = activeOptions.value[Number(event.detail.value)];
if (!batch || String(batch.batchId) === String(testBatchStore.currentBatchId)) return;
if (!(await confirmModeSwitch())) return;
try {
testBatchStore.selectBatch(batch);
uni.reLaunch({ url: "/pages/work/index" });
} catch (error) {
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
}
}
onMounted(loadOptions);
</script>
<style lang="scss" scoped>
.test-batch-switch {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 1rpx solid #e5eeee;
border-radius: 20rpx;
padding: 22rpx;
background: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
}
.mode-switch-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 14rpx;
}
.mode-switch-copy {
min-width: 0;
flex: 1;
}
.mode-switch-title,
.mode-switch-description,
.test-batch-picker-label-title,
.test-batch-picker-label,
.mode-switch-error-text {
display: block;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.mode-switch-title {
color: #28225c;
font-size: 26rpx;
font-weight: 700;
line-height: 1.4;
}
.mode-switch-description {
margin-top: 5rpx;
color: #8b969d;
font-size: 21rpx;
line-height: 1.5;
}
.mode-switch-status {
flex: 0 0 auto;
border: 1rpx solid #dce7e7;
border-radius: 999rpx;
padding: 7rpx 12rpx;
font-size: 20rpx;
line-height: 1.3;
}
.mode-switch-status.is-formal {
border-color: #dce7e7;
background: #f5f8f8;
color: #64727a;
}
.mode-switch-status.is-test {
border-color: #f1b8b4;
background: #fff1f0;
color: #b42318;
}
.mode-switch-controls {
display: flex;
width: 100%;
min-width: 0;
gap: 10rpx;
margin-top: 18rpx;
border-radius: 12rpx;
padding: 5rpx;
background: #f1f5f5;
}
.mode-switch-button {
width: auto;
min-width: 0;
height: 64rpx;
flex: 1;
margin: 0;
border: 0;
border-radius: 9rpx;
background: transparent;
color: #748087;
font-size: 23rpx;
line-height: 64rpx;
}
.mode-switch-button::after,
.mode-switch-retry::after {
border: 0;
}
.mode-switch-button.active {
background: #ffffff;
box-shadow: 0 4rpx 10rpx rgba(40, 34, 92, 0.08);
color: #28225c;
font-weight: 700;
}
.mode-switch-button.active-test {
background: #d9534f;
box-shadow: 0 4rpx 10rpx rgba(217, 83, 79, 0.18);
color: #ffffff;
font-weight: 700;
}
.test-batch-picker-row {
min-width: 0;
margin-top: 18rpx;
}
.test-batch-picker-label-title {
color: #6c7786;
font-size: 21rpx;
line-height: 1.4;
}
.test-batch-picker {
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-top: 8rpx;
border: 1rpx solid #e0eaea;
border-radius: 12rpx;
padding: 16rpx;
background: #fbfdfd;
}
.test-batch-picker-label {
flex: 1;
color: #47545c;
font-size: 23rpx;
line-height: 1.5;
}
.test-batch-picker-arrow {
flex: 0 0 30rpx;
color: #96a5a9;
font-size: 30rpx;
}
.mode-switch-error {
display: flex;
min-width: 0;
align-items: center;
gap: 10rpx;
margin-top: 14rpx;
border-radius: 10rpx;
padding: 10rpx 12rpx;
background: #fff8ed;
}
.mode-switch-error-text {
flex: 1;
color: #a15c00;
font-size: 21rpx;
line-height: 1.5;
}
.mode-switch-retry {
width: auto;
min-width: 74rpx;
height: 52rpx;
flex: 0 0 auto;
margin: 0;
padding: 0 12rpx;
border: 0;
border-radius: 8rpx;
background: #fff0d6;
color: #a15c00;
font-size: 20rpx;
line-height: 52rpx;
}
</style>
+4 -3
View File
@@ -1,14 +1,15 @@
// 应用全局配置
export default {
baseUrl: "http://localhost:9099",
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
// 应用信息
appInfo: {
// 应用名称
name: "ShiErFeng-FastAPI-APP",
name: "智链云枢管理系统",
// 应用版本
version: "1.9.0",
// 应用logo
logo: "/static/logo.png",
logo: "/static/sylvan-logo.png",
// 官方网站
site_url: "https://vfadmin.insistence.tech",
// 政策协议
@@ -0,0 +1,43 @@
export const businessMenuEntries = [
{
path: "/pages/business/inbound/index",
title: "仓库入库",
description: "扫描托盘登记入库",
icon: "i-mdi-package-down",
iconClass: "bg-blue-50 text-blue-600",
permission: "business:inbound:list",
},
{
path: "/pages/business/filling/index",
title: "工厂装填",
description: "录入菌种和包数",
icon: "i-mdi-package-variant-closed-plus",
iconClass: "bg-green-50 text-green-600",
permission: "business:filling:list",
},
{
path: "/pages/business/shipment/index",
title: "发货出库",
description: "扫码创建发货单",
icon: "i-mdi-truck-delivery",
iconClass: "bg-orange-50 text-orange-600",
permission: "business:shipment:list",
permissions: ["business:shipment:list", "business:shipment:receive"],
},
{
path: "/pages/business/usage/index",
title: "客户现场使用",
description: "绑定产线记录消耗",
icon: "i-mdi-factory",
iconClass: "bg-purple-50 text-purple-600",
permission: "business:usage:list",
},
{
path: "/pages/business/return/index",
title: "退回仓库",
description: "申请和确认退回",
icon: "i-mdi-undo-variant",
iconClass: "bg-red-50 text-red-600",
permission: "business:return:list",
},
];
+14
View File
@@ -5,10 +5,24 @@ import store from "./store"; // store
import { install } from "./plugins"; // plugins
import "./permission"; // permission
import { useDict } from "@/utils/dict";
import { useTestBatchStore } from "./store";
let testBatchUnavailableListenerRegistered = false;
function registerTestBatchUnavailableListener() {
if (testBatchUnavailableListenerRegistered || typeof uni.$on !== "function") {
return;
}
testBatchUnavailableListenerRegistered = true;
uni.$on("test-batch-unavailable", () => {
useTestBatchStore().leaveTestMode();
});
}
export function createApp() {
const app = createSSRApp(App);
app.use(store);
registerTestBatchUnavailableListener();
app.config.globalProperties.useDict = useDict;
install(app);
return {
+67 -65
View File
@@ -1,70 +1,72 @@
{
"name": "ShiErFeng-FastAPI移动端",
"appid": "__UNI__25A9D80",
"description": "",
"versionName": "1.9.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueCompiler": "uni-app",
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
"name" : "智链云枢管理系统",
"appid" : "__UNI__F8DBA93",
"description" : "",
"versionName" : "1.9.0",
"versionCode" : "100",
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
"nvueCompiler" : "uni-app",
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules" : {},
"distribute" : {
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios" : {
"dSYMs" : false
},
"sdkConfigs" : {}
}
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios": {
"dSYMs": false
},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "wxccd7e2a0911b3397",
"setting": {
"urlCheck": false,
"es6": false,
"minified": true,
"postcss": true
"quickapp" : {},
"mp-weixin" : {
"appid" : "wxccd7e2a0911b3397",
"setting" : {
"urlCheck" : false,
"es6" : false,
"minified" : true,
"postcss" : true
},
"optimization" : {
"subPackages" : true
},
"usingComponents" : true
},
"optimization": {
"subPackages": true
"vueVersion" : "3",
"h5" : {
"devServer" : {
"port" : 9090,
"https" : false
},
"title" : "智链云枢管理系统",
"router" : {
"mode" : "hash",
"base" : "./"
}
},
"usingComponents": true
},
"vueVersion": "3",
"h5": {
"devServer": {
"port": 9090,
"https": false
},
"title": "ShiErFeng-FastAPI-APP",
"router": {
"mode": "hash",
"base": "./"
}
}
"locale" : "zh-Hans",
"fallbackLocale" : "zh-Hans"
}
+42 -2
View File
@@ -15,7 +15,7 @@
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "ShiErFeng-FastAPI移动端",
"navigationBarTitleText": "智链云枢管理系统",
"navigationStyle": "custom"
}
},
@@ -25,6 +25,36 @@
"navigationBarTitleText": "工作台"
}
},
{
"path": "pages/business/inbound/index",
"style": {
"navigationBarTitleText": "仓库入库"
}
},
{
"path": "pages/business/filling/index",
"style": {
"navigationBarTitleText": "工厂装填"
}
},
{
"path": "pages/business/shipment/index",
"style": {
"navigationBarTitleText": "发货出库"
}
},
{
"path": "pages/business/usage/index",
"style": {
"navigationBarTitleText": "现场使用"
}
},
{
"path": "pages/business/return/index",
"style": {
"navigationBarTitleText": "退回仓库"
}
},
{
"path": "pages/mine/index",
"style": {
@@ -126,7 +156,17 @@
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "ShiErFeng",
"navigationBarTitleText": "智链云枢管理系统",
"navigationBarBackgroundColor": "#FFFFFF"
},
"condition" : { //模式配置,仅开发期间生效
"current": 0, //当前激活的模式(list 的索引项)
"list": [
{
"name": "", //模式名称
"path": "", //启动页面,必选
"query": "" //启动参数,在页面的onLoad函数里面得到
}
]
}
}
@@ -0,0 +1,391 @@
<template>
<view class="min-h-screen bg-gray-50">
<scroll-view scroll-y class="h-screen">
<view class="space-y-4 p-4 pb-24">
<view>
<text class="block text-xl font-bold text-gray-800">工厂装填</text>
<text class="mt-1 block text-xs text-gray-500">
扫描空托盘,录入菌种、批次、规格和初始包数
</text>
</view>
<TestBatchBanner />
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有装填操作权限</text>
</view>
<template v-else>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">扫描待装填托盘</text>
<button
class="text-xs text-gray-500"
size="mini"
:disabled="operationPending"
@click="resetOperation"
>
清空
</button>
</view>
<PalletScanInput
:disabled="operationPending || optionLoading"
:loading="scanLoading"
@scan="handleFillingScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
仅接受 EMPTY_IN_WAREHOUSE 状态托盘。
</text>
<text v-if="scanError" class="mt-2 block text-xs text-red-500">{{ scanError }}</text>
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-green-50 p-3">
<view class="flex items-center justify-between">
<text class="font-medium text-gray-700">
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
</text>
<text class="text-xs text-green-600">{{ scannedPallet.flowState }}</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
仓库:{{ scannedPallet.warehouseName || `#${scannedPallet.warehouseId}` }} · 容量:{{ scannedPallet.capacityPackages }} 包
</text>
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">装填信息</text>
<view class="space-y-3">
<view>
<text class="mb-2 block text-xs text-gray-500">菌种</text>
<picker
:range="strainOptions"
range-key="name"
:value="strainIndex"
:disabled="operationPending || optionLoading || !scannedPallet"
@change="handleStrainChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedStrainName || "请选择启用菌种" }}
</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">生产批次</text>
<input
v-model="form.batchNo"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
maxlength="64"
placeholder="请输入生产批次"
:disabled="operationPending || !scannedPallet"
@input="markPayloadChanged"
/>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">生产日期</text>
<picker mode="date" :value="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('productionDate', $event)">
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.productionDate || '选择来源生产日期' }}</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">有效期至</text>
<picker mode="date" :value="form.expiryDate" :start="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('expiryDate', $event)">
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.expiryDate || '选择有效期截止日期' }}</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
<input
v-model="form.packageSpec"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
maxlength="64"
placeholder="例如:500g/包"
:disabled="operationPending || !scannedPallet"
@input="markPayloadChanged"
/>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">初始包数</text>
<input
v-model="form.initialPackages"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
type="number"
placeholder="固定 100 包"
disabled
/>
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
</text>
</view>
</view>
<text v-if="formError" class="mt-3 block text-xs text-red-500">{{ formError }}</text>
<button
class="mt-4 w-full rounded-xl bg-green-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canOperate || operationPending || scanLoading || !scannedPallet"
@click="submitFilling"
>
{{ submitLoading ? "提交中" : "确认装填" }}
</button>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">最近装填记录</text>
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadFillingList">
刷新
</button>
</view>
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
<view v-else-if="!fillingList.length" class="py-6 text-center text-xs text-gray-400">
暂无装填记录
</view>
<view
v-for="row in fillingList"
v-else
:key="row.fillingId"
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
>
<text class="block font-medium text-gray-700">{{ row.fillingNo || `#${row.fillingId}` }}</text>
<text class="mt-1 block text-xs text-gray-500">
{{ row.strainNameSnapshot || row.strainCodeSnapshot }} · {{ row.batchNo }} · {{ row.initialPackages }} 包
</text>
<text class="mt-1 block text-xs text-gray-400">托盘 #{{ row.palletId }} · {{ row.operatedAt || row.createTime || "—" }}</text>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import {
addFilling,
getFillingStrainOptions,
listFilling,
scanFillingPallet,
} from "@/api/business/filling";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const fillingList = ref([]);
const strainOptions = ref([]);
const scannedPallet = ref(null);
const loading = ref(false);
const optionLoading = ref(false);
const scanLoading = ref(false);
const submitLoading = ref(false);
const listError = ref("");
const scanError = ref("");
const formError = ref("");
const idempotencyKey = ref("");
const form = reactive({
qrToken: "",
warehouseId: null,
strainId: null,
batchNo: "",
productionDate: "",
expiryDate: "",
packageSpec: "",
initialPackages: 100,
});
let listGeneration = 0;
let scanGeneration = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:filling:list",
"business:filling:query",
"business:filling:add",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:filling:list"));
const canOperate = computed(() =>
proxy.$auth.hasPermiAnd(["business:filling:query", "business:filling:add"]),
);
const operationPending = computed(() => submitLoading.value);
const strainIndex = computed(() => {
const index = strainOptions.value.findIndex((item) => item.id === form.strainId);
return index >= 0 ? index : 0;
});
const selectedStrainName = computed(
() => strainOptions.value.find((item) => item.id === form.strainId)?.name || "",
);
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function loadFillingList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listFilling({ pageNum: 1, pageSize: 20 })
.then((response) => {
if (generation !== listGeneration) return;
fillingList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
fillingList.value = [];
listError.value = errorMessage(error, "装填记录加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadStrainOptions() {
if (!proxy.$auth.hasPermi("business:filling:query")) return;
optionLoading.value = true;
getFillingStrainOptions()
.then((response) => {
strainOptions.value = Array.isArray(response.data) ? response.data : [];
})
.catch((error) => {
strainOptions.value = [];
formError.value = errorMessage(error, "菌种选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function resetOperation() {
scanGeneration += 1;
scannedPallet.value = null;
idempotencyKey.value = "";
form.qrToken = "";
form.warehouseId = null;
form.strainId = null;
form.batchNo = "";
form.productionDate = "";
form.expiryDate = "";
form.packageSpec = "";
form.initialPackages = 100;
scanError.value = "";
formError.value = "";
scanLoading.value = false;
}
function handleFillingScan(qrToken) {
if (operationPending.value || scanLoading.value || optionLoading.value) return;
resetOperation();
const generation = ++scanGeneration;
scanLoading.value = true;
scanError.value = "";
scanFillingPallet(qrToken)
.then((response) => {
if (generation !== scanGeneration) return;
const pallet = response.data;
if (pallet?.flowState !== "EMPTY_IN_WAREHOUSE") {
scanError.value = "该托盘当前不可装填";
return;
}
scannedPallet.value = {
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
warehouseId: pallet.warehouseId,
warehouseName: pallet.warehouseName,
flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages,
};
form.qrToken = pallet.qrToken || qrToken;
form.warehouseId = pallet.warehouseId;
loadStrainOptions();
})
.catch((error) => {
if (generation === scanGeneration) {
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration) scanLoading.value = false;
});
}
function handleScanInvalid(message) {
resetOperation();
scanError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function handleStrainChange(event) {
const option = strainOptions.value[Number(event.detail.value)];
form.strainId = option?.id || null;
markPayloadChanged();
}
function markPayloadChanged() {
if (!submitLoading.value) idempotencyKey.value = "";
}
function handleMaterialDateChange(field, event) {
if (operationPending.value) return;
form[field] = event.detail.value;
markPayloadChanged();
}
function ensureIdempotencyKey() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
return idempotencyKey.value;
}
function submitFilling() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
const initialPackages = Number(form.initialPackages);
if (!scannedPallet.value || !form.qrToken || !form.warehouseId) {
formError.value = "请先扫描待装填托盘";
return;
}
if (!form.strainId || !form.batchNo.trim() || !form.packageSpec.trim()) {
formError.value = "请完整填写菌种、批次和包装规格";
return;
}
if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
return;
}
if (initialPackages !== 100) {
formError.value = "新装填固定为 100 包";
return;
}
submitLoading.value = true;
formError.value = "";
addFilling({
qrToken: form.qrToken,
warehouseId: form.warehouseId,
strainId: form.strainId,
batchNo: form.batchNo.trim(),
productionDate: form.productionDate,
expiryDate: form.expiryDate,
packageSpec: form.packageSpec.trim(),
initialPackages,
idempotencyKey: ensureIdempotencyKey(),
})
.then((response) => {
idempotencyKey.value = "";
proxy.$modal.msgSuccess(response.msg || "托盘装填成功");
resetOperation();
loadFillingList();
})
.catch((error) => {
formError.value = `${errorMessage(error, "装填提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
})
.finally(() => {
submitLoading.value = false;
});
}
onShow(() => {
loadFillingList();
});
</script>
@@ -0,0 +1,486 @@
<template>
<view class="min-h-screen bg-gray-50">
<scroll-view scroll-y class="h-screen">
<view class="space-y-4 p-4 pb-24">
<view class="flex items-center justify-between">
<view>
<text class="block text-xl font-bold text-gray-800">仓库入库</text>
<text class="mt-1 block text-xs text-gray-500">
扫描尚未首次入库的空托盘,保存草稿后确认
</text>
</view>
<button
v-if="canDraft"
class="rounded-lg bg-blue-500 px-3 py-2 text-xs text-white active:opacity-80"
size="mini"
:disabled="operationPending"
@click="startNewDraft"
>
新建草稿
</button>
</view>
<TestBatchBanner />
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有入库操作权限</text>
</view>
<template v-else>
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-4 flex items-center justify-between">
<text class="font-bold text-gray-800">
{{ editing ? "编辑入库草稿" : "新建入库草稿" }}
</text>
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">入库仓库</text>
<picker
:range="warehouseOptions"
range-key="name"
:value="warehouseIndex"
:disabled="optionLoading || detailLoading || operationPending"
@change="handleWarehouseChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedWarehouseName || "请选择授权仓库" }}
</view>
</picker>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
<PalletScanInput
:disabled="!form.warehouseId || detailLoading || operationPending"
:loading="scanLoading"
@scan="handleInboundScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
仅接受 UNSTOCKED 托盘;同一托盘不会重复加入。
</text>
</view>
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
<view
v-for="pallet in scannedPallets"
:key="pallet.palletId"
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
>
<view>
<text class="block text-sm font-medium text-gray-700">
{{ pallet.palletCode || `#${pallet.palletId}` }}
</text>
<text class="block text-xs text-gray-500">{{ pallet.flowState }}</text>
</view>
<button
class="text-xs text-red-500"
size="mini"
:disabled="operationPending || detailLoading"
@click="removeScannedPallet(pallet.palletId)"
>
移除
</button>
</view>
</view>
<view class="flex gap-3">
<button
class="flex-1 rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
@click="saveDraft"
>
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
</button>
<button
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
:disabled="operationPending"
@click="cancelEditor"
>
取消
</button>
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">入库单</text>
<button
class="text-xs text-blue-500"
size="mini"
:disabled="loading || operationPending"
@click="loadInboundList"
>
刷新
</button>
</view>
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
<view v-else-if="!inboundList.length" class="py-6 text-center text-xs text-gray-400">
暂无入库单
</view>
<view
v-for="row in inboundList"
v-else
:key="row.inboundId"
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
>
<view class="flex items-center justify-between">
<text class="font-medium text-gray-700">{{ row.inboundNo || `#${row.inboundId}` }}</text>
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
仓库:{{ warehouseName(row.warehouseId) }} · 托盘:{{ row.items?.length || 0 }} 个
</text>
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
<button
v-if="canEdit"
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
size="mini"
:disabled="operationPending"
@click="startEditDraft(row)"
>
编辑
</button>
<button
v-if="canConfirm"
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
size="mini"
:disabled="operationPending"
@click="confirmDraft(row)"
>
{{ confirmLoading ? "确认中" : "确认" }}
</button>
</view>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import {
addInbound,
confirmInbound,
getInbound,
getInboundWarehouseOptions,
listInbound,
scanInboundPallet,
updateInbound,
} from "@/api/business/inbound";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const inboundList = ref([]);
const warehouseOptions = ref([]);
const scannedPallets = ref([]);
const editorVisible = ref(false);
const editing = ref(false);
const loading = ref(false);
const optionLoading = ref(false);
const detailLoading = ref(false);
const scanLoading = ref(false);
const submitLoading = ref(false);
const confirmLoading = ref(false);
const listError = ref("");
const formError = ref("");
const scanError = ref("");
const confirmError = ref("");
const form = reactive({ inboundId: null, warehouseId: null });
const confirmState = reactive({ inboundId: null, key: "" });
let listGeneration = 0;
let scanGeneration = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:inbound:list",
"business:inbound:query",
"business:inbound:add",
"business:inbound:edit",
"business:inbound:confirm",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:inbound:list"));
const canDraft = computed(() =>
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:add"]),
);
const canEdit = computed(() =>
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:edit"]),
);
const canConfirm = computed(() =>
proxy.$auth.hasPermi("business:inbound:confirm"),
);
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
const operationPending = computed(
() =>
submitLoading.value ||
confirmLoading.value ||
detailLoading.value,
);
const warehouseIndex = computed(() => {
const index = warehouseOptions.value.findIndex(
(item) => item.id === form.warehouseId,
);
return index >= 0 ? index : 0;
});
const selectedWarehouseName = computed(
() => warehouseOptions.value.find((item) => item.id === form.warehouseId)?.name || "",
);
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function warehouseName(id) {
return warehouseOptions.value.find((item) => item.id === id)?.name || `#${id || "-"}`;
}
function resetEditor() {
scanGeneration += 1;
editorVisible.value = false;
editing.value = false;
form.inboundId = null;
form.warehouseId = null;
scannedPallets.value = [];
detailLoading.value = false;
scanLoading.value = false;
formError.value = "";
scanError.value = "";
}
function loadInboundList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listInbound({ pageNum: 1, pageSize: 20 })
.then((response) => {
if (generation !== listGeneration) return;
inboundList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
inboundList.value = [];
listError.value = errorMessage(error, "入库单列表加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadWarehouseOptions() {
if (!proxy.$auth.hasPermi("business:inbound:query")) return;
optionLoading.value = true;
getInboundWarehouseOptions()
.then((response) => {
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
})
.catch((error) => {
warehouseOptions.value = [];
formError.value = errorMessage(error, "仓库选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function startNewDraft() {
if (!canDraft.value || operationPending.value) return;
resetEditor();
editorVisible.value = true;
loadWarehouseOptions();
}
function startEditDraft(row) {
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
resetEditor();
editorVisible.value = true;
editing.value = true;
form.inboundId = row.inboundId;
loadWarehouseOptions();
detailLoading.value = true;
getInbound(row.inboundId)
.then((response) => {
const detail = response.data;
if (!detail || detail.status !== "DRAFT") {
formError.value = "该入库单已不是草稿状态,不能编辑";
return;
}
form.warehouseId = detail.warehouseId;
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
(item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
flowState: item.flowState || "UNSTOCKED",
}),
);
})
.catch((error) => {
formError.value = errorMessage(error, "入库单详情加载失败,请稍后重试");
})
.finally(() => {
detailLoading.value = false;
});
}
function cancelEditor() {
if (operationPending.value) return;
resetEditor();
}
function handleWarehouseChange(event) {
const option = warehouseOptions.value[Number(event.detail.value)];
if (!option || option.id === form.warehouseId) return;
form.warehouseId = option.id;
scannedPallets.value = [];
scanError.value = "";
scanGeneration += 1;
}
function handleInboundScan(qrToken) {
if (!form.warehouseId || operationPending.value || scanLoading.value) {
scanError.value = "请先选择入库仓库";
return;
}
const generation = ++scanGeneration;
scanLoading.value = true;
scanError.value = "";
scanInboundPallet(qrToken, { warehouseId: form.warehouseId })
.then((response) => {
if (generation !== scanGeneration) return;
const pallet = response.data;
if (pallet?.flowState !== "UNSTOCKED") {
scanError.value = "该托盘不是未入库状态,不能加入入库草稿";
return;
}
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msg("该托盘已在当前草稿中");
return;
}
scannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
flowState: pallet.flowState,
});
})
.catch((error) => {
if (generation === scanGeneration) {
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration) scanLoading.value = false;
});
}
function handleScanInvalid(message) {
scanError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function removeScannedPallet(palletId) {
if (operationPending.value) return;
scannedPallets.value = scannedPallets.value.filter(
(item) => item.palletId !== palletId,
);
}
function saveDraft() {
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
if (!form.warehouseId) {
formError.value = "请选择入库仓库";
return;
}
if (!scannedPallets.value.length) {
formError.value = "请至少扫描一个托盘";
return;
}
submitLoading.value = true;
formError.value = "";
const payload = {
warehouseId: form.warehouseId,
palletIds: scannedPallets.value.map((item) => item.palletId),
};
const submitRequest = editing.value
? updateInbound({ ...payload, inboundId: form.inboundId })
: addInbound(payload);
submitRequest
.then((response) => {
proxy.$modal.msgSuccess(response.msg || "入库草稿保存成功");
resetEditor();
loadInboundList();
})
.catch((error) => {
formError.value = errorMessage(error, "入库草稿保存失败,请根据提示修正后重试");
})
.finally(() => {
submitLoading.value = false;
});
}
function askConfirm(content) {
return new Promise((resolve) => {
uni.showModal({
title: "系统提示",
content,
cancelText: "取消",
confirmText: "确定",
success: (result) => resolve(Boolean(result.confirm)),
});
});
}
function ensureConfirmIdempotencyKey(inboundId) {
if (confirmState.inboundId !== inboundId) {
confirmState.inboundId = inboundId;
confirmState.key = "";
}
if (!confirmState.key) confirmState.key = createIdempotencyKey();
return confirmState.key;
}
function clearConfirmIdempotency() {
confirmState.inboundId = null;
confirmState.key = "";
}
async function confirmDraft(row) {
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
if (!(await askConfirm(`确认入库单“${row.inboundNo || `#${row.inboundId}`}”?`))) return;
confirmLoading.value = true;
confirmError.value = "";
try {
await confirmInbound(row.inboundId, {
idempotencyKey: ensureConfirmIdempotencyKey(row.inboundId),
});
clearConfirmIdempotency();
proxy.$modal.msgSuccess("入库确认成功");
loadInboundList();
} catch (error) {
confirmError.value = `${errorMessage(error, "入库确认失败")};再次确认将复用本次幂等键`;
} finally {
confirmLoading.value = false;
}
}
onShow(() => {
loadInboundList();
});
</script>
@@ -0,0 +1,560 @@
<template>
<view class="min-h-screen bg-gray-50">
<scroll-view scroll-y class="h-screen">
<view class="space-y-4 p-4 pb-24">
<view>
<text class="block text-xl font-bold text-gray-800">退回仓库</text>
<text class="mt-1 block text-xs text-gray-500">
客户现场发起退回申请,仓库逐托扫描后确认收货
</text>
</view>
<TestBatchBanner />
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有退回操作权限</text>
</view>
<template v-else>
<view v-if="canRequest" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">发起退回申请</text>
<button class="text-xs text-gray-500" size="mini" :disabled="requestPending" @click="resetRequestOperation">
清空
</button>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">目标仓库</text>
<picker
:range="warehouseOptions"
range-key="name"
:value="warehouseIndex"
:disabled="optionLoading || requestPending"
@change="handleWarehouseChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedWarehouseName || "请选择目标仓库" }}
</view>
</picker>
</view>
<PalletScanInput
:disabled="!requestForm.targetWarehouseId || requestPending"
:loading="scanLoading.request"
@scan="handleRequestScan"
@invalid="handleRequestScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
同一申请只能包含同一客户现场的托盘;扫码成功后托盘由服务端冻结。
</text>
<text v-if="requestError" class="mt-2 block text-xs text-red-500">{{ requestError }}</text>
<view v-if="requestScannedPallets.length" class="mt-3 space-y-2">
<view
v-for="pallet in requestScannedPallets"
:key="pallet.palletId"
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
>
<view>
<text class="block text-sm text-gray-700">{{ pallet.palletCode || `#${pallet.palletId}` }}</text>
<text class="block text-xs text-gray-500">现场 #{{ pallet.siteId }} · 剩余 {{ pallet.remainingPackages }} 包</text>
</view>
<button class="text-xs text-red-500" size="mini" :disabled="requestPending" @click="removeRequestPallet(pallet.palletId)">
移除
</button>
</view>
</view>
<text v-if="requestForm.sourceSiteId" class="mt-3 block text-xs text-gray-500">
来源现场:#{{ requestForm.sourceSiteId }} · {{ requestScannedPallets.length }} 个托盘
</text>
<button
class="mt-4 w-full rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!requestScannedPallets.length || requestPending || scanLoading.request"
@click="submitReturnRequest"
>
{{ requestSubmitLoading ? "申请中" : "提交退回申请" }}
</button>
</view>
<view v-if="canConfirm" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">仓库确认退回</text>
<text class="text-xs text-gray-400">必须完整扫描单据托盘</text>
</view>
<picker
:range="requestedReturns"
range-key="returnNo"
:value="requestedReturnIndex"
:disabled="confirmPending || detailLoading"
@change="handleConfirmSelection"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedReturnLabel || "请选择待确认退回单" }}
</view>
</picker>
<text v-if="confirmError" class="mt-2 block text-xs text-red-500">{{ confirmError }}</text>
<view v-if="confirmExpectedRows.length" class="mt-3 rounded-xl bg-orange-50 p-3">
<text class="block text-xs text-gray-500">
预期托盘 {{ confirmExpectedRows.length }} 个,已扫描 {{ confirmScannedPallets.length }} 个
</text>
<view class="mt-2 space-y-1">
<text v-for="row in confirmExpectedRows" :key="row.palletId" class="block text-xs text-gray-600">
{{ row.palletCode || `#${row.palletId}` }}
<text v-if="isConfirmPalletScanned(row.palletId)" class="text-green-600"> · 已扫描</text>
</text>
</view>
</view>
<PalletScanInput
class="mt-3"
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || detailLoading"
:loading="scanLoading.confirm"
@scan="handleConfirmScan"
@invalid="handleConfirmScanInvalid"
@duplicate="handleScanDuplicate"
/>
<button
class="mt-4 w-full rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || scanLoading.confirm || detailLoading"
@click="submitReturnConfirm"
>
{{ confirmSubmitLoading ? "确认中" : "确认退回收货" }}
</button>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">退回单</text>
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadReturnList">
刷新
</button>
</view>
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
<view v-else-if="!returnList.length" class="py-6 text-center text-xs text-gray-400">
暂无退回单
</view>
<view
v-for="row in returnList"
v-else
:key="row.returnId"
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
>
<view class="flex items-center justify-between">
<text class="font-medium text-gray-700">{{ row.returnNo || `#${row.returnId}` }}</text>
<text :class="row.status === 'REQUESTED' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
{{ row.status === "REQUESTED" ? "待确认" : "已确认" }}
</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
现场 #{{ row.sourceSiteId }} → {{ warehouseName(row.targetWarehouseId) }} · 托盘 {{ row.items?.length || 0 }} 个
</text>
<button
v-if="row.status === 'REQUESTED' && canConfirm"
class="mt-3 w-full rounded-lg bg-orange-50 py-2 text-xs text-orange-600"
size="mini"
:disabled="confirmPending"
@click="selectReturnRow(row)"
>
选择并确认
</button>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import {
confirmReturn,
getReturn,
getReturnWarehouseOptions,
listReturn,
requestReturn,
scanReturnPallet,
} from "@/api/business/return";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const returnList = ref([]);
const warehouseOptions = ref([]);
const requestScannedPallets = ref([]);
const confirmExpectedRows = ref([]);
const confirmExpectedPalletIds = ref([]);
const confirmScannedPallets = ref([]);
const loading = ref(false);
const optionLoading = ref(false);
const detailLoading = ref(false);
const listError = ref("");
const requestError = ref("");
const confirmError = ref("");
const scanLoading = reactive({ request: false, confirm: false });
const requestSubmitLoading = ref(false);
const confirmSubmitLoading = ref(false);
const requestIdempotencyKey = ref("");
const confirmIdempotencyKey = ref("");
const requestForm = reactive({ sourceSiteId: null, targetWarehouseId: null });
const confirmForm = reactive({ returnId: null });
let listGeneration = 0;
let requestScanGeneration = 0;
let confirmScanGeneration = 0;
let detailGeneration = 0;
let requestSession = 0;
let confirmSession = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:return:list",
"business:return:query",
"business:return:request",
"business:return:confirm",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:return:list"));
const canRequest = computed(() =>
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:request"]),
);
const canConfirm = computed(() =>
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:confirm"]),
);
const requestPending = computed(() => requestSubmitLoading.value);
const confirmPending = computed(() => confirmSubmitLoading.value);
const warehouseIndex = computed(() => {
const index = warehouseOptions.value.findIndex(
(item) => item.id === requestForm.targetWarehouseId,
);
return index >= 0 ? index : 0;
});
const selectedWarehouseName = computed(
() => warehouseOptions.value.find((item) => item.id === requestForm.targetWarehouseId)?.name || "",
);
const requestedReturns = computed(() =>
returnList.value.filter((item) => item.status === "REQUESTED"),
);
const requestedReturnIndex = computed(() => {
const index = requestedReturns.value.findIndex(
(item) => item.returnId === confirmForm.returnId,
);
return index >= 0 ? index : 0;
});
const selectedReturnLabel = computed(() => {
const item = requestedReturns.value.find((row) => row.returnId === confirmForm.returnId);
return item ? `${item.returnNo || `#${item.returnId}`} · 现场 #${item.sourceSiteId}` : "";
});
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function warehouseName(id) {
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
}
function loadReturnList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listReturn({ pageNum: 1, pageSize: 30 })
.then((response) => {
if (generation !== listGeneration) return;
returnList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
returnList.value = [];
listError.value = errorMessage(error, "退回单列表加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadWarehouseOptions() {
if (!proxy.$auth.hasPermi("business:return:query")) return;
optionLoading.value = true;
getReturnWarehouseOptions()
.then((response) => {
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
})
.catch((error) => {
warehouseOptions.value = [];
requestError.value = errorMessage(error, "目标仓库选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function resetRequestOperation() {
requestSession += 1;
requestScanGeneration += 1;
requestForm.sourceSiteId = null;
requestForm.targetWarehouseId = null;
requestScannedPallets.value = [];
requestIdempotencyKey.value = "";
requestError.value = "";
scanLoading.request = false;
}
function handleWarehouseChange(event) {
const option = warehouseOptions.value[Number(event.detail.value)];
if (!option || option.id === requestForm.targetWarehouseId) return;
requestForm.targetWarehouseId = option.id;
requestForm.sourceSiteId = null;
requestScannedPallets.value = [];
requestIdempotencyKey.value = "";
requestError.value = "";
requestScanGeneration += 1;
}
function handleRequestScan(qrToken) {
if (!requestForm.targetWarehouseId || requestPending.value || scanLoading.request) {
requestError.value = "请先选择目标仓库";
return;
}
const session = requestSession;
const generation = ++requestScanGeneration;
scanLoading.request = true;
requestError.value = "";
scanReturnPallet(qrToken, { mode: "request" })
.then((response) => {
if (session !== requestSession || generation !== requestScanGeneration) return;
const pallet = response.data;
if (!pallet?.siteId) {
requestError.value = "该托盘不在客户现场,不能发起退回";
return;
}
if (requestForm.sourceSiteId && pallet.siteId !== requestForm.sourceSiteId) {
requestError.value = "同一退回申请只能包含同一客户现场的托盘";
return;
}
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msg("该托盘已在退回申请中");
return;
}
requestForm.sourceSiteId = requestForm.sourceSiteId || pallet.siteId;
requestScannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId,
remainingPackages: pallet.remainingPackages,
});
requestIdempotencyKey.value = "";
})
.catch((error) => {
if (session === requestSession && generation === requestScanGeneration) {
requestError.value = errorMessage(error, "退回托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (session === requestSession && generation === requestScanGeneration) {
scanLoading.request = false;
}
});
}
function handleRequestScanInvalid(message) {
requestError.value = message;
}
function removeRequestPallet(palletId) {
if (requestPending.value) return;
requestScannedPallets.value = requestScannedPallets.value.filter(
(item) => item.palletId !== palletId,
);
if (!requestScannedPallets.value.length) requestForm.sourceSiteId = null;
requestIdempotencyKey.value = "";
}
function ensureRequestIdempotencyKey() {
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey();
return requestIdempotencyKey.value;
}
async function submitReturnRequest() {
if (requestPending.value || scanLoading.request || !requestScannedPallets.value.length) return;
if (!requestForm.sourceSiteId || !requestForm.targetWarehouseId) {
requestError.value = "请选择目标仓库并扫描退回托盘";
return;
}
requestSubmitLoading.value = true;
requestError.value = "";
try {
await requestReturn({
sourceSiteId: requestForm.sourceSiteId,
targetWarehouseId: requestForm.targetWarehouseId,
palletIds: requestScannedPallets.value.map((item) => item.palletId),
idempotencyKey: ensureRequestIdempotencyKey(),
});
requestIdempotencyKey.value = "";
proxy.$modal.msgSuccess("退回申请已提交");
resetRequestOperation();
loadReturnList();
} catch (error) {
requestError.value = `${errorMessage(error, "退回申请提交失败")};直接重试将复用本次幂等键`;
} finally {
requestSubmitLoading.value = false;
}
}
function resetConfirmOperation() {
confirmSession += 1;
confirmScanGeneration += 1;
detailGeneration += 1;
confirmForm.returnId = null;
confirmExpectedRows.value = [];
confirmExpectedPalletIds.value = [];
confirmScannedPallets.value = [];
confirmIdempotencyKey.value = "";
confirmError.value = "";
detailLoading.value = false;
scanLoading.confirm = false;
}
function handleConfirmSelection(event) {
const row = requestedReturns.value[Number(event.detail.value)];
selectReturnRow(row);
}
function selectReturnRow(row) {
if (!row || row.status !== "REQUESTED" || confirmPending.value) return;
resetConfirmOperation();
const session = confirmSession;
const generation = ++detailGeneration;
confirmForm.returnId = row.returnId;
detailLoading.value = true;
getReturn(row.returnId)
.then((response) => {
if (session !== confirmSession || generation !== detailGeneration) return;
const detail = response.data;
if (!detail || detail.status !== "REQUESTED") {
confirmError.value = "该退回单已不是待确认状态";
return;
}
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map(
(item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
}),
);
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId);
if (!confirmExpectedPalletIds.value.length) confirmError.value = "该退回单没有托盘明细";
})
.catch((error) => {
if (session === confirmSession && generation === detailGeneration) {
confirmError.value = errorMessage(error, "退回单详情加载失败,请稍后重试");
}
})
.finally(() => {
if (session === confirmSession && generation === detailGeneration) {
detailLoading.value = false;
}
});
}
function handleConfirmScan(qrToken) {
if (!confirmForm.returnId || !confirmExpectedPalletIds.value.length || confirmPending.value || scanLoading.confirm) return;
const session = confirmSession;
const generation = ++confirmScanGeneration;
scanLoading.confirm = true;
confirmError.value = "";
scanReturnPallet(qrToken, { mode: "confirm", returnId: confirmForm.returnId })
.then((response) => {
if (session !== confirmSession || generation !== confirmScanGeneration) return;
const pallet = response.data;
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
confirmError.value = "该托盘不属于所选退回单";
return;
}
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msg("该托盘已完成扫码");
return;
}
confirmScannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
});
confirmIdempotencyKey.value = "";
})
.catch((error) => {
if (session === confirmSession && generation === confirmScanGeneration) {
confirmError.value = errorMessage(error, "到仓托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (session === confirmSession && generation === confirmScanGeneration) {
scanLoading.confirm = false;
}
});
}
function handleConfirmScanInvalid(message) {
confirmError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function isConfirmPalletScanned(palletId) {
return confirmScannedPallets.value.some((item) => item.palletId === palletId);
}
function sameIdSet(left, right) {
if (left.length !== right.length) return false;
const leftSet = new Set(left);
const rightSet = new Set(right);
return (
leftSet.size === left.length &&
rightSet.size === right.length &&
right.every((id) => leftSet.has(id))
);
}
function ensureConfirmIdempotencyKey(returnId) {
if (!confirmIdempotencyKey.value) confirmIdempotencyKey.value = createIdempotencyKey();
return confirmIdempotencyKey.value;
}
async function submitReturnConfirm() {
if (confirmPending.value || detailLoading.value || scanLoading.confirm) return;
const returnId = confirmForm.returnId;
const expectedIds = [...confirmExpectedPalletIds.value];
const scannedIds = confirmScannedPallets.value.map((item) => item.palletId);
if (!returnId || !sameIdSet(expectedIds, scannedIds)) {
confirmError.value = "必须逐个扫描并完整匹配退回单全部托盘";
return;
}
confirmSubmitLoading.value = true;
confirmError.value = "";
try {
const latest = (await getReturn(returnId)).data;
const latestIds = (Array.isArray(latest.items) ? latest.items : []).map((item) => item.palletId);
if (latest.status !== "REQUESTED" || !sameIdSet(latestIds, scannedIds)) {
confirmError.value = "退回单状态或托盘明细已变化,请重新选择并扫码";
return;
}
await confirmReturn(returnId, {
scannedPalletIds: scannedIds,
idempotencyKey: ensureConfirmIdempotencyKey(returnId),
});
confirmIdempotencyKey.value = "";
proxy.$modal.msgSuccess("退回确认成功");
resetConfirmOperation();
loadReturnList();
} catch (error) {
confirmError.value = `${errorMessage(error, "退回确认失败")};直接重试将复用本次幂等键`;
} finally {
confirmSubmitLoading.value = false;
}
}
onShow(() => {
loadReturnList();
loadWarehouseOptions();
});
</script>
@@ -0,0 +1,563 @@
<template>
<view class="min-h-screen bg-gray-50">
<scroll-view scroll-y class="h-screen">
<view class="space-y-4 p-4 pb-24">
<view class="flex items-center justify-between">
<view>
<text class="block text-xl font-bold text-gray-800">{{ activeTab === 'receipt' ? '收货核对' : '发货出库' }}</text>
<text class="mt-1 block text-xs text-gray-500">
{{ activeTab === 'receipt' ? '扫描原托盘标签,逐托核对目标现场收货' : '选择来源仓库和目标现场,扫码创建整托发货单' }}
</text>
</view>
<button
v-if="canDraft && activeTab === 'shipment'"
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
size="mini"
:disabled="operationPending"
@click="startNewDraft"
>
新建草稿
</button>
</view>
<TestBatchBanner />
<view v-if="canReceive" class="flex gap-3">
<button v-if="canShipment" size="mini" :class="activeTab === 'shipment' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'shipment'">发货出库</button>
<button size="mini" :disabled="operationPending || editorVisible" :class="activeTab === 'receipt' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'receipt'">收货核对</button>
</view>
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有发货或收货权限</text>
</view>
<ShipmentReceiptPanel v-else-if="canReceive && activeTab === 'receipt'" @received="loadShipmentList" />
<template v-else-if="canShipment && activeTab === 'shipment'">
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-4 flex items-center justify-between">
<text class="font-bold text-gray-800">
{{ editing ? "编辑发货草稿" : "新建发货草稿" }}
</text>
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">来源仓库</text>
<picker
:range="warehouseOptions"
range-key="name"
:value="warehouseIndex"
:disabled="optionLoading || detailLoading || operationPending"
@change="handleWarehouseChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedWarehouseName || "请选择来源仓库" }}
</view>
</picker>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">目标客户现场</text>
<picker
:range="siteOptions"
range-key="name"
:value="siteIndex"
:disabled="optionLoading || detailLoading || operationPending"
@change="handleSiteChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedSiteName || "请选择目标现场" }}
</view>
</picker>
</view>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
<PalletScanInput
:disabled="!form.sourceWarehouseId || !form.targetSiteId || detailLoading || operationPending"
:loading="scanLoading"
@scan="handleShipmentScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
仅接受来源仓库内有剩余包数的 FILLED_IN_WAREHOUSE 托盘。
</text>
</view>
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
<view
v-for="pallet in scannedPallets"
:key="pallet.palletId"
class="rounded-xl bg-orange-50 px-3 py-2"
>
<view class="flex items-center justify-between">
<view>
<text class="block text-sm font-medium text-gray-700">
{{ pallet.palletCode || `#${pallet.palletId}` }}
</text>
<text class="block text-xs text-gray-500">
{{ pallet.strainName || pallet.strainCode || "未提供菌种" }} · 剩余 {{ pallet.remainingPackages }} 包
</text>
</view>
<button
class="text-xs text-red-500"
size="mini"
:disabled="operationPending || detailLoading"
@click="removeScannedPallet(pallet.palletId)"
>
移除
</button>
</view>
<text class="mt-1 block text-xs text-gray-400">
批次:{{ pallet.batchNo || "—" }}
</text>
</view>
</view>
<view class="flex gap-3">
<button
class="flex-1 rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
@click="saveDraft"
>
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
</button>
<button
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
:disabled="operationPending"
@click="cancelEditor"
>
取消
</button>
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">发货单</text>
<button
class="text-xs text-blue-500"
size="mini"
:disabled="loading || operationPending"
@click="loadShipmentList"
>
刷新
</button>
</view>
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
<view v-else-if="!shipmentList.length" class="py-6 text-center text-xs text-gray-400">
暂无发货单
</view>
<view
v-for="row in shipmentList"
v-else
:key="row.shipmentId"
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
>
<view class="flex items-center justify-between">
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
<text :class="row.status === 'DRAFT' || row.receiptState === 'PENDING' ? 'text-orange-500' : row.receiptState === 'LEGACY' ? 'text-gray-500' : 'text-green-500'" class="text-xs">
{{ shipmentStatusLabel(row) }}
</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
{{ warehouseName(row.sourceWarehouseId) }} → {{ siteName(row.targetSiteId) }} · 托盘 {{ row.items?.length || 0 }} 个
</text>
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
<button
v-if="canEdit"
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
size="mini"
:disabled="operationPending"
@click="startEditDraft(row)"
>
编辑
</button>
<button
v-if="canConfirm"
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
size="mini"
:disabled="operationPending"
@click="confirmDraft(row)"
>
{{ confirmLoading ? "确认中" : "确认" }}
</button>
</view>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import ShipmentReceiptPanel from "@/components/business/ShipmentReceiptPanel.vue";
import {
addShipment,
confirmShipment,
getShipment,
getShipmentSiteOptions,
getShipmentWarehouseOptions,
listShipment,
scanShipmentPallet,
updateShipment,
} from "@/api/business/shipment";
import { createIdempotencyKey } from "@/utils/palletScan";
const { proxy } = getCurrentInstance();
const shipmentList = ref([]);
const warehouseOptions = ref([]);
const siteOptions = ref([]);
const scannedPallets = ref([]);
const editorVisible = ref(false);
const editing = ref(false);
const loading = ref(false);
const optionLoading = ref(false);
const detailLoading = ref(false);
const scanLoading = ref(false);
const submitLoading = ref(false);
const confirmLoading = ref(false);
const listError = ref("");
const formError = ref("");
const scanError = ref("");
const confirmError = ref("");
const form = reactive({ shipmentId: null, sourceWarehouseId: null, targetSiteId: null });
const confirmState = reactive({ shipmentId: null, key: "" });
let listGeneration = 0;
let scanGeneration = 0;
const canShipment = computed(() =>
proxy.$auth.hasPermiOr([
"business:shipment:list",
"business:shipment:query",
"business:shipment:add",
"business:shipment:edit",
"business:shipment:confirm",
]),
);
const canReceive = computed(() => proxy.$auth.hasPermi("business:shipment:receive"));
const canPage = computed(() => canShipment.value || canReceive.value);
const activeTab = ref(canShipment.value ? "shipment" : "receipt");
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
const canDraft = computed(() =>
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
);
const canEdit = computed(() =>
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:edit"]),
);
const canConfirm = computed(() => proxy.$auth.hasPermi("business:shipment:confirm"));
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
const operationPending = computed(
() =>
submitLoading.value ||
confirmLoading.value ||
detailLoading.value,
);
const warehouseIndex = computed(() => {
const index = warehouseOptions.value.findIndex(
(item) => item.id === form.sourceWarehouseId,
);
return index >= 0 ? index : 0;
});
const siteIndex = computed(() => {
const index = siteOptions.value.findIndex((item) => item.id === form.targetSiteId);
return index >= 0 ? index : 0;
});
const selectedWarehouseName = computed(
() => warehouseOptions.value.find((item) => item.id === form.sourceWarehouseId)?.name || "",
);
const selectedSiteName = computed(
() => siteOptions.value.find((item) => item.id === form.targetSiteId)?.name || "",
);
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function shipmentStatusLabel(row) {
if (row.status === "DRAFT") return "草稿";
return { PENDING: "待收货", RECEIVED: "已收货", LEGACY: "历史未登记收货" }[row.receiptState] || "已确认";
}
function warehouseName(id) {
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
}
function siteName(id) {
return siteOptions.value.find((item) => item.id === id)?.name || `现场 #${id || "-"}`;
}
function resetEditor() {
scanGeneration += 1;
editorVisible.value = false;
editing.value = false;
form.shipmentId = null;
form.sourceWarehouseId = null;
form.targetSiteId = null;
scannedPallets.value = [];
detailLoading.value = false;
scanLoading.value = false;
formError.value = "";
scanError.value = "";
}
function loadShipmentList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listShipment({ pageNum: 1, pageSize: 20 })
.then((response) => {
if (generation !== listGeneration) return;
shipmentList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
shipmentList.value = [];
listError.value = errorMessage(error, "发货单列表加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function loadOptions() {
if (!proxy.$auth.hasPermi("business:shipment:query")) return;
optionLoading.value = true;
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()])
.then(([warehouseResponse, siteResponse]) => {
warehouseOptions.value = Array.isArray(warehouseResponse.data)
? warehouseResponse.data
: [];
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : [];
})
.catch((error) => {
warehouseOptions.value = [];
siteOptions.value = [];
formError.value = errorMessage(error, "仓库或现场选项加载失败,请稍后重试");
})
.finally(() => {
optionLoading.value = false;
});
}
function startNewDraft() {
if (!canDraft.value || operationPending.value) return;
resetEditor();
editorVisible.value = true;
loadOptions();
}
function startEditDraft(row) {
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
resetEditor();
editorVisible.value = true;
editing.value = true;
form.shipmentId = row.shipmentId;
loadOptions();
detailLoading.value = true;
getShipment(row.shipmentId)
.then((response) => {
const detail = response.data;
if (!detail || detail.status !== "DRAFT") {
formError.value = "该发货单已不是草稿状态,不能编辑";
return;
}
form.sourceWarehouseId = detail.sourceWarehouseId;
form.targetSiteId = detail.targetSiteId;
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
(item) => ({
palletId: item.palletId,
palletCode: item.palletCode || `#${item.palletId}`,
strainName: item.strainName || item.strainCode,
batchNo: item.batchNo,
remainingPackages: item.remainingPackages ?? item.packageQty ?? 0,
}),
);
})
.catch((error) => {
formError.value = errorMessage(error, "发货单详情加载失败,请稍后重试");
})
.finally(() => {
detailLoading.value = false;
});
}
function cancelEditor() {
if (operationPending.value) return;
resetEditor();
}
function clearScannedPallets() {
scannedPallets.value = [];
scanError.value = "";
scanGeneration += 1;
}
function handleWarehouseChange(event) {
const option = warehouseOptions.value[Number(event.detail.value)];
if (!option || option.id === form.sourceWarehouseId) return;
form.sourceWarehouseId = option.id;
clearScannedPallets();
}
function handleSiteChange(event) {
const option = siteOptions.value[Number(event.detail.value)];
if (!option || option.id === form.targetSiteId) return;
form.targetSiteId = option.id;
clearScannedPallets();
}
function handleShipmentScan(qrToken) {
if (
!form.sourceWarehouseId ||
!form.targetSiteId ||
operationPending.value ||
scanLoading.value
) {
scanError.value = "请先选择来源仓库和目标现场";
return;
}
const generation = ++scanGeneration;
scanLoading.value = true;
scanError.value = "";
scanShipmentPallet(qrToken, {
sourceWarehouseId: form.sourceWarehouseId,
targetSiteId: form.targetSiteId,
})
.then((response) => {
if (generation !== scanGeneration) return;
const pallet = response.data;
if (pallet?.flowState !== "FILLED_IN_WAREHOUSE" || pallet.remainingPackages <= 0) {
scanError.value = "该托盘不是来源仓库内的有料托盘,不能发货";
return;
}
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
proxy.$modal.msg("该托盘已在当前发货草稿中");
return;
}
scannedPallets.value.push({
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
strainName: pallet.strainName || pallet.strainCode,
batchNo: pallet.batchNo,
remainingPackages: pallet.remainingPackages,
});
})
.catch((error) => {
if (generation === scanGeneration) {
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration) scanLoading.value = false;
});
}
function handleScanInvalid(message) {
scanError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function removeScannedPallet(palletId) {
if (operationPending.value) return;
scannedPallets.value = scannedPallets.value.filter(
(item) => item.palletId !== palletId,
);
}
function saveDraft() {
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
if (!form.sourceWarehouseId || !form.targetSiteId) {
formError.value = "请选择来源仓库和目标现场";
return;
}
if (!scannedPallets.value.length) {
formError.value = "请至少扫描一个有料托盘";
return;
}
submitLoading.value = true;
formError.value = "";
const payload = {
sourceWarehouseId: form.sourceWarehouseId,
targetSiteId: form.targetSiteId,
palletIds: scannedPallets.value.map((item) => item.palletId),
};
const submitRequest = editing.value
? updateShipment({ ...payload, shipmentId: form.shipmentId })
: addShipment(payload);
submitRequest
.then((response) => {
proxy.$modal.msgSuccess(response.msg || "发货草稿保存成功");
resetEditor();
loadShipmentList();
})
.catch((error) => {
formError.value = errorMessage(error, "发货草稿保存失败,请根据提示修正后重试");
})
.finally(() => {
submitLoading.value = false;
});
}
function askConfirm(content) {
return new Promise((resolve) => {
uni.showModal({
title: "系统提示",
content,
cancelText: "取消",
confirmText: "确定",
success: (result) => resolve(Boolean(result.confirm)),
});
});
}
function ensureConfirmIdempotencyKey(shipmentId) {
if (confirmState.shipmentId !== shipmentId) {
confirmState.shipmentId = shipmentId;
confirmState.key = "";
}
if (!confirmState.key) confirmState.key = createIdempotencyKey();
return confirmState.key;
}
function clearConfirmIdempotency() {
confirmState.shipmentId = null;
confirmState.key = "";
}
async function confirmDraft(row) {
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
if (!(await askConfirm(`确认发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`))) return;
confirmLoading.value = true;
confirmError.value = "";
try {
await confirmShipment(row.shipmentId, {
idempotencyKey: ensureConfirmIdempotencyKey(row.shipmentId),
});
clearConfirmIdempotency();
proxy.$modal.msgSuccess("发货确认成功");
loadShipmentList();
} catch (error) {
confirmError.value = `${errorMessage(error, "发货确认失败")};再次确认将复用本次幂等键`;
} finally {
confirmLoading.value = false;
}
}
onShow(() => {
loadShipmentList();
});
</script>
@@ -0,0 +1,820 @@
<template>
<view class="min-h-screen bg-gray-50">
<scroll-view scroll-y class="h-screen">
<view class="space-y-4 p-4 pb-24">
<view>
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
<text class="mt-1 block text-xs text-gray-500">
扫描现场托盘,确认入隧后记录实际使用包数,产线可选
</text>
</view>
<TestBatchBanner />
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
</view>
<template v-else>
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
<text class="block font-bold text-gray-800">提交结果核对</text>
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
</text>
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
<text class="block text-sm text-gray-700">
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
</text>
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
恢复重试 / 核对
</button>
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
已人工核对,解除待确认
</button>
</view>
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">扫描待使用托盘</text>
<button class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
重新扫描
</button>
</view>
<PalletScanInput
:disabled="operationPending || optionLoading"
:loading="scanLoading"
@scan="handleUsageScan"
@invalid="handleScanInvalid"
@duplicate="handleScanDuplicate"
/>
<text class="mt-2 block text-xs text-gray-400">
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
</text>
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
<view class="flex items-center justify-between">
<text class="font-medium text-gray-700">
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
</text>
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
</text>
<text class="mt-1 block text-xs text-gray-500">
生产日期:{{ scannedPallet.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
</text>
<text class="mt-1 block text-xs text-gray-500">
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
</text>
</view>
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
<view class="flex items-center justify-between">
<text class="text-sm text-gray-700">先到先用建议</text>
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
刷新建议
</button>
</view>
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</text>
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">托盘入隧</text>
<view v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
{{ selectedTunnelName }}(已入隧,不可切换)
</view>
<picker
v-else
:range="tunnelPickerOptions"
range-key="label"
:value="tunnelIndex"
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
@change="handleTunnelChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedTunnelName || "请选择使用隧道" }}
</view>
</picker>
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
先确认入隧,再提交使用包数。
</text>
<view class="mt-3">
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
</view>
<button
v-if="!tunnelLocked"
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
@click="enterTunnel"
>
{{ entryLoading ? "入隧中" : "确认入隧" }}
</button>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">使用信息</text>
<view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">使用产线(可选)</text>
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
{{ selectedLineName }}(已由托盘绑定)
</view>
<picker
v-else
:range="linePickerOptions"
range-key="name"
:value="lineIndex"
:disabled="operationPending || lineOptionLoading || !scannedPallet"
@change="handleLineChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedLineName || "不选择产线" }}
</view>
</picker>
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
<input
v-model="form.usedPackages"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
type="number"
placeholder="请输入正整数"
:disabled="operationPending || !scannedPallet"
@input="markPayloadChanged"
/>
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
</text>
</view>
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
<text class="mt-1 block text-xs text-gray-600">
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
</text>
</view>
<button
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
@click="submitUsage"
>
{{ submitLoading ? "提交中" : "确认使用" }}
</button>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">最近使用记录</text>
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
刷新
</button>
</view>
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
暂无使用记录
</view>
<view
v-for="row in usageList"
v-else
:key="row.usageId"
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
>
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
<text class="mt-1 block text-xs text-gray-500">
托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
</text>
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
产线 #{{ row.lineId }}
</text>
<text class="mt-1 block text-xs text-gray-400">
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
</text>
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
</view>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import {
addUsage,
enterUsageTunnel,
getUsageFifoRecommendation,
getUsageLineOptions,
getUsageTunnelOptions,
listUsage,
scanUsagePallet,
} from "@/api/business/usage";
import { createIdempotencyKey } from "@/utils/palletScan";
import config from "@/config";
import constant from "@/utils/constant";
import storage from "@/utils/storage";
const { proxy } = getCurrentInstance();
const usageList = ref([]);
const lineOptions = ref([]);
const tunnelOptions = ref([]);
const scannedPallet = ref(null);
const usageResult = ref(null);
const lineLocked = ref(false);
const tunnelLocked = ref(false);
const loading = ref(false);
const optionLoading = ref(false);
const lineOptionLoading = ref(false);
const lineOptionError = ref("");
const fifoLoading = ref(false);
const fifoMessage = ref("");
const fifoRequiresSkipReason = ref(false);
const scanLoading = ref(false);
const submitLoading = ref(false);
const entryLoading = ref(false);
const listError = ref("");
const operationError = ref("");
const submitError = ref("");
const pendingOperations = ref([]);
const recoveryStorageKey = ref("");
const recoveryLoading = ref(false);
const recoveryError = ref("");
const recoveryMessage = ref("");
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
let listGeneration = 0;
let scanGeneration = 0;
let optionGeneration = 0;
let fifoGeneration = 0;
let operationSession = 0;
const canPage = computed(() =>
proxy.$auth.hasPermiOr([
"business:usage:list",
"business:usage:query",
"business:usage:add",
]),
);
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
const canOperate = computed(() =>
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
);
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
const linePickerOptions = computed(() => [
{ id: null, name: "不选择产线" },
...lineOptions.value,
]);
const lineIndex = computed(() => {
const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
return index >= 0 ? index : 0;
});
const selectedLineName = computed(
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
);
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
...item,
label: formatTunnelName(item),
})));
const tunnelIndex = computed(() => {
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
return index >= 0 ? index : 0;
});
const selectedTunnelName = computed(() => {
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
if (option) return formatTunnelName(option);
if (!form.tunnelId) return "";
return formatTunnelName({
id: form.tunnelId,
name: scannedPallet.value?.tunnelName,
code: scannedPallet.value?.tunnelCode,
});
});
function formatTunnelName(option) {
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function currentRecoveryStorageKey() {
const userId = storage.get(constant.id);
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
}
function loadPendingOperations() {
try {
const key = currentRecoveryStorageKey();
const records = uni.getStorageSync(key) || [];
if (!Array.isArray(records) || records.some((item) =>
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
recoveryStorageKey.value = key;
pendingOperations.value = records;
recoveryError.value = "";
return true;
} catch (error) {
pendingOperations.value = [];
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
return false;
}
}
function savePendingOperations(records) {
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
uni.setStorageSync(recoveryStorageKey.value, records);
pendingOperations.value = records;
}
function pendingForPallet() {
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
}
function operationSignature(action, payload) {
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
}
function prepareOperation(action, payload) {
if (!loadPendingOperations()) throw new Error(recoveryError.value);
const pending = pendingForPallet();
if (pending) {
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
}
return pending;
}
const operation = {
action,
palletId: scannedPallet.value.palletId,
palletCode: scannedPallet.value.palletCode,
tunnelName: selectedTunnelName.value,
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
};
savePendingOperations([...pendingOperations.value, operation]);
return operation;
}
function completeOperation(operation, response) {
const result = response?.data;
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
const confirmed = result && (operation.action === "usage"
? Number.isInteger(result.usageId) && result.usageId > 0 &&
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
typeof result.flowState === "string" && result.flowState.trim()
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
typeof result.traceNo === "string" && result.traceNo.trim());
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
if (!loadPendingOperations()) throw new Error(recoveryError.value);
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
return result;
}
function isCurrentOperation(session, scopeKey) {
try {
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
} catch {
return false;
}
}
function validatedFifoSkipReason() {
const reason = form.fifoSkipReason.trim();
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
return reason;
}
function handleFifoRejection(error, operation) {
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
const response = error?.response;
const body = response?.data && typeof response.data === "object" ? response.data : response;
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
try {
if (!loadPendingOperations()) return true;
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
return true;
}
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
} catch (storageError) {
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
return true;
}
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
fifoRequiresSkipReason.value = true;
loadFifoRecommendation();
} else {
recoveryLoading.value = false;
handleUsageScan(operation.payload.qrToken, operation);
}
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
submitError.value = operationError.value;
recoveryMessage.value = operationError.value;
return true;
}
async function releasePendingOperation(item) {
if (operationPending.value) return;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
if (!operation) return;
recoveryLoading.value = true;
try {
const confirmed = await new Promise((resolve, reject) => {
uni.showModal({
title: "解除本机待确认记录",
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
cancelText: "取消",
confirmText: "确认解除",
success: (result) => resolve(Boolean(result.confirm)),
fail: reject,
});
});
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
if (!loadPendingOperations()) return;
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
resetUsageOperation();
}
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
} finally {
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
}
}
async function retryPendingOperation(item) {
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
if (!operation) return;
resetUsageOperation();
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
recoveryLoading.value = true;
recoveryMessage.value = "";
try {
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
const result = completeOperation(operation, response);
recoveryMessage.value = operation.action === "usage"
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
loadUsageList();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
} finally {
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
}
}
function loadUsageList() {
if (!canList.value) return;
const generation = ++listGeneration;
loading.value = true;
listError.value = "";
listUsage({ pageNum: 1, pageSize: 20 })
.then((response) => {
if (generation !== listGeneration) return;
usageList.value = Array.isArray(response.rows) ? response.rows : [];
})
.catch((error) => {
if (generation !== listGeneration) return;
usageList.value = [];
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
})
.finally(() => {
if (generation === listGeneration) loading.value = false;
});
}
function resetUsageOperation() {
operationSession += 1;
scanGeneration += 1;
optionGeneration += 1;
fifoGeneration += 1;
scannedPallet.value = null;
usageResult.value = null;
lineOptions.value = [];
tunnelOptions.value = [];
lineLocked.value = false;
tunnelLocked.value = false;
form.qrToken = "";
form.tunnelId = null;
form.lineId = null;
form.usedPackages = "";
form.fifoSkipReason = "";
operationError.value = "";
submitError.value = "";
scanLoading.value = false;
optionLoading.value = false;
lineOptionLoading.value = false;
lineOptionError.value = "";
fifoLoading.value = false;
fifoMessage.value = "";
fifoRequiresSkipReason.value = false;
submitLoading.value = false;
entryLoading.value = false;
recoveryLoading.value = false;
}
function loadUsageOptions(pallet, session, scanRequestGeneration) {
const generation = ++optionGeneration;
const isCurrent = () => generation === optionGeneration &&
session === operationSession && scanRequestGeneration === scanGeneration;
optionLoading.value = true;
lineOptionLoading.value = true;
const lineRequest = pallet.currentLineId == null
? getUsageLineOptions(pallet.siteId)
: Promise.resolve({ data: lineOptions.value });
getUsageTunnelOptions(pallet.siteId)
.then((tunnels) => {
if (!isCurrent()) return;
tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
if (!tunnelLocked.value && !tunnelOptions.value.length) {
operationError.value = "该现场暂无可用隧道";
}
})
.catch((error) => {
if (!isCurrent()) return;
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
})
.finally(() => {
if (isCurrent()) optionLoading.value = false;
});
lineRequest
.then((lines) => {
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
})
.catch((error) => {
if (!isCurrent()) return;
lineOptions.value = [];
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
})
.finally(() => {
if (isCurrent()) lineOptionLoading.value = false;
});
}
async function loadFifoRecommendation() {
if (!scannedPallet.value || !form.qrToken) return;
const generation = ++fifoGeneration;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
const { qrToken, tunnelId } = form;
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
qrToken === form.qrToken && tunnelId === form.tunnelId;
fifoLoading.value = true;
fifoMessage.value = "";
try {
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
if (!isCurrent()) return;
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
} catch {
if (!isCurrent()) return;
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
} finally {
if (isCurrent()) fifoLoading.value = false;
}
}
function handleUsageScan(qrToken, rejectedOperation = null) {
if (operationPending.value || scanLoading.value || optionLoading.value) return;
resetUsageOperation();
if (!loadPendingOperations()) return;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
const generation = ++scanGeneration;
scanLoading.value = true;
scanUsagePallet(qrToken)
.then((response) => {
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
const pallet = response.data;
if (
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
pallet.remainingPackages <= 0
) {
operationError.value = "该托盘当前不可记录使用";
return;
}
scannedPallet.value = {
palletId: pallet.palletId,
palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId,
currentLineId: pallet.currentLineId,
tunnelId: pallet.tunnelId,
tunnelName: pallet.tunnelName,
tunnelCode: pallet.tunnelCode,
strainCode: pallet.strainCode,
strainName: pallet.strainName,
batchNo: pallet.batchNo,
productionDate: pallet.productionDate,
expiryDate: pallet.expiryDate,
remainingPackages: pallet.remainingPackages,
flowState: pallet.flowState,
};
form.qrToken = pallet.qrToken || qrToken;
form.tunnelId = pallet.tunnelId ?? null;
tunnelLocked.value = pallet.tunnelId != null;
if (rejectedOperation) {
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
fifoRequiresSkipReason.value = true;
}
if (pallet.currentLineId != null) {
lineLocked.value = true;
form.lineId = pallet.currentLineId;
lineOptions.value = [
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
];
} else {
lineLocked.value = false;
form.lineId = rejectedOperation?.payload.lineId ?? null;
}
loadUsageOptions(pallet, session, generation);
loadFifoRecommendation();
})
.catch((error) => {
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
scanLoading.value = false;
}
});
}
function handleScanInvalid(message) {
resetUsageOperation();
operationError.value = message;
}
function handleScanDuplicate() {
proxy.$modal.msg("扫码过快,请勿重复扫描");
}
function handleLineChange(event) {
if (lineLocked.value || operationPending.value) return;
const option = linePickerOptions.value[Number(event.detail.value)];
form.lineId = option?.id ?? null;
markPayloadChanged();
}
function handleTunnelChange(event) {
if (tunnelLocked.value || operationPending.value) return;
const option = tunnelOptions.value[Number(event.detail.value)];
if (form.tunnelId === (option?.id ?? null)) return;
form.tunnelId = option?.id ?? null;
markPayloadChanged();
fifoRequiresSkipReason.value = false;
loadFifoRecommendation();
}
function markPayloadChanged() {
if (operationPending.value) return;
submitError.value = "";
}
async function enterTunnel() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
operationError.value = "请先扫描托盘并选择隧道";
return;
}
let fifoSkipReason;
try {
fifoSkipReason = validatedFifoSkipReason();
} catch (error) {
operationError.value = error.message;
return;
}
const session = operationSession;
entryLoading.value = true;
operationError.value = "";
let scopeKey = recoveryStorageKey.value;
let operation;
try {
operation = prepareOperation("entry", {
qrToken: form.qrToken,
tunnelId: form.tunnelId,
...(fifoSkipReason ? { fifoSkipReason } : {}),
});
scopeKey = recoveryStorageKey.value;
const response = await enterUsageTunnel({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
const result = completeOperation(operation, response);
form.tunnelId = result.tunnelId;
scannedPallet.value.tunnelId = result.tunnelId;
tunnelLocked.value = true;
submitError.value = "";
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
loadFifoRecommendation();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
} finally {
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
}
}
async function submitUsage() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
const usedPackages = Number(form.usedPackages);
if (!scannedPallet.value || !form.qrToken) {
submitError.value = "请先扫描待使用托盘";
return;
}
if (!tunnelLocked.value || !form.tunnelId) {
submitError.value = "请先选择隧道并确认入隧";
return;
}
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
submitError.value = "使用包数必须为正整数";
return;
}
let fifoSkipReason;
try {
fifoSkipReason = validatedFifoSkipReason();
} catch (error) {
submitError.value = error.message;
return;
}
if (!loadPendingOperations()) {
submitError.value = recoveryError.value;
return;
}
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
submitError.value = "使用包数不能超过当前剩余包数";
return;
}
const session = operationSession;
submitLoading.value = true;
submitError.value = "";
const scopeKey = recoveryStorageKey.value;
let operation;
try {
operation = prepareOperation("usage", {
qrToken: form.qrToken,
tunnelId: form.tunnelId,
lineId: form.lineId,
usedPackages,
...(fifoSkipReason ? { fifoSkipReason } : {}),
});
const response = await addUsage({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
usageResult.value = completeOperation(operation, response);
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
loadUsageList();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
} finally {
if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
}
}
function leaveUsagePage() {
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
resetUsageOperation();
listGeneration += 1;
loading.value = false;
usageList.value = [];
listError.value = "";
recoveryMessage.value = "";
}
onHide(leaveUsagePage);
onUnload(leaveUsagePage);
onShow(() => {
loadPendingOperations();
loadUsageList();
});
loadPendingOperations();
</script>
@@ -10,7 +10,7 @@
>1. 服务条款的确认和接纳</text
>
<text
>欢迎使用ShiErFeng-FastAPI移动端框架(以下简称“本产品”)。本产品的服务所有权和运作权归ShiErFeng-FastAPI所有。用户在使用本产品前,请仔细阅读本协议。当您点击“同意”或实际使用本产品服务时,即视为您已阅读并完全同意本协议的所有条款。</text
>欢迎使用智链云枢管理系统(以下简称“本产品”)。本产品的服务所有权和运作权归智链云枢管理系统所有。用户在使用本产品前,请仔细阅读本协议。当您点击“同意”或实际使用本产品服务时,即视为您已阅读并完全同意本协议的所有条款。</text
>
</view>
@@ -42,7 +42,7 @@
<view>
<text class="mb-2 block font-bold text-gray-800">5. 知识产权声明</text>
<text
>本产品包含的所有内容(包括但不限于文字、图片、音频、视频、软件代码等)的知识产权归ShiErFeng-FastAPI或相关权利人所有。未经授权,任何人不得擅自使用、复制、修改或传播。</text
>本产品包含的所有内容(包括但不限于文字、图片、音频、视频、软件代码等)的知识产权归智链云枢管理系统或相关权利人所有。未经授权,任何人不得擅自使用、复制、修改或传播。</text
>
</view>
@@ -7,7 +7,7 @@
<view class="space-y-6 text-justify text-sm leading-relaxed text-gray-600">
<view>
<text
>ShiErFeng-FastAPI非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用本产品前仔细阅读本政策。</text
>智链云枢管理系统非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用本产品前仔细阅读本政策。</text
>
</view>
File diff suppressed because it is too large Load Diff
+333 -119
View File
@@ -1,118 +1,94 @@
<template>
<view
class="flex h-full flex-col items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-6 pb-20 overflow-hidden"
>
<!-- Logo Section -->
<view class="mb-6 flex flex-col items-center">
<view
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
>
<image
class="size-10"
:src="globalConfig.appInfo.logo"
mode="widthFix"
/>
</view>
<text class="text-xl font-bold tracking-wide text-gray-800"
>ShiErFeng-FastAPI移动端登录</text
>
</view>
<view class="login-page">
<image
class="login-background"
src="/static/login-background.jpg"
mode="aspectFill"
/>
<view class="login-scrim" />
<!-- Form Section -->
<view class="w-full rounded-3xl bg-white/80 p-6 shadow-xl backdrop-blur-md">
<!-- Username -->
<view class="group relative mb-5">
<view
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
>
<view class="i-mdi-account text-xl"></view>
</view>
<input
v-model="loginForm.username"
class="h-12 w-full rounded-xl bg-gray-50 pl-12 pr-4 text-sm text-gray-700 outline-none transition-all focus:bg-white focus:ring-2 focus:ring-blue-400"
type="text"
placeholder="请输入账号"
maxlength="30"
/>
</view>
<!-- Password -->
<view class="group relative mb-5">
<view
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
>
<view class="i-mdi-lock text-xl"></view>
</view>
<input
v-model="loginForm.password"
type="password"
class="h-12 w-full rounded-xl bg-gray-50 pl-12 pr-4 text-sm text-gray-700 outline-none transition-all focus:bg-white focus:ring-2 focus:ring-blue-400"
placeholder="请输入密码"
maxlength="20"
/>
</view>
<!-- Captcha -->
<view
class="mb-8 flex items-center justify-between"
v-if="captchaEnabled"
>
<view class="group relative mr-3 flex-1">
<view
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
>
<view class="i-mdi-security text-xl"></view>
<view class="login-content">
<view class="login-card">
<view class="brand-lockup">
<view class="brand-logo-frame">
<image
class="brand-logo"
:src="globalConfig.appInfo.logo"
mode="aspectFit"
/>
</view>
<input
v-model="loginForm.code"
type="number"
class="h-12 w-full rounded-xl bg-gray-50 pl-12 pr-4 text-sm text-gray-700 outline-none transition-all focus:bg-white focus:ring-2 focus:ring-blue-400"
placeholder="验证码"
maxlength="4"
/>
<text class="brand-title">{{ globalConfig.appInfo.name }}</text>
<text class="brand-subtitle">仓储运营管理平台</text>
</view>
<view
class="h-12 w-28 overflow-hidden rounded-xl bg-gray-100 shadow-sm transition-opacity active:opacity-80"
@click="getCode"
>
<image :src="codeUrl" class="size-full object-cover"></image>
<view class="login-form">
<view class="field-control">
<view class="field-icon i-mdi-account" />
<input
v-model="loginForm.username"
class="field-input"
type="text"
placeholder="请输入您的账号"
maxlength="30"
confirm-type="next"
/>
</view>
<view class="field-control">
<view class="field-icon i-mdi-lock" />
<input
v-model="loginForm.password"
class="field-input"
type="password"
placeholder="请输入您的密码"
maxlength="20"
confirm-type="done"
@confirm="handleLogin"
/>
</view>
<view v-if="captchaEnabled" class="captcha-row">
<view class="field-control captcha-input">
<view class="field-icon i-mdi-security" />
<input
v-model="loginForm.code"
class="field-input"
type="number"
placeholder="请输入验证码"
maxlength="4"
confirm-type="done"
@confirm="handleLogin"
/>
</view>
<view class="captcha-image-wrap" @click="getCode">
<image
:src="codeUrl"
class="captcha-image"
mode="aspectFill"
/>
</view>
</view>
<button
class="login-button"
:loading="loading"
:disabled="loading"
@click="handleLogin"
>
{{ loading ? "登录中..." : "登 录" }}
</button>
</view>
</view>
<!-- Login Button -->
<button
@click="handleLogin"
class="flex h-12 w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-indigo-600 text-base font-semibold text-white shadow-lg shadow-blue-500/30 transition-transform active:scale-95"
>
登 录
</button>
<!-- Footer Links -->
<view class="mt-6 flex flex-col items-center space-y-3">
<view class="flex items-center text-sm text-gray-500" v-if="register">
<view v-if="register" class="register-tip">
<text>没有账号?</text>
<text
@click="handleUserRegister"
class="ml-1 font-medium text-blue-600 active:opacity-70"
>立即注册</text
>
<text class="action-link" @click="handleUserRegister">立即注册</text>
</view>
<view
class="flex flex-wrap items-center justify-center text-xs text-gray-400"
>
<view class="agreement-tip">
<text>登录即代表同意</text>
<text
@click="handleUserAgrement"
class="mx-1 text-blue-500 active:opacity-70"
>《用户协议》</text
>
<text class="action-link" @click="handleUserAgrement">《用户协议》</text>
<text>和</text>
<text
@click="handlePrivacy"
class="mx-1 text-blue-500 active:opacity-70"
>《隐私协议》</text
>
<text class="action-link" @click="handlePrivacy">《隐私协议》</text>
</view>
</view>
</view>
@@ -129,13 +105,14 @@ import { useConfigStore, useUserStore } from "@/store";
const { proxy } = getCurrentInstance();
const globalConfig = useConfigStore().config;
const codeUrl = ref("");
const loading = ref(false);
// 验证码开关
const captchaEnabled = ref(true);
// 用户注册开关
const register = ref(false);
const loginForm = ref({
username: "admin",
password: "admin123",
password: "",
code: "",
uuid: "",
});
@@ -160,6 +137,8 @@ function getCode() {
getCodeImg().then((res) => {
captchaEnabled.value =
res.captchaEnabled === undefined ? true : res.captchaEnabled;
register.value =
res.registerEnabled === undefined ? false : res.registerEnabled;
if (captchaEnabled.value) {
codeUrl.value = "data:image/gif;base64," + res.img;
loginForm.value.uuid = res.uuid;
@@ -176,6 +155,7 @@ async function handleLogin() {
} else if (loginForm.value.code === "" && captchaEnabled.value) {
proxy.$modal.msgError("请输入验证码");
} else {
loading.value = true;
proxy.$modal.loading("登录中,请耐心等待...");
pwdLogin();
}
@@ -183,25 +163,26 @@ async function handleLogin() {
// 密码登录
async function pwdLogin() {
useUserStore()
.login(loginForm.value)
.then(() => {
proxy.$modal.closeLoading();
loginSuccess();
})
.catch(() => {
if (captchaEnabled.value) {
getCode();
}
});
try {
await useUserStore().login(loginForm.value);
proxy.$modal.closeLoading();
loading.value = false;
loginSuccess();
} catch (error) {
proxy.$modal.closeLoading();
loading.value = false;
if (captchaEnabled.value) {
getCode();
}
}
}
// 登录成功后,处理函数
function loginSuccess(result) {
function loginSuccess() {
// 设置用户信息
useUserStore()
.getInfo()
.then((res) => {
.then(() => {
proxy.$tab.reLaunch("/pages/index");
});
}
@@ -219,6 +200,239 @@ getCode();
<style lang="scss" scoped>
page {
background-color: #ffffff;
background-color: #eef8f8;
}
.login-page {
position: relative;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 48rpx 32rpx;
background: #eef8f8;
}
.login-background,
.login-scrim {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.login-background {
z-index: 0;
}
.login-scrim {
z-index: 1;
background: rgba(241, 250, 250, 0.22);
}
.login-content {
position: relative;
z-index: 2;
display: flex;
width: 100%;
max-width: 720rpx;
justify-content: center;
}
.login-card {
width: 100%;
border: 1rpx solid rgba(255, 255, 255, 0.88);
border-radius: 20rpx;
padding: 56rpx 44rpx 42rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 20rpx 60rpx rgba(40, 34, 92, 0.18);
}
.brand-lockup {
display: flex;
align-items: center;
margin-bottom: 46rpx;
text-align: center;
flex-direction: column;
}
.brand-logo-frame {
display: flex;
width: 400rpx;
max-width: 100%;
height: 136rpx;
box-sizing: border-box;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(255, 255, 255, 0.12);
border-radius: 18rpx;
padding: 16rpx 22rpx;
background: #28225c;
box-shadow: 0 10rpx 22rpx rgba(40, 34, 92, 0.16);
}
.brand-logo {
width: 100%;
height: 100%;
}
.brand-title {
margin-top: 18rpx;
color: #28225c;
font-size: 34rpx;
font-weight: 600;
line-height: 1.35;
}
.brand-subtitle {
margin-top: 10rpx;
color: #6c7786;
font-size: 24rpx;
line-height: 1.4;
}
.login-form {
width: 100%;
}
.field-control {
display: flex;
align-items: center;
height: 96rpx;
margin-bottom: 24rpx;
border: 1rpx solid #d9e6e7;
border-radius: 10rpx;
padding: 0 24rpx;
background: #ffffff;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.field-control:focus-within {
border-color: #008596;
box-shadow: 0 0 0 4rpx rgba(0, 133, 150, 0.1);
}
.field-icon {
flex: 0 0 36rpx;
margin-right: 18rpx;
color: #008596;
font-size: 36rpx;
}
.field-input {
flex: 1;
min-width: 0;
height: 96rpx;
color: #28333f;
font-size: 28rpx;
line-height: 96rpx;
}
.captcha-row {
display: flex;
align-items: center;
gap: 18rpx;
}
.captcha-input {
flex: 1;
min-width: 0;
}
.captcha-image-wrap {
flex: 0 0 220rpx;
width: 220rpx;
height: 96rpx;
margin-bottom: 24rpx;
overflow: hidden;
border: 1rpx solid #d9e6e7;
border-radius: 10rpx;
background: #f4f8f8;
}
.captcha-image {
width: 100%;
height: 100%;
}
.login-button {
width: 100%;
height: 96rpx;
margin: 8rpx 0 0;
border: 0;
border-radius: 10rpx;
background: #008596;
box-shadow: 0 12rpx 24rpx rgba(0, 133, 150, 0.2);
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
line-height: 96rpx;
transition: opacity 160ms ease, transform 160ms ease;
}
.login-button::after {
border: 0;
}
.login-button:active {
opacity: 0.84;
transform: translateY(1rpx);
}
.login-button[disabled] {
opacity: 0.72;
}
.register-tip,
.agreement-tip {
display: flex;
align-items: center;
justify-content: center;
color: #7b8791;
font-size: 24rpx;
line-height: 1.6;
text-align: center;
}
.register-tip {
margin-top: 30rpx;
}
.agreement-tip {
flex-wrap: wrap;
margin-top: 22rpx;
color: #9aa5ad;
font-size: 22rpx;
}
.action-link {
margin: 0 6rpx;
color: #008596;
}
.action-link:active {
opacity: 0.65;
}
@media screen and (max-width: 420px) {
.login-page {
padding: 32rpx 24rpx;
}
.login-card {
padding: 44rpx 28rpx 34rpx;
}
.brand-logo-frame {
width: 360rpx;
height: 122rpx;
padding: 14rpx 20rpx;
}
.captcha-image-wrap {
flex-basis: 190rpx;
width: 190rpx;
}
}
</style>
@@ -2,13 +2,15 @@
<view class="flex h-full flex-col bg-gray-50 pb-10">
<!-- Header -->
<view class="flex flex-col items-center py-10">
<image
class="size-20 rounded-xl shadow-md"
src="/static/logo200.png"
mode="widthFix"
/>
<view class="flex h-24 w-64 items-center justify-center rounded-xl bg-gray-800 px-4 shadow-md">
<image
class="h-20 w-full"
src="/static/sylvan-logo.png"
mode="aspectFit"
/>
</view>
<text class="mt-4 text-xl font-bold text-gray-800"
>ShiErFeng-FastAPI移动端</text
>智链云枢管理系统</text
>
</view>
@@ -36,22 +36,22 @@ const { proxy } = getCurrentInstance();
const list = ref([
{
icon: "i-mdi-github",
title: "ShiErFeng-FastAPI问题",
title: "智链云枢管理系统问题",
childList: [
{
title: "ShiErFeng-FastAPI开源吗?",
title: "智链云枢管理系统开源吗?",
content: "开源",
},
{
title: "ShiErFeng-FastAPI可以商用吗?",
title: "智链云枢管理系统可以商用吗?",
content: "可以",
},
{
title: "ShiErFeng-FastAPI官网地址多少?",
title: "智链云枢管理系统官网地址多少?",
content: "https://vfadmin.insistence.tech",
},
{
title: "ShiErFeng-FastAPI文档地址多少?",
title: "智链云枢管理系统文档地址多少?",
content: "https://vfadmin.insistence.tech",
},
],
+4 -4
View File
@@ -5,16 +5,16 @@
<!-- Logo Section -->
<view class="mb-6 flex flex-col items-center">
<view
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
class="mb-4 flex h-20 w-60 items-center justify-center rounded-2xl bg-gray-800 px-4 shadow-lg"
>
<image
class="size-10"
class="h-16 w-full"
:src="globalConfig.appInfo.logo"
mode="widthFix"
mode="aspectFit"
/>
</view>
<text class="text-xl font-bold tracking-wide text-gray-800"
>ShiErFeng-FastAPI移动端注册</text
>智链云枢管理系统注册</text
>
</view>
+332 -206
View File
@@ -1,199 +1,62 @@
<template>
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
<view class="p-4 pb-24 space-y-6">
<!-- Banner -->
<view
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
>
<swiper
class="h-40 w-full"
:current="swiperDotIndex"
@change="changeSwiper"
autoplay
interval="3000"
circular
>
<swiper-item v-for="(item, index) in data" :key="index">
<view class="h-full w-full" @click="clickBannerItem(item)">
<image
:src="item.image"
mode="scaleToFill"
class="block h-full w-full"
/>
</view>
</swiper-item>
</swiper>
<!-- Custom Dots -->
<view
class="absolute bottom-3 right-0 left-0 flex justify-center space-x-2 pointer-events-none"
>
<view
v-for="(item, index) in data"
:key="index"
class="h-1.5 rounded-full transition-all duration-300"
:class="current === index ? 'bg-white w-4' : 'bg-white/50 w-1.5'"
>
</view>
<view class="work-page">
<scroll-view
class="work-scroll"
scroll-y
:show-scrollbar="false"
>
<view class="work-content">
<view class="work-heading">
<view class="work-heading-copy">
<text class="work-kicker">业务工作台</text>
<text class="work-subtitle">
根据当前账号权限,快速进入现场业务流程
</text>
</view>
<view class="work-permission-count">
<text class="work-permission-value">{{ visibleBusinessEntries.length }}</text>
<text class="work-permission-label">项可用</text>
</view>
</view>
<!-- AI Tools Section -->
<view>
<view class="mb-4 flex items-center space-x-2">
<view class="h-4 w-1 rounded-full bg-indigo-500"></view>
<text class="text-base font-bold text-gray-800">AI 生产力</text>
<TestBatchModeSwitch />
<view class="work-section">
<view class="work-section-header">
<view class="work-section-marker"></view>
<view class="work-section-copy">
<text class="work-section-title">业务管理</text>
<text class="work-section-description">选择需要执行的现场流程</text>
</view>
</view>
<view class="grid grid-cols-2 gap-4">
<view v-if="visibleBusinessEntries.length" class="work-entry-list">
<view
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
@click="handleToAiChat"
v-for="entry in visibleBusinessEntries"
:key="entry.path"
class="work-entry"
@click="handleBusinessEntry(entry)"
>
<view
class="absolute -right-4 -top-4 size-20 rounded-full bg-indigo-50 opacity-50"
></view>
<view class="relative z-10 flex flex-col">
<view
class="mb-3 flex size-10 items-center justify-center rounded-xl bg-indigo-500 text-white shadow-md shadow-indigo-200"
>
<view class="i-mdi-chat text-xl"></view>
</view>
<text class="font-bold text-gray-800">智能对话</text>
<text class="mt-1 text-xs text-gray-500">智能助手</text>
class="work-entry-icon"
:class="entry.iconClass"
>
<view :class="entry.icon" class="work-entry-icon-glyph"></view>
</view>
</view>
<view
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
@click="handleBuilding"
>
<view
class="absolute -right-4 -top-4 size-20 rounded-full bg-purple-50 opacity-50"
></view>
<view class="relative z-10 flex flex-col">
<view
class="mb-3 flex size-10 items-center justify-center rounded-xl bg-purple-500 text-white shadow-md shadow-purple-200"
>
<view class="i-mdi-image-multiple text-xl"></view>
</view>
<text class="font-bold text-gray-800">图像生成</text>
<text class="mt-1 text-xs text-gray-500">创意工坊</text>
<view class="work-entry-copy">
<text class="work-entry-title">{{ entry.title }}</text>
<text class="work-entry-description">{{ entry.description }}</text>
</view>
<view class="i-mdi-chevron-right work-entry-arrow"></view>
</view>
</view>
</view>
<!-- System Management Section -->
<view>
<view class="mb-4 flex items-center space-x-2">
<view class="h-4 w-1 rounded-full bg-blue-500"></view>
<text class="text-base font-bold text-gray-800">系统管理</text>
</view>
<view class="rounded-2xl bg-white p-5 shadow-sm">
<view class="flex flex-wrap justify-between gap-y-4">
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleToUserList"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-blue-50 text-blue-500 transition-colors group-active:bg-blue-100"
>
<view class="i-mdi-account text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>用户管理</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-500 transition-colors group-active:bg-orange-100"
>
<view class="i-mdi-badge-account text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>角色管理</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-green-50 text-green-500 transition-colors group-active:bg-green-100"
>
<view class="i-mdi-office-building text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>部门管理</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-red-50 text-red-500 transition-colors group-active:bg-red-100"
>
<view class="i-mdi-cog text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>配置管理</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-cyan-50 text-cyan-500 transition-colors group-active:bg-cyan-100"
>
<view class="i-mdi-book-open-page-variant text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>字典管理</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-yellow-50 text-yellow-500 transition-colors group-active:bg-yellow-100"
>
<view class="i-mdi-bullhorn text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>通知公告</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-pink-50 text-pink-500 transition-colors group-active:bg-pink-100"
>
<view class="i-mdi-file-document-outline text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600"
>日志管理</text
>
</view>
<view
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
@click="handleBuilding"
>
<view
class="flex size-10 items-center justify-center rounded-xl bg-gray-50 text-gray-500 transition-colors group-active:bg-gray-100"
>
<view class="i-mdi-dots-horizontal text-xl"></view>
</view>
<text class="text-[11px] font-medium text-gray-600">更多</text>
</view>
<view v-else class="work-empty-state">
<view class="work-empty-icon">
<view class="i-mdi-lock-outline work-empty-icon-glyph"></view>
</view>
<text class="work-empty-title">暂无已授权业务</text>
<text class="work-empty-description">请联系管理员分配业务菜单权限</text>
</view>
</view>
</view>
@@ -202,34 +65,297 @@
</template>
<script setup>
import { ref, getCurrentInstance } from "vue";
import { computed, getCurrentInstance } from "vue";
import TestBatchModeSwitch from "@/components/business/TestBatchModeSwitch.vue";
import { businessMenuEntries } from "@/config/businessMenu";
const { proxy } = getCurrentInstance();
const current = ref(0);
const swiperDotIndex = ref(0);
const data = ref([
{ image: "/static/images/banner/banner01.jpg" },
{ image: "/static/images/banner/banner02.jpg" },
{ image: "/static/images/banner/banner03.jpg" },
]);
function clickBannerItem(item) {
console.info(item);
const visibleBusinessEntries = computed(() =>
businessMenuEntries.filter(canOpenBusinessEntry),
);
function canOpenBusinessEntry(entry) {
return entry.permissions
? proxy.$auth.hasPermiOr(entry.permissions)
: proxy.$auth.hasPermi(entry.permission);
}
function changeSwiper(e) {
current.value = e.detail.current;
}
function handleToAiChat() {
handleBuilding();
}
function handleToUserList() {
handleBuilding();
}
function handleBuilding() {
proxy.$modal.msg("模块建设中~");
function handleBusinessEntry(entry) {
if (!canOpenBusinessEntry(entry)) {
proxy.$modal.msgError("当前账号没有该业务菜单权限");
return;
}
proxy.$tab.navigateTo(entry.path);
}
</script>
<style lang="scss" scoped>
.work-page {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
flex-direction: column;
overflow-x: hidden;
background: #f4f8f8;
}
.work-scroll {
width: 100%;
min-width: 0;
flex: 1;
}
.work-content {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 24rpx 28rpx calc(140rpx + env(safe-area-inset-bottom));
}
.work-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 20rpx;
}
.work-heading-copy {
min-width: 0;
flex: 1;
}
.work-kicker {
display: block;
color: #28225c;
font-size: 38rpx;
font-weight: 700;
line-height: 1.3;
}
.work-subtitle {
display: block;
margin-top: 10rpx;
color: #6c7786;
font-size: 24rpx;
line-height: 1.55;
overflow-wrap: anywhere;
}
.work-permission-count {
display: flex;
flex: 0 0 auto;
align-items: baseline;
border-radius: 14rpx;
padding: 12rpx 16rpx;
background: #e9f7f6;
color: #008596;
flex-direction: column;
text-align: right;
}
.work-permission-value {
font-size: 34rpx;
font-weight: 700;
line-height: 1.1;
}
.work-permission-label {
margin-top: 4rpx;
font-size: 20rpx;
line-height: 1.2;
}
.work-section {
width: 100%;
min-width: 0;
box-sizing: border-box;
margin-top: 20rpx;
border-radius: 20rpx;
padding: 24rpx;
background: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
}
.work-section-header {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 14rpx;
}
.work-section-marker {
width: 8rpx;
height: 38rpx;
flex: 0 0 8rpx;
border-radius: 999rpx;
background: #008596;
}
.work-section-copy {
min-width: 0;
flex: 1;
}
.work-section-title {
display: block;
color: #28225c;
font-size: 30rpx;
font-weight: 700;
line-height: 1.35;
}
.work-section-description {
display: block;
margin-top: 6rpx;
color: #8b969d;
font-size: 22rpx;
line-height: 1.5;
}
.work-entry-list {
display: flex;
min-width: 0;
margin-top: 20rpx;
gap: 12rpx;
flex-direction: column;
}
.work-entry {
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: center;
gap: 16rpx;
border: 1rpx solid #e5eeee;
border-radius: 16rpx;
padding: 18rpx;
background: #fbfdfd;
}
.work-entry:active {
border-color: #8ccbc5;
background: #f0faf9;
}
.work-entry-icon {
display: flex;
width: 82rpx;
height: 82rpx;
flex: 0 0 82rpx;
align-items: center;
justify-content: center;
border-radius: 18rpx;
}
.work-entry-icon-glyph {
font-size: 42rpx;
}
.work-entry-copy {
min-width: 0;
flex: 1;
}
.work-entry-title,
.work-entry-description {
display: block;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.work-entry-title {
color: #27313a;
font-size: 27rpx;
font-weight: 700;
line-height: 1.4;
}
.work-entry-description {
margin-top: 7rpx;
color: #7b8791;
font-size: 22rpx;
line-height: 1.5;
}
.work-entry-arrow {
flex: 0 0 34rpx;
color: #a9b7bb;
font-size: 34rpx;
}
.work-empty-state {
display: flex;
align-items: center;
padding: 70rpx 16rpx 54rpx;
flex-direction: column;
text-align: center;
}
.work-empty-icon {
display: flex;
width: 84rpx;
height: 84rpx;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f5f5;
color: #9aa5ad;
}
.work-empty-icon-glyph {
font-size: 42rpx;
}
.work-empty-title {
display: block;
margin-top: 20rpx;
color: #4b5563;
font-size: 26rpx;
font-weight: 600;
}
.work-empty-description {
display: block;
margin-top: 8rpx;
color: #9aa5ad;
font-size: 22rpx;
line-height: 1.5;
overflow-wrap: anywhere;
}
@media screen and (max-width: 420px) {
.work-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.work-kicker {
font-size: 34rpx;
}
.work-section {
padding: 20rpx;
}
.work-entry {
gap: 12rpx;
padding: 14rpx;
}
.work-entry-icon {
width: 72rpx;
height: 72rpx;
flex-basis: 72rpx;
}
.work-entry-icon-glyph {
font-size: 36rpx;
}
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 516.1 194.52">
<defs>
<style>
.cls-1 {
fill: #008596;
}
.cls-2 {
fill: #fff;
}
.cls-3 {
fill: #91cdb9;
}
.cls-4 {
fill: #43b69e;
}
.cls-5 {
fill: #554d8a;
}
.cls-6 {
fill: #50bcba;
}
.cls-7, .cls-8, .cls-9 {
fill: #28225c;
}
.cls-10 {
fill: #009a91;
opacity: .8;
}
.cls-10, .cls-11 {
fill-rule: evenodd;
}
.cls-11 {
fill: #a5d7d7;
}
.cls-8 {
opacity: .5;
}
.cls-9 {
opacity: .25;
}
</style>
</defs>
<g id="tekening">
<g>
<g>
<path class="cls-2" d="M302.33,61.89l-18.13,63.56c-3.35,11.65-7.77,17.59-23.63,17.59-3.99,0-10.36-.86-13.06-1.73-1.19-.43-2.27-.76-2.27-1.94v-8.74c0-1.19,.97-2.05,2.16-2.05h.22c3.56,.21,9.93,.86,13.49,.86,3.88,0,5.83-1.29,7.12-5.61l.97-3.35h-1.62c-3.99,0-7.34-.65-10.04-10.04l-14.24-48.56c-.11-.32-.11-.54-.11-.76,0-1.08,.65-1.73,2.16-1.73h14.35c1.08,0,1.83,.97,2.05,2.05l9.71,40.58c.32,1.51,.65,2.05,1.29,2.05h.97l10.04-42.63c.21-1.08,1.08-2.05,2.27-2.05h14.35c1.08,0,2.05,.86,2.05,1.94,0,.22-.11,.33-.11,.54Z"/>
<path class="cls-2" d="M326.72,121.44h-13.17c-1.19,0-2.27-1.08-2.27-2.27V40.83c0-1.19,1.08-2.16,2.27-2.16h13.17c1.19,0,2.05,.97,2.05,2.16V119.17c0,1.19-.86,2.27-2.05,2.27Z"/>
<path class="cls-2" d="M396,61.89l-13.6,48.99c-2.7,9.6-9.28,11.33-15.65,11.33s-12.84-1.73-15.54-11.33l-13.49-48.99c-.11-.21-.11-.43-.11-.65,0-1.08,.76-1.84,1.73-1.84h14.24c1.08,0,1.94,.97,2.27,2.27l9.28,42.3c.33,1.62,.86,2.27,1.62,2.27s1.4-.65,1.73-2.27l9.28-42.3c.32-1.3,1.19-2.27,2.27-2.27h14.24c.97,0,1.84,.76,1.84,1.84,0,.21,0,.43-.11,.65Z"/>
<path class="cls-2" d="M418.66,121.3c-9.39,0-18.45-4.53-18.45-18.56v-.97c0-12.63,8.09-18.24,26.55-18.24h5.83v-3.89c0-6.15-6.78-7.47-11.85-7.47,0,0-17.96,1.95-17.96-12.69,0,0,16.18-.15,23.3-.15,15.54,0,23.99,4.55,23.99,20.31v39.49c0,1.19-.97,2.16-2.16,2.16h-29.24Zm13.89-27.41h-6.15c-7.56,0-9.5,2.59-9.5,7.88v.97c0,4.53,2.27,5.39,6.8,5.39,3.24,0,8.68,0,8.68,0l.17-14.25Z"/>
<path class="cls-2" d="M497.01,58.61c15.65,0,19.1,10.9,19.1,24.5v35.93c0,1.19-1.08,2.27-2.27,2.27h-13.06c-1.19,0-2.16-1.08-2.16-2.27v-35.93c-.1-6.03,.45-8.65-6.13-8.65-3.22,0-8.33,.12-11.99,2.06v42.52c0,1.19-.97,2.27-2.16,2.27h-13.06c-1.19,0-2.27-1.08-2.27-2.27V61.63c0-1.19,.97-2.16,2.16-2.16l31.83-.86Z"/>
<path class="cls-2" d="M184.25,121.47c0-17.9,16.39-18.14,16.39-18.14,8.24,0,17.54-.5,17.98-6.18,.24-3.16-.41-4.44-6.02-7.82l-15.23-8.96c-10.04-6.01-12.73-12.89-12.73-20.87,0-12.02,6.69-21.3,27.63-21.3,1.66,0,23.24,.05,23.26,.07-.13,.14,.12,2.04,.12,3.24,0,14.93-20.16,13.71-20.16,13.71-6.15,0-9.85,2.38-9.85,6.31,0,3.06,1.26,4.7,7.09,7.87l13.81,6.38c11.22,6.12,12.5,12.79,12.5,21.64,0,11.58-6.73,24.27-24.96,24.11l-29.81-.05Z"/>
<g>
<path class="cls-2" d="M365.04,163.65h-1.67c-.32,0-.58-.29-.58-.64v-22.95c0-.32,.23-.61,.58-.61h1.67c.32,0,.58,.26,.58,.61v22.95c0,.32-.23,.64-.58,.64Z"/>
<path class="cls-2" d="M373.88,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M393.77,146.72l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.34-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
<path class="cls-2" d="M400.81,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M420.03,163.65h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M440.07,148.1l-1.16,.22c.58,.61,1.09,1.58,1.09,3.32v.26c0,3.54-2.19,5.5-7.15,5.5-1.71,0-2.8-.26-3.73-.64-.55,.26-.8,.61-.8,1.09,0,.55,.26,.84,1,1.03l4.8,1.22c3.15,.8,6.18,1.71,6.18,4.73v.26c0,3.09-2.06,4.99-7.34,4.99s-7.4-1.96-7.4-5.12v-.29c0-1.8,.97-3.28,2.03-4.09l-.23-.1c-1.03-.45-1.48-1.51-1.48-2.51v-.03c0-.87,.42-1.8,1.32-2.32-1-.93-1.48-2.09-1.48-3.73v-.26c0-2.86,1.61-5.7,7.14-5.7h7.05c.39,0,.74,.32,.74,.71v.84c0,.42-.23,.55-.58,.61Zm-2.61,16.74c0-1.26-1.03-1.77-3.93-2.51l-3.64-.93c-.93,.8-1.58,1.61-1.58,3.28v.29c0,1.8,1.06,2.93,4.63,2.93s4.51-1.03,4.51-2.8v-.26Zm-.26-13.2c0-1.93-.87-3.44-4.35-3.44s-4.34,1.64-4.34,3.44v.26c0,1.42,.64,3.28,4.34,3.28,3.48,0,4.35-1.35,4.35-3.28v-.26Z"/>
<path class="cls-2" d="M365.07,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M384.28,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M403.17,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M415.71,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
<path class="cls-2" d="M440.52,177.36l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.35-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
<path class="cls-2" d="M455.97,194.29h-1.48c-.35,0-.64-.29-.64-.64v-.71c-1.54,.9-3.15,1.58-4.96,1.58-2.74,0-5.25-1.19-5.25-5.21v-.39c0-3.48,2.03-5.21,7.76-5.21h2.38v-1.29c0-2.7-.93-3.64-3.44-3.64-2.09,0-3.64,.13-4.92,.26h-.16c-.29,0-.58-.1-.58-.51v-1.13c0-.35,.23-.55,.68-.61,1.32-.23,2.8-.42,4.99-.42,4.47,0,6.28,2.25,6.28,6.05v11.23c0,.35-.29,.64-.64,.64Zm-2.19-8.56h-2.38c-3.9,0-4.93,.87-4.93,3.15v.39c0,2.32,1.09,2.93,2.96,2.93,1.48,0,2.8-.45,4.35-1.29v-5.18Z"/>
<path class="cls-2" d="M469.99,194.36c-.64,.1-1.48,.16-2.09,.16-2.96,0-4.38-.77-4.38-4.31v-11.23l-3.12-.35c-.35-.03-.61-.22-.61-.58v-.87c0-.35,.26-.58,.61-.58h3.12v-3.15c0-.32,.22-.58,.58-.64l1.67-.26h.1c.26,0,.48,.22,.48,.55v3.51h3.48c.35,0,.64,.29,.64,.64v1.09c0,.35-.29,.64-.64,.64h-3.48v11.23c0,1.77,.45,2.03,1.74,2.03h1.8c.45,0,.68,.13,.68,.45v1.13c0,.29-.16,.48-.58,.55Z"/>
<path class="cls-2" d="M477.14,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
<path class="cls-2" d="M490.01,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
<path class="cls-2" d="M514.85,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
</g>
</g>
<g>
<path class="cls-11" d="M57.78,67.44c-.03,5.02-.86,9.86-2.35,14.39,0,0,.01,.01,.02,.02,0,0,0,0,.01,0,10.82-31.09,38.25-54.41,71.67-59.3C130.83,15.86,132.95,8.18,132.95,0h0s-46.67,0-46.67,0H46.67C30.78,0,16.76,7.94,8.33,20.07c.88-.04,1.76-.07,2.65-.06,26.02,.17,46.98,21.41,46.8,47.43Z"/>
<path class="cls-10" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
<path class="cls-6" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
<path class="cls-7" d="M55.46,81.85s-.01-.01-.02-.02c0,0,0,0,0,.01,0,0,.02,0,.02,0Z"/>
<path class="cls-10" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
<path class="cls-8" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
<path class="cls-7" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
<path class="cls-8" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
<path class="cls-10" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
<path class="cls-3" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
<path class="cls-10" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
<path class="cls-8" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
<path class="cls-9" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
<polygon class="cls-7" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
<polygon class="cls-8" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
<polygon class="cls-9" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
<path class="cls-10" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
<path class="cls-9" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
<path class="cls-4" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
<path class="cls-10" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
<path class="cls-8" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
<path class="cls-9" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
<path class="cls-1" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
<path class="cls-5" d="M55.47,81.86s0,0,0,0c-.02,0-.03,0-.05,.01,0-.01,0-.02,.01-.03,0,0,0,0,0-.01-9.09-10.29-21.81-17.47-36.5-19.35-5.49-.7-10.89-.6-16.11,.17,2.93,8.02,7.98,15.01,14.5,20.29,5.11,4.14,11.12,7.22,17.69,8.91,2.45,.63,4.96,1.08,7.55,1.3,.24,.02,.24,.34,0,.36-4.74,.41-9.26,1.54-13.49,3.27-8.71,3.55-16.1,9.65-21.25,17.38-4.93,7.4-7.82,16.28-7.82,25.84H0s8.74,0,8.74,0h57.85c1.51-3.77,2.58-7.74,3.11-11.86,2.23-17.44-3.45-34.06-14.23-46.28Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+2 -1
View File
@@ -1,9 +1,10 @@
import { createPinia } from "pinia";
import { useUserStore } from "./modules/user";
import { useConfigStore } from "./modules/config";
import { useTestBatchStore } from "./modules/testBatch";
const pinia = createPinia();
export default pinia;
export { useUserStore, useConfigStore };
export { useUserStore, useConfigStore, useTestBatchStore };
@@ -0,0 +1,83 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { listAvailableTestBatches } from "@/api/system/testBatch";
import storage from "@/utils/storage";
import constant from "@/utils/constant";
export const useTestBatchStore = defineStore("testBatch", () => {
const options = ref([]);
const currentBatchId = ref(String(storage.get(constant.testBatchId) || ""));
const currentBatchName = ref(storage.get(constant.testBatchName) || "");
const scopeVersion = ref(0);
const isTestMode = computed(() => Boolean(currentBatchId.value));
function leaveTestMode() {
currentBatchId.value = "";
currentBatchName.value = "";
storage.remove(constant.testBatchId);
storage.remove(constant.testBatchName);
scopeVersion.value += 1;
}
async function refreshOptions() {
try {
const response = await listAvailableTestBatches();
const available = (Array.isArray(response.data) ? response.data : []).filter(
(item) => item && item.status === "ACTIVE" && item.batchId != null,
);
options.value = available;
const current = available.find(
(item) => String(item.batchId) === String(currentBatchId.value),
);
if (current) {
currentBatchId.value = String(current.batchId);
currentBatchName.value = current.batchName || "";
storage.set(constant.testBatchId, currentBatchId.value);
storage.set(constant.testBatchName, currentBatchName.value);
} else if (currentBatchId.value) {
// 本地缓存不能代替后端授权;选项接口未返回当前批次时立即回到正式模式。
leaveTestMode();
}
return available;
} catch (error) {
options.value = [];
leaveTestMode();
throw error;
}
}
function selectBatch(batch) {
const allowed = options.value.find(
(item) =>
String(item.batchId) === String(batch?.batchId) && item.status === "ACTIVE",
);
if (!allowed) {
throw new Error("所选测试批次当前不可用");
}
currentBatchId.value = String(allowed.batchId);
currentBatchName.value = allowed.batchName || "";
storage.set(constant.testBatchId, currentBatchId.value);
storage.set(constant.testBatchName, currentBatchName.value);
scopeVersion.value += 1;
}
function reset() {
options.value = [];
leaveTestMode();
}
return {
options,
currentBatchId,
currentBatchName,
scopeVersion,
isTestMode,
refreshOptions,
selectBatch,
leaveTestMode,
reset,
};
});
export default useTestBatchStore;
@@ -15,6 +15,7 @@ export const useUserStore = defineStore("user", () => {
const id = ref(storage.get(constant.id));
const name = ref(storage.get(constant.name));
const avatar = ref(storage.get(constant.avatar));
const admin = ref(storage.get(constant.admin) === true || storage.get(constant.admin) === "true");
const roles = ref(storage.get(constant.roles));
const permissions = ref(storage.get(constant.permissions));
@@ -33,6 +34,10 @@ export const useUserStore = defineStore("user", () => {
avatar.value = val;
storage.set(constant.avatar, val);
};
const SET_ADMIN = (val) => {
admin.value = val === true;
storage.set(constant.admin, admin.value);
};
const SET_ROLES = (val) => {
roles.value = val;
storage.set(constant.roles, val);
@@ -81,6 +86,7 @@ export const useUserStore = defineStore("user", () => {
} else {
SET_ROLES(["ROLE_DEFAULT"]);
}
SET_ADMIN(user.admin === true);
SET_ID(userid);
SET_NAME(username);
SET_AVATAR(avatar);
@@ -115,6 +121,7 @@ export const useUserStore = defineStore("user", () => {
id,
name,
avatar,
admin,
roles,
permissions,
SET_AVATAR,
@@ -2,8 +2,11 @@ const constant = {
avatar: "user_avatar",
id: "user_id",
name: "user_name",
admin: "user_admin",
roles: "user_roles",
permissions: "user_permissions",
testBatchId: "test_batch_id",
testBatchName: "test_batch_name",
};
export default constant;
@@ -0,0 +1,45 @@
export function normalizeScanValue(value) {
const normalizedValue = String(value ?? "").trim();
if (!normalizedValue) {
throw new Error("二维码不能为空");
}
if (normalizedValue.length > 128) {
throw new Error("二维码长度不能超过 128 个字符");
}
return normalizedValue;
}
export function isDuplicateScan(
value,
lastValue,
scannedAt,
lastScannedAt,
duplicateWindowMs,
) {
const elapsed = scannedAt - lastScannedAt;
return (
value === lastValue &&
elapsed >= 0 &&
elapsed <= duplicateWindowMs
);
}
export function createIdempotencyKey(
cryptoProvider =
typeof globalThis === "undefined" ? undefined : globalThis.crypto,
) {
if (typeof cryptoProvider?.randomUUID === "function") {
return cryptoProvider.randomUUID();
}
if (typeof cryptoProvider?.getRandomValues !== "function") {
throw new Error("当前环境不支持安全随机数,请使用 HTTPS 或 localhost 访问系统");
}
const bytes = cryptoProvider.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+62 -5
View File
@@ -1,6 +1,8 @@
import config from "@/config";
import { getToken } from "@/utils/auth";
import errorCode from "@/utils/errorCode";
import constant from "@/utils/constant";
import storage from "@/utils/storage";
import { useUserStore } from "@/store/modules/user";
import { toast, showConfirm, tansParams } from "@/utils/common";
import {
@@ -14,6 +16,37 @@ import {
let timeout = 10000;
const baseUrl = config.baseUrl;
let testBatchRecoveryStarted = false;
function getResponseBody(payload) {
return payload?.data && typeof payload.data === "object" ? payload.data : payload || {};
}
function getResponseData(payload) {
const body = getResponseBody(payload);
return body.data && typeof body.data === "object" ? body.data : body;
}
function isTestBatchUnavailable(payload) {
return getResponseData(payload)?.reason === "TEST_BATCH_UNAVAILABLE";
}
function recoverUnavailableTestBatch(payload) {
if (testBatchRecoveryStarted) return;
testBatchRecoveryStarted = true;
const body = getResponseBody(payload);
const message = body.msg || "当前测试批次不可用,已切回正式数据模式";
storage.remove(constant.testBatchId);
storage.remove(constant.testBatchName);
if (typeof uni.$emit === "function") {
uni.$emit("test-batch-unavailable", message);
}
uni.showToast({ title: message, icon: "none", duration: 3500 });
uni.reLaunch({ url: "/pages/work/index" });
setTimeout(() => {
testBatchRecoveryStarted = false;
}, 1000);
}
const request = async (config) => {
// 是否需要设置 token
@@ -23,6 +56,10 @@ const request = async (config) => {
if (getToken() && !isToken) {
config.header["Authorization"] = "Bearer " + getToken();
}
const testBatchId = storage.get(constant.testBatchId);
if (getToken() && testBatchId) {
config.header["X-Test-Batch-Id"] = String(testBatchId);
}
try {
config = await encryptTransportRequest(config);
@@ -55,8 +92,18 @@ const request = async (config) => {
return;
}
const code = res.data.code || 200;
const msg = errorCode[code] || res.data.msg || errorCode["default"];
if (isTestBatchUnavailable(res)) {
recoverUnavailableTestBatch(res);
const error = new Error(getResponseBody(res).msg || "当前测试批次不可用");
error.response = res;
reject(error);
return;
}
const body = getResponseBody(res);
const status = res.statusCode ?? res.status;
const code = body.code || 200;
const msg = errorCode[code] || body.msg || errorCode["default"];
if (code === 401) {
showConfirm(
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
@@ -80,8 +127,13 @@ const request = async (config) => {
const error = new Error(msg);
error.response = res;
reject(error);
} else if (status != null && (status < 200 || status >= 300)) {
// uni.request 的 success 仅表示收到 HTTP 响应,不能据此确认业务成功。
const error = new Error(body.msg || `Request failed with status code ${status}`);
error.response = res;
reject(error);
} else {
resolve(res.data);
resolve(body);
}
} catch (error) {
reject(error);
@@ -100,10 +152,15 @@ const request = async (config) => {
return request(config);
}
if (isTestBatchUnavailable(error?.response)) {
recoverUnavailableTestBatch(error.response);
}
const response = error.response;
const responseStatus = response?.status ?? response?.statusCode;
const responseCode = response?.data?.code;
const responseMsg = response?.data?.msg;
const responseBody = getResponseBody(response);
const responseCode = responseBody?.code;
const responseMsg = responseBody?.msg;
if (responseMsg) {
uni.showToast({
title: responseMsg,
@@ -8,8 +8,11 @@ let storageNodeKeys = [
constant.avatar,
constant.id,
constant.name,
constant.admin,
constant.roles,
constant.permissions,
constant.testBatchId,
constant.testBatchName,
];
const storage = {
@@ -0,0 +1,409 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import JSON5 from "json5";
const appRoot = fileURLToPath(new URL("..", import.meta.url));
test("PDA 的 H5、App 和小程序统一使用 Sylvan 品牌标识", async () => {
const [config, loginPage, registerPage, aboutPage, indexHtml, fullLogo, markPng] =
await Promise.all([
readFile(path.join(appRoot, "src/config.js"), "utf8"),
readFile(path.join(appRoot, "src/pages/login.vue"), "utf8"),
readFile(path.join(appRoot, "src/pages/register.vue"), "utf8"),
readFile(path.join(appRoot, "src/pages/mine/about/index.vue"), "utf8"),
readFile(path.join(appRoot, "index.html"), "utf8"),
readFile(path.join(appRoot, "src/static/sylvan-logo.svg"), "utf8"),
readFile(path.join(appRoot, "src/static/sylvan-mark.png")),
]);
assert.match(config, /logo:\s*["']\/static\/sylvan-logo\.png["']/);
assert.match(loginPage, /globalConfig\.appInfo\.logo/);
assert.match(registerPage, /globalConfig\.appInfo\.logo/);
assert.match(aboutPage, /src=["']\/static\/sylvan-logo\.png["']/);
assert.match(indexHtml, /href=["']\/static\/favicon\.ico["']/);
assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/);
assert.equal(markPng.subarray(1, 4).toString("ascii"), "PNG");
assert.doesNotMatch(`${config}\n${aboutPage}`, /\/static\/logo(?:200)?\.png/);
assert.doesNotMatch(fullLogo, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i);
});
const apiContracts = {
"src/api/business/inbound.js": [
["listInbound", "'/business/inbound/list'", "get"],
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
["scanInboundPallet", "'/business/inbound/scan/' + encodeURIComponent(qrToken)", "get"],
["getInbound", "'/business/inbound/' + inboundId", "get"],
["addInbound", "'/business/inbound'", "post"],
["updateInbound", "'/business/inbound'", "put"],
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
],
"src/api/business/filling.js": [
["listFilling", "'/business/filling/list'", "get"],
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
["scanFillingPallet", "'/business/filling/scan/' + encodeURIComponent(qrToken)", "get"],
["getFilling", "'/business/filling/' + fillingId", "get"],
["addFilling", "'/business/filling'", "post"],
],
"src/api/business/shipment.js": [
["listShipment", "'/business/shipment/list'", "get"],
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
["scanShipmentPallet", "'/business/shipment/scan/' + encodeURIComponent(qrToken)", "get"],
["getShipment", "'/business/shipment/' + shipmentId", "get"],
["addShipment", "'/business/shipment'", "post"],
["updateShipment", "'/business/shipment'", "put"],
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
],
"src/api/business/usage.js": [
["listUsage", "'/business/usage/list'", "get"],
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
["scanUsagePallet", "'/business/usage/scan/' + encodeURIComponent(qrToken)", "get"],
["getUsage", "'/business/usage/' + usageId", "get"],
["addUsage", "'/business/usage'", "post"],
],
"src/api/business/return.js": [
["listReturn", "'/business/return/list'", "get"],
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
["scanReturnPallet", "'/business/return/scan/' + encodeURIComponent(qrToken)", "get"],
["requestReturn", "'/business/return/request'", "post"],
["getReturn", "'/business/return/' + returnId", "get"],
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
],
};
function sourceForFunction(source, functionName) {
const match = source.match(
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
);
const start = match?.index ?? -1;
assert.notEqual(start, -1, `${functionName} 导出不存在`);
const end = source.indexOf("\n}", start);
return source.slice(start, end === -1 ? source.length : end + 2);
}
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
await t.test(relativePath, async () => {
const source = await readFile(path.join(appRoot, relativePath), "utf8");
for (const [functionName, url, method] of contracts) {
const functionSource = sourceForFunction(source, functionName);
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
const normalizedUrl = url.replaceAll("'", '"');
assert.match(
normalizedFunctionSource,
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
);
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
}
});
}
});
test("扫码值只去除首尾空白并拒绝空值", async () => {
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
});
test("同一扫码值只在短时窗口内被识别为重复", async () => {
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
});
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
const expected = "12345678-1234-4123-8123-123456789abc";
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
assert.equal(
createIdempotencyKey({
getRandomValues(bytes) {
bytes.fill(0);
return bytes;
},
}),
"00000000-0000-4000-8000-000000000000",
);
});
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
const source = await readFile(
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
"utf8",
);
assert.match(source, /@confirm=["']handleSubmit["']/);
assert.match(source, /normalizeScanValue/);
assert.match(source, /isDuplicateScan/);
assert.match(source, /emit\(["']scan["']/);
assert.match(source, /emit\(["']invalid["']/);
assert.match(source, /emit\(["']duplicate["']/);
assert.match(source, /loading/);
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
});
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
// uni-app 的 pages.json 允许开发模式注释,与构建端保持相同解析语义。
const pages = JSON5.parse(
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
);
const pagePaths = new Set(pages.pages.map((page) => page.path));
for (const pagePath of [
"pages/business/inbound/index",
"pages/business/filling/index",
"pages/business/shipment/index",
"pages/business/usage/index",
"pages/business/return/index",
]) {
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
}
const [workSource, menuSource] = await Promise.all([
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
readFile(path.join(appRoot, "src/config/businessMenu.js"), "utf8"),
]);
assert.match(workSource, /hasPermi\(entry\.permission\)/);
assert.match(menuSource, /business:inbound:list/);
assert.match(menuSource, /business:filling:list/);
assert.match(menuSource, /business:shipment:list/);
assert.match(menuSource, /business:usage:list/);
assert.match(menuSource, /business:return:list/);
assert.match(menuSource, /pages\/business\/inbound\/index/);
assert.match(menuSource, /pages\/business\/filling\/index/);
assert.match(menuSource, /pages\/business\/shipment\/index/);
assert.match(menuSource, /pages\/business\/usage\/index/);
assert.match(menuSource, /pages\/business\/return\/index/);
});
test("首页展示真实运营驾驶舱,不保留模板入口和虚假统计", async () => {
const source = await readFile(path.join(appRoot, "src/pages/index.vue"), "utf8");
assert.match(source, /listScanExceptions/);
assert.match(source, /最近扫码异常/);
assert.match(source, /TestBatchBanner/);
assert.doesNotMatch(
source,
/快捷入口|新闻|计划|请假申请|周报|工作效率|新通知|待办任务|85%/,
);
});
test("工作台只展示真实业务入口并使用后端列表权限", async () => {
const [workSource, menuModule] = await Promise.all([
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
importSource("src/config/businessMenu.js"),
]);
assert.deepEqual(
menuModule.businessMenuEntries.map(({ title, permission, path: entryPath }) => ({
title,
permission,
path: entryPath,
})),
[
{
title: "仓库入库",
permission: "business:inbound:list",
path: "/pages/business/inbound/index",
},
{
title: "工厂装填",
permission: "business:filling:list",
path: "/pages/business/filling/index",
},
{
title: "发货出库",
permission: "business:shipment:list",
path: "/pages/business/shipment/index",
},
{
title: "客户现场使用",
permission: "business:usage:list",
path: "/pages/business/usage/index",
},
{
title: "退回仓库",
permission: "business:return:list",
path: "/pages/business/return/index",
},
],
);
assert.match(workSource, /businessMenuEntries/);
assert.match(workSource, /hasPermi\(entry\.permission\)/);
assert.match(workSource, /navigateTo\(entry\.path\)/);
assert.doesNotMatch(workSource, /AI 生产力|智能对话|图像生成|系统管理|handleBuilding|模块建设中/);
});
test("运营驾驶舱 API 对接现有扫码异常接口", async () => {
const source = await readFile(path.join(appRoot, "src/api/business/dashboard.js"), "utf8");
assert.match(source, /export function listScanExceptions\(/);
assert.match(source, /url:\s*["']\/business\/dashboard\/scan-exceptions["']/);
assert.match(source, /method:\s*["']get["']/);
});
test("APP 页面标题与后端业务菜单名称一致", async () => {
const [pages, fillingSource] = await Promise.all([
JSON5.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"),
]);
const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index");
assert.equal(fillingPage?.style?.navigationBarTitleText, "工厂装填");
assert.match(fillingSource, />工厂装填</);
});
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
const requestSource = await readFile(
path.join(appRoot, "src/utils/request.js"),
"utf8",
);
assert.match(requestSource, /X-Test-Batch-Id/);
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
const source = await readFile(
path.join(appRoot, `src/pages/business/${page}/index.vue`),
"utf8",
);
assert.match(source, /TestBatchBanner/);
}
});
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
const source = await readFile(path.join(appRoot, file), "utf8");
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
}
for (const page of ["inbound", "shipment"]) {
const source = await readFile(
path.join(appRoot, `src/pages/business/${page}/index.vue`),
"utf8",
);
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
}
});
test("登录页只预填账号,不把管理员密码写入源码", async () => {
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
assert.match(source, /username:\s*["']admin["']/);
assert.match(source, /password:\s*["']["']/);
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
});
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
});
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/inbound/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanInboundPallet/);
assert.match(source, /UNSTOCKED/);
assert.match(source, /warehouseId/);
assert.match(source, /palletIds/);
assert.match(source, /getInbound\(/);
assert.match(source, /updateInbound/);
assert.match(source, /confirmInbound/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
});
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/shipment/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanShipmentPallet/);
assert.match(source, /FILLED_IN_WAREHOUSE/);
assert.match(source, /remainingPackages/);
assert.match(source, /sourceWarehouseId/);
assert.match(source, /targetSiteId/);
assert.match(source, /palletIds/);
assert.match(source, /getShipment\(/);
assert.match(source, /updateShipment/);
assert.match(source, /confirmShipment/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
});
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/filling/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanFillingPallet/);
assert.match(source, /EMPTY_IN_WAREHOUSE/);
for (const field of [
"qrToken",
"warehouseId",
"strainId",
"batchNo",
"packageSpec",
"initialPackages",
"idempotencyKey",
]) {
assert.match(source, new RegExp(field));
}
assert.match(source, /addFilling/);
assert.match(source, /business:filling:(?:query|add)/);
});
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/usage/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanUsagePallet/);
assert.match(source, /AT_SITE_UNUSED/);
assert.match(source, /AT_SITE_PARTIAL/);
assert.match(source, /currentLineId/);
assert.match(source, /getUsageLineOptions/);
assert.match(source, /usedPackages/);
assert.match(source, /remainingPackages/);
assert.match(source, /flowState/);
assert.match(source, /addUsage/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:usage:(?:query|add)/);
});
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/return/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanReturnPallet/);
assert.match(source, /mode:\s*["']request["']/);
assert.match(source, /mode:\s*["']confirm["']/);
assert.match(source, /sourceSiteId/);
assert.match(source, /targetWarehouseId/);
assert.match(source, /palletIds/);
assert.match(source, /scannedPalletIds/);
assert.match(source, /requestReturn/);
assert.match(source, /getReturn\(/);
assert.match(source, /confirmReturn/);
assert.match(source, /sameIdSet/);
assert.match(source, /REQUESTED/);
assert.match(source, /requestIdempotencyKey/);
assert.match(source, /confirmIdempotencyKey/);
assert.match(source, /business:return:(?:query|request|confirm)/);
});
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function importSource(relativePath) {
const source = await readFile(path.join(appRoot, relativePath), "utf8");
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
}
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const readSource = (relativePath) =>
readFileSync(resolve(appRoot, relativePath), "utf8");
test("APP 首页展示 PC 端核心运营统计并保护移动端横向空间", () => {
const source = readSource("src/pages/index.vue");
for (const className of [
"dashboard-overview",
"dashboard-kpi-grid",
"dashboard-trend-card",
"dashboard-distribution-card",
"dashboard-trend-range",
"dashboard-status-list",
]) {
assert.match(source, new RegExp(`class=\\"[^\\"]*${className}[^\\"]*\\"`));
}
for (const label of [
"当前库存托盘",
"剩余包数",
"今日入库单",
"今日出库单",
"待确认单据",
"入库 / 出库趋势",
"托盘流转状态分布",
]) {
assert.match(source, new RegExp(label));
}
assert.match(source, /listPalletInventory/);
assert.match(source, /listShipmentReport/);
assert.match(source, /listInbound/);
assert.match(source, /listShipment/);
assert.match(source, /listReturn/);
assert.match(source, /TREND_RANGE_OPTIONS/);
assert.match(source, /overflow-x:\s*hidden/);
assert.match(source, /min-width:\s*0/);
});
test("APP 首页统计保留加载、空数据和部分失败状态", () => {
const source = readSource("src/pages/index.vue");
assert.match(source, /statsLoading/);
assert.match(source, /statsError/);
assert.match(source, /dashboard-stat-empty/);
assert.match(source, /dashboard-stat-error/);
assert.match(source, /formatMetric/);
assert.match(source, /--/);
});
test("APP 首页扫码异常支持默认收起并通过标题展开", () => {
const source = readSource("src/pages/index.vue");
assert.match(source, /const exceptionsExpanded = ref\(false\)/);
assert.match(source, /function toggleExceptionList\(\)/);
assert.match(source, /class="[^"]*exception-header[^"]*"/);
assert.match(source, /@click="toggleExceptionList"/);
assert.match(source, /exception-toggle-icon/);
assert.match(source, /v-else-if="exceptionsExpanded" class="exception-list"/);
});
test("APP 首页报表 API 复用现有只读报表路径", () => {
const inventorySource = readSource("src/api/report/palletInventory.js");
const shipmentSource = readSource("src/api/report/shipment.js");
assert.match(inventorySource, /export function listPalletInventory\(/);
assert.match(inventorySource, /url:\s*["']\/report\/pallet-inventory\/list["']/);
assert.match(inventorySource, /method:\s*["']get["']/);
assert.match(shipmentSource, /export function listShipmentReport\(/);
assert.match(shipmentSource, /url:\s*["']\/report\/shipment\/list["']/);
assert.match(shipmentSource, /method:\s*["']get["']/);
});
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const loginPagePath = resolve(appRoot, "src/pages/login.vue");
const loginPageSource = readFileSync(loginPagePath, "utf8");
test("APP 登录页沿用 PC 端的品牌背景与登录卡片结构", () => {
assert.match(loginPageSource, /class="login-page"/);
assert.match(loginPageSource, /\/static\/login-background\.jpg/);
assert.match(loginPageSource, /class="login-card"/);
assert.match(loginPageSource, /globalConfig\.appInfo\.name/);
assert.doesNotMatch(
loginPageSource,
/bg-gradient-to-br from-blue-50 to-indigo-100/,
);
assert.ok(
existsSync(resolve(appRoot, "src/static/login-background.jpg")),
"APP 登录页需要包含与 PC 端一致的背景资源",
);
});
test("APP 登录页保留验证码刷新与回车登录交互", () => {
assert.match(loginPageSource, /@click="getCode"/);
assert.match(loginPageSource, /@confirm="handleLogin"/);
assert.match(loginPageSource, /captchaEnabled/);
});
test("APP 登录页为白色 Sylvan 字标提供深色承载层", () => {
assert.match(loginPageSource, /class="brand-logo-frame"/);
assert.match(
loginPageSource,
/\.brand-logo-frame\s*\{[\s\S]*background:\s*#28225c/,
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const readSource = (relativePath) =>
readFileSync(resolve(appRoot, relativePath), "utf8");
const homeSource = readSource("src/pages/index.vue");
const workSource = readSource("src/pages/work/index.vue");
const bannerSource = readSource("src/components/business/TestBatchBanner.vue");
const modeSwitchSource = readSource(
"src/components/business/TestBatchModeSwitch.vue",
);
test("首页使用受约束的运营布局并保护横向空间", () => {
assert.match(homeSource, /class="dashboard-page"/);
assert.match(homeSource, /class="dashboard-scroll"/);
assert.match(homeSource, /class="dashboard-content"/);
assert.match(homeSource, /class="dashboard-hero"/);
assert.match(homeSource, /class="dashboard-metric-grid"/);
assert.match(homeSource, /class="exception-list"/);
assert.match(homeSource, /class="exception-item"/);
assert.match(homeSource, /overflow-x:\s*hidden/);
assert.match(homeSource, /overflow-wrap:\s*anywhere/);
});
test("工作台入口在窄屏下保持单列和可换行", () => {
assert.match(workSource, /class="work-page"/);
assert.match(workSource, /class="work-scroll"/);
assert.match(workSource, /class="work-content"/);
assert.match(workSource, /class="work-entry-list"/);
assert.match(workSource, /class="work-entry"/);
assert.match(workSource, /class="work-entry-copy"/);
assert.match(workSource, /overflow-x:\s*hidden/);
assert.match(workSource, /overflow-wrap:\s*anywhere/);
});
test("测试批次提示和切换区不会撑开页面", () => {
assert.match(bannerSource, /class="test-batch-banner"/);
assert.match(bannerSource, /overflow-wrap:\s*anywhere/);
assert.match(modeSwitchSource, /class="test-batch-switch"/);
assert.match(modeSwitchSource, /class="test-batch-picker"/);
assert.match(modeSwitchSource, /overflow-wrap:\s*anywhere/);
});
+12 -4
View File
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
# -------- 数据库配置 --------
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
# 数据库类型,固定使用'mysql'
DB_TYPE = 'mysql'
# 数据库主机
DB_HOST = '127.0.0.1'
@@ -51,7 +51,7 @@ DB_PORT = 3306
# 数据库用户名
DB_USERNAME = 'root'
# 数据库密码
DB_PASSWORD = 'mysqlroot'
DB_PASSWORD = '12345678'
# 数据库名称
DB_DATABASE = 'shierfeng-fastapi'
# 是否开启sqlalchemy日志
@@ -140,9 +140,15 @@ LOG_WORKER_ID = 'auto'
# -------- 传输层加解密配置 --------
# 是否启用传输层加解密
TRANSPORT_CRYPTO_ENABLED = false
#TRANSPORT_CRYPTO_ENABLED = false
## 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
#TRANSPORT_CRYPTO_MODE = 'off'
TRANSPORT_CRYPTO_ENABLED = true
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
TRANSPORT_CRYPTO_MODE = 'off'
TRANSPORT_CRYPTO_MODE = 'optional'
# 传输层加解密算法标识
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
# 当前启用的密钥版本标识
@@ -171,3 +177,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
# 排除传输层加密的路径列表,多个值使用逗号分隔
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
+1 -1
View File
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
# -------- 数据库配置 --------
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
# 数据库类型,固定使用'mysql'
DB_TYPE = 'mysql'
# 数据库主机
DB_HOST = 'shierfeng-mysql'
-173
View File
@@ -1,173 +0,0 @@
# -------- 应用配置 --------
# 应用运行环境
APP_ENV = 'prod'
# 应用名称
APP_NAME = 'ShiErFeng-FastAPI'
# 应用代理路径
APP_ROOT_PATH = '/docker-api'
# 应用主机
APP_HOST = '0.0.0.0'
# 应用端口
APP_PORT = 9099
# 应用版本
APP_VERSION= '1.9.0'
# 应用是否开启热重载
APP_RELOAD = false
# 应用工作进程数
APP_WORKERS = 1
# 应用是否开启IP归属区域查询
APP_IP_LOCATION_QUERY = true
# 应用是否为演示模式
APP_DEMO_MODE = false
# 应用是否允许账号同时登录
APP_SAME_TIME_LOGIN = true
# 应用是否禁用Swagger文档
APP_DISABLE_SWAGGER = true
# 应用是否禁用ReDoc文档
APP_DISABLE_REDOC = true
# 可信代理IP列表,多个值使用逗号分隔
APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1'
# 可信代理跳数,单层Nginx代理通常为1
APP_TRUSTED_PROXY_HOPS = 1
# -------- Jwt配置 --------
# Jwt秘钥
JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55'
# Jwt算法
JWT_ALGORITHM = 'HS256'
# 令牌过期时间
JWT_EXPIRE_MINUTES = 1440
# redis中令牌过期时间
JWT_REDIS_EXPIRE_MINUTES = 30
# -------- 数据库配置 --------
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
DB_TYPE = 'postgresql'
# 数据库主机
DB_HOST = 'shierfeng-pg'
# 数据库端口
DB_PORT = 5432
# 数据库用户名
DB_USERNAME = 'postgres'
# 数据库密码
DB_PASSWORD = 'root'
# 数据库名称
DB_DATABASE = 'shierfeng-fastapi'
# 是否开启sqlalchemy日志
DB_ECHO = true
# 允许溢出连接池大小的最大连接数
DB_MAX_OVERFLOW = 10
# 连接池大小,0表示连接数无限制
DB_POOL_SIZE = 50
# 连接回收时间(单位:秒)
DB_POOL_RECYCLE = 3600
# 连接池中没有线程可用时,最多等待的时间(单位:秒)
DB_POOL_TIMEOUT = 30
# -------- Redis配置 --------
# Redis主机
REDIS_HOST = 'shierfeng-redis'
# Redis端口
REDIS_PORT = 6379
# Redis用户名
REDIS_USERNAME = ''
# Redis密码
REDIS_PASSWORD = ''
# Redis数据库
REDIS_DATABASE = 2
# -------- 日志配置 --------
# 是否启用日志脱敏
LOG_MASK_ENABLED = true
# 日志脱敏占位符
LOG_MASK_PLACEHOLDER = '******'
# 以下三项为互补关系(非互斥):
# 1) LOG_MASK_FIELDS:命中后执行全量脱敏
# 2) LOG_PARTIAL_MASK_FIELDS:命中后执行部分脱敏
# 3) LOG_CONFIG_SECRET_PATTERNS:按 configKey 关键词决定是否脱敏 configValue
# 全量脱敏字段,多个值使用逗号分隔
LOG_MASK_FIELDS = 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,sms_code,captcha_code,system_prompt'
# 部分脱敏字段,多个值使用逗号分隔
# 当前默认不对IP脱敏;如后期需要开启,可直接追加 ip,ipaddr,oper_ip,login_ip 并重启服务
LOG_PARTIAL_MASK_FIELDS = 'phonenumber,phone,mobile,email'
# 按配置键名识别敏感配置的关键词,多个值使用逗号分隔
LOG_CONFIG_SECRET_PATTERNS = 'password,token,secret,key,private,credential,access,jwt,captcha,sms'
# Redis Stream Key
LOG_STREAM_KEY = 'log:stream'
# Redis Stream 消费组名称
LOG_STREAM_GROUP = 'log_aggregator'
# Redis Stream 消费者名称前缀
LOG_STREAM_CONSUMER_PREFIX = 'worker'
# 每次读取的最大消息数量
LOG_STREAM_BATCH_SIZE = 100
# 阻塞读取等待时间(毫秒)
LOG_STREAM_BLOCK_MS = 2000
# Stream 最大长度(近似裁剪)
LOG_STREAM_MAXLEN = 100000
# Pending 回收最小空闲时间(毫秒)
LOG_STREAM_CLAIM_IDLE_MS = 60000
# Pending 回收检查间隔(毫秒)
LOG_STREAM_CLAIM_INTERVAL_MS = 5000
# 每次回收的最大消息数量
LOG_STREAM_CLAIM_BATCH_SIZE = 100
# 去重 Key 过期时间(秒)
LOG_STREAM_DEDUP_TTL = 3600
# 去重 Key 前缀
LOG_STREAM_DEDUP_PREFIX = 'log:dedup'
# stdout 输出是否为 JSON
LOGURU_JSON = false
# Loguru 最低输出级别
LOGURU_LEVEL = 'INFO'
# 是否输出到 stdout
LOGURU_STDOUT = true
# 是否启用文件日志
LOG_FILE_ENABLED = true
# 文件日志根目录
LOG_FILE_BASE_DIR = 'logs'
# 文件滚动策略
LOGURU_ROTATION = '50MB'
# 文件保留策略
LOGURU_RETENTION = '30 days'
# 文件压缩格式
LOGURU_COMPRESSION = 'zip'
# 实例标识(用于区分实例)
LOG_INSTANCE_ID = 'dockerpg'
# 服务名称(用于统一标识服务)
LOG_SERVICE_NAME = 'shierfeng-fastapi-backend'
# Worker 标识(auto 自动生成)
LOG_WORKER_ID = 'auto'
# -------- 传输层加解密配置 --------
# 是否启用传输层加解密
TRANSPORT_CRYPTO_ENABLED = true
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
TRANSPORT_CRYPTO_MODE = 'optional'
# 传输层加解密算法标识
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
# 当前启用的密钥版本标识
TRANSPORT_CRYPTO_KID = 'default'
# 传输层RSA位数,需与下方密钥对匹配
TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096
# 传输层公钥,默认提供一套可用示例值;Docker 部署前请替换为正式密钥
TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----'
# 传输层私钥,必须与上方公钥成对使用;Docker 部署前请替换为正式密钥
TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAwA2ooWpsxLzIVMJp7WcvvR0Bu8paFn8NVPpzz+wGpUlwP5DG\nK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6UbQVyHJ6BXXIMs/BpzcHCbyXR/wWG+\npKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEsA81kskVlDdGeNyaOJg5QuofEErCf\nR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu9YbEbKs7JvNle5vjc72ebbMIeGej\nHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj8F/TySLuOy37iVWfD+5ikLyou4ZD\nI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6SrldwZfZDBl3EGgRby8yJqO6SqGg\nyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqXhK67kbYaA9xJeHRaP04cg16imB7s\nBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6dWdJA3qe3iBSSM8pmedv+Jgfau/PF\nam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIaQ6acv+v5N6QVFptlcx6I7j8yZJ7W\nUHZlB1IxVqfPb69+985eknZyLul60gyu0kPGYUecypUu2wiNDBAErZlUxEujMWgJ\nDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSoRYxPhTbwo68RJDpImBjn+YsCAwEA\nAQKCAgEAtH7hwjp8WPY3rPk+hqEYy7psO6q0ujnUNM5hc8MWh9caSJNuu/D51vCK\nHX3d63hITNK+x9ZaM2Jcj/9XC56vm+EzILngARFDgPf3EHC06UO1IfEUND3CoMqs\njS/sEDCwiXpccc/JgkUT7EgluwDL5hLuFWGE1NTxxlFU/U0s7/HTA+G9AFuPQHjt\nZNJxxozscOh0W44OM4/jdebJ4TBEaDqtdpgDMttDwEVFIizOrbi6ODbmpCQaZzDq\nJczGoTZG7c5w5jfRnD1NnDzB0apjr3DQYVOT5EiWA39Vy6o2NmMojX1ksfxKZtll\n83vYZ91vSR5waQvo5nFJB5+vJ0CCy4BZaV9/gQBP/OSElELfD0bGYidO7NURAJMW\njGc2B6uIog8BZ3ix02FO/30i5rWeDWM7McLol8+IP0Qcg473YvrCgmRZpprdc9HT\nHnB6BgIwu0ZBoP7CxW7pBm0ApwmOf7OvUdAu68kFQ4KxABuv+Qegu0vSsktBtmIs\ngUDzu/ekoayyOvFHVwVTd4QKOSa2gXb3TS7PP22j6QLL1OPy2fK2NBW0xrRDIgtw\nSx3Yo0dxCYkxJWz1nQYsdIjZbz2/xQPZZQ5yNeePwxaOKyDuA50elN86I9RR2gjL\nh9QMrvPG7DOlgmBG9+JsydQZf00G+x45Su/1fydA09X3AFtZLkkCggEBAOj8NY/x\n3G3/1qAkrcQYLwB0mjLNkgKchRIP8NMildk0nSub5SRUxleW2wp0kM0WqkFTXLMa\nFR0qCRjiyPOtBBZxLobIfGc6568LTKfg4N7VsCeAcTjFjSOE57iX5XzwhTy6ZCKe\nZFGFWfhj/a9lNQHneTcklDHq/oHjLJQ1FacbWNycIOX2Hk7SZKyJvyMutAem3BI+\nm5SLyj5648N2ylmz6f3eaTn8CyNCrUKWnPriQZfV0XF/Aalh4sq/I3fI6pqnYkHK\nuSoG9YHujGXKN2iOV/0krCiGj94l7JqvKGs4ZRoZdD+s1EugGD7qBK1wGATuEseg\nR6mS74ylgD88JPcCggEBANMGWrqhHOAtGeIewLbNob54+nqdwU/O+zk0RcTZErcD\naRZMTLPRlpdbgWbNgdfjFnbUx0WmIeTIboD2MaW5KOnDfV/x+eRhGtEmtNRvHA/o\nHK6uYzok7Ekm7oc298CYrfRbEWVx3m4+mHmU+rsvBeof664zpEEIsSwm+NaAnjz8\nru0U8xw8KWLB8F/Q4J4p4D0yVKCmAXmrrTgEYm1H355JlAlmiu6t6YPS2Xd+gNFD\ntWBsQXYnxEZyPhoD1Q4SQjQmQMEN7+lIHm8f/nMwwUkAd3y6D1w/Uu81Gqa/9eAh\nz3woK++tj/U1CMMTWXJumIyJdH28CWQJa9VdJBoFbw0CggEAdIBhPDhh1DNhHDUb\nGvpIzn5/+LVotJuVwwFrl/gsXC9+BCdxPmiRwYyyvRiqq5MQ0ZegvAJn2myBngsR\nFyBF8f7omAc6hdgjsBkDXNMLPwI1kpscgpnuSHuV720tGPugdExz9Ael/EmlQpql\naQY/qlMX/uXwoMF0QHjbesTMrgHzvmTn4nXek+WK5+f9Rtd8uHLMiub5nx8Do5iJ\nudz1tENN119W2OLaougdgTWVC7MFv6nwkENFDnDfGijX0HcMMQdQD7wSORy/uRgS\n5ndmm4bKItAqsh0PZbMrC/JYUL0jeRiPU6PViHdmiFc2vY9Ww2hUxbO1Aetyk67S\nYUxu4wKCAQAvSOQ5n3JV9SUwms156LfOc1NE+GZhmLKITeM4SZ+87IG2omAphtQk\nlDd5rqDinBrjg6gnPiOoHRVNxly3krbNMqW8Qv9Iok2dAfxRAZibI9qRdbf8Rlu/\nxH58Q9/eAxgvgdxzC8HYmCQYUj6ghNfhb0ejICU1AVqZ1x25CtbqOYCE1UXoVL69\nR1GyVp6OMjnx3H5EBQ6dkc9dlvXrIMjFshz+wkBtXQDPLgbWlL0OpwpUUc2VSTHc\nyyqJL8Skw4icINovqAzTC/rt0ZB1hT46OmWLyDE67WGLAi43oRnaIBla67F0okJ4\nomqVM5e+YXPDQeWdau37wXStOZKmVgNdAoIBADojGo0h9mBgnr+u1oYB+mKx6LVD\n3TTy45IB1ikqprBnSjgXNbQycsTbl+qDo5ge+KqlPNk6Scvn0L7k43/VB9Y5qK2s\nBgxa1KgdeC5WUNU0rs/1UKIODA5SlWIk6JqekiAE+glljVZ5E53l93gPL3uRcvr5\nSD5CPe+qqoBh9nYIRJDogP0e2xV11EuE10j1WxuLkHL5//hePGPpgnb0/rZkP+vb\nkzwz1fTv88kQDnRk6uIe0L78iFTpEwAnlDmuMJ4KafKnujaR5VVCoI7bwryCp4+f\n8zyU7ZpKo/2EE8bYmxHMDPiYByJxCwAbm2Xn7Kw4H/17MLJOxg8685A8jyA=\n-----END RSA PRIVATE KEY-----'
# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md
TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]'
# 公钥缓存有效期(秒)
TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600
# 前端传输加密策略缓存有效期(秒)
TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300
# GET/DELETE 请求参数加密后的最大 URL 长度
TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096
# 请求时间窗允许偏差(秒)
TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120
# 防重放随机数有效期(秒)
TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300
# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用
TRANSPORT_CRYPTO_ENABLED_PATHS = ''
# 强制要求传输层加密的路径列表,多个值使用逗号分隔
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
# 排除传输层加密的路径列表,多个值使用逗号分隔
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
+4 -2
View File
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
# -------- 数据库配置 --------
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
# 数据库类型,固定使用'mysql'
DB_TYPE = 'mysql'
# 数据库主机
DB_HOST = '127.0.0.1'
@@ -51,7 +51,7 @@ DB_PORT = 3306
# 数据库用户名
DB_USERNAME = 'root'
# 数据库密码
DB_PASSWORD = 'root'
DB_PASSWORD = '12345678'
# 数据库名称
DB_DATABASE = 'shierfeng-fastapi'
# 是否开启sqlalchemy日志
@@ -171,3 +171,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
# 排除传输层加密的路径列表,多个值使用逗号分隔
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.10
WORKDIR /app
# 复制源代码
COPY . .
# 安装依赖
RUN pip install --no-cache-dir -r requirements-pg.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 暴露端口
EXPOSE 9099
# 启动命令
CMD ["shierfeng", "app", "run", "--env=dockerpg"]
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from pathlib import Path
DEFAULT_ENVIRONMENTS = ('dev', 'prod', 'dockermy', 'dockerpg')
DEFAULT_ENVIRONMENTS = ('dev', 'prod', 'dockermy')
@dataclass(frozen=True)
@@ -28,7 +28,7 @@ GENERAL_COPY = {
'action_confirm_hint': '按键提示 · [Enter] 确认执行 [Esc] 取消返回',
'more_detail_hint': '> 进入对应页面查看更多',
'tui_command_help': '进入只读巡检工作台',
'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`',
'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt`',
'tui_missing_dependency_message': '当前环境未安装 TUI 可选依赖,无法启动 `shierfeng tui`',
}
@@ -19,7 +19,7 @@ from user_agents import parse
from common.context import RequestContext
from common.enums import BusinessType
from config.env import AppConfig
from exceptions.exception import LoginException, ServiceException, ServiceWarning
from exceptions.exception import LoginException, PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.log_vo import LogininforModel, OperLogModel
from module_admin.service.log_service import LogQueueService
from utils.client_ip_util import ClientIPUtil
@@ -117,6 +117,7 @@ class Log:
log_type: Literal['login', 'operation'] | None = 'operation',
request_log_mode: Literal['full', 'none', 'summary', 'include', 'exclude'] = 'full',
response_log_mode: Literal['full', 'none', 'summary', 'include', 'exclude'] = 'full',
oper_url_mode: Literal['full', 'route'] = 'full',
request_include_fields: tuple[str, ...] | None = None,
response_include_fields: tuple[str, ...] | None = None,
request_exclude_fields: tuple[str, ...] | None = None,
@@ -130,6 +131,7 @@ class Log:
:param log_type: 日志类型;`login`表示登录日志,仅落登录信息,`operation`表示操作日志,会落请求/响应摘要与操作人信息
:param request_log_mode: 请求日志记录模式;`full`记录脱敏后的完整请求,`none`不记录请求体,`summary`记录请求摘要,`include`仅记录request_include_fields指定的字段,`exclude`记录完整请求后排除request_exclude_fields指定的字段
:param response_log_mode: 响应日志记录模式;`full`记录脱敏后的完整响应,`none`不记录响应体,`summary`记录响应摘要,`include`仅记录response_include_fields指定的字段,`exclude`记录完整响应后排除response_exclude_fields指定的字段
:param oper_url_mode: 操作URL记录模式;`full`记录实际请求路径,`route`记录路由模板,适合路径中包含敏感标识的接口
:param request_include_fields: 请求日志白名单字段路径,仅在request_log_mode='include'时生效;推荐从path_params/query_params/json_body/form_data/files/raw_body开始,字段名支持 snake_case 与 camelCase 自动兼容
:param response_include_fields: 响应日志白名单字段路径,仅在response_log_mode='include'时生效;推荐从code/msg/data/rows/success/time开始,字段名支持 snake_case 与 camelCase 自动兼容
:param request_exclude_fields: 请求日志排除字段路径,仅在request_log_mode='exclude'时生效;推荐从path_params/query_params/json_body/form_data/files/raw_body开始,字段名支持 snake_case 与 camelCase 自动兼容
@@ -141,6 +143,7 @@ class Log:
self.log_type = log_type
self.request_log_mode = request_log_mode
self.response_log_mode = response_log_mode
self.oper_url_mode = oper_url_mode
self.request_include_fields = request_include_fields or ()
self.response_include_fields = response_include_fields or ()
self.request_exclude_fields = request_exclude_fields or ()
@@ -166,7 +169,7 @@ class Log:
# 获取操作类型
operator_type = self._get_oper_type(user_agent)
# 获取请求的url
oper_url = request.url.path
oper_url = self._get_oper_url(request)
# 获取请求ip
oper_ip = ClientIPUtil.get_client_ip(request)
# 获取请求ip归属区域
@@ -193,6 +196,8 @@ class Log:
except (LoginException, ServiceWarning) as e:
logger.warning(e.message)
result = ResponseUtil.failure(data=e.data, msg=e.message)
except PermissionException as e:
result = ResponseUtil.forbidden(data=e.data, msg=e.message)
except ServiceException as e:
logger.error(e.message)
result = ResponseUtil.error(data=e.data, msg=e.message)
@@ -279,6 +284,21 @@ class Log:
return func_path
def _get_oper_url(self, request: Request) -> str:
"""
获取操作日志中的请求URL。
路由模板模式只保留路由结构,不把路径参数(例如二维码固定标识)写入日志。
:param request: 当前请求
:return: 用于操作日志的URL
"""
if self.oper_url_mode == 'route':
route_path = getattr(request.scope.get('route'), 'path', None)
if route_path:
return route_path
return request.url.path
@staticmethod
def _limit_log_text(log_text: str, max_length: int, overflow_text: str) -> str:
"""
@@ -63,7 +63,6 @@ class ApiRateLimitPreset:
ANON_AUTH_CAPTCHA: 匿名验证码类接口限流预设
ANON_PUBLIC_METADATA: 匿名公开元数据接口限流预设
COMMON_UPLOAD: 通用上传接口限流预设
USER_INTERACTIVE_HIGH_FREQ: 用户高频交互接口限流预设
USER_RESOURCE_EXECUTION: 用户执行类接口限流预设
USER_COMMON_MUTATION: 用户普通写操作接口限流预设
USER_SECURITY_MUTATION: 用户安全敏感操作接口限流预设
@@ -111,12 +110,6 @@ class ApiRateLimitPreset:
scope='user_or_ip',
)
USER_INTERACTIVE_HIGH_FREQ = ApiRateLimitPresetConfig(
name='USER_INTERACTIVE_HIGH_FREQ',
limit=40,
window_seconds=60,
scope='user',
)
USER_RESOURCE_EXECUTION = ApiRateLimitPresetConfig(
name='USER_RESOURCE_EXECUTION',
limit=12,
+105 -272
View File
@@ -1,4 +1,3 @@
from config.env import DataBaseConfig
class CommonConstant:
@@ -223,13 +222,6 @@ class ApiNamespace:
SYSTEM_USER_IMPORT: 用户导入接口命名空间
SYSTEM_USER_EXPORT: 用户导出接口命名空间
AI_MODEL_LIST: AI模型列表接口命名空间
AI_MODEL_ALL: AI模型全量列表接口命名空间
AI_MODEL_DETAIL: AI模型详情接口命名空间
AI_CHAT_CONFIG: AI对话配置接口命名空间
AI_CHAT_SEND: AI对话发送接口命名空间
AI_CHAT_CANCEL: AI对话取消接口命名空间
TOOL_GEN_LIST: 代码生成列表接口命名空间
TOOL_GEN_DB_LIST: 代码生成数据源列表接口命名空间
TOOL_GEN_DETAIL: 代码生成详情接口命名空间
@@ -319,13 +311,6 @@ class ApiNamespace:
SYSTEM_USER_IMPORT = 'system:user:import'
SYSTEM_USER_EXPORT = 'system:user:export'
AI_MODEL_LIST = 'ai:model:list'
AI_MODEL_ALL = 'ai:model:all'
AI_MODEL_DETAIL = 'ai:model:detail'
AI_CHAT_CONFIG = 'ai:chat:config'
AI_CHAT_SEND = 'ai:chat:send'
AI_CHAT_CANCEL = 'ai:chat:cancel'
TOOL_GEN_LIST = 'tool:gen:list'
TOOL_GEN_DB_LIST = 'tool:gen:db-list'
TOOL_GEN_DETAIL = 'tool:gen:detail'
@@ -362,8 +347,6 @@ class ApiGroup:
CONFIG_MUTATION: 参数配置写操作关联命名空间分组
DICT_TYPE_MUTATION: 字典类型写操作关联命名空间分组
DICT_DATA_MUTATION: 字典数据写操作关联命名空间分组
AI_MODEL_MUTATION: AI模型写操作关联命名空间分组
AI_CHAT_CONFIG_MUTATION: AI对话配置写操作关联命名空间分组
GEN_MUTATION: 代码生成写操作关联命名空间分组
"""
@@ -389,9 +372,6 @@ class ApiGroup:
ApiNamespace.SYSTEM_USER_LIST,
ApiNamespace.SYSTEM_USER_DETAIL,
ApiNamespace.SYSTEM_USER_PROFILE,
ApiNamespace.AI_MODEL_LIST,
ApiNamespace.AI_MODEL_ALL,
ApiNamespace.AI_MODEL_DETAIL,
)
MENU_MUTATION = (
@@ -488,14 +468,6 @@ class ApiGroup:
ApiNamespace.SYSTEM_DICT_DATA_DETAIL,
)
AI_MODEL_MUTATION = (
ApiNamespace.AI_MODEL_LIST,
ApiNamespace.AI_MODEL_ALL,
ApiNamespace.AI_MODEL_DETAIL,
)
AI_CHAT_CONFIG_MUTATION = (ApiNamespace.AI_CHAT_CONFIG,)
GEN_MUTATION = (
ApiNamespace.TOOL_GEN_LIST,
ApiNamespace.TOOL_GEN_DB_LIST,
@@ -576,42 +548,19 @@ class GenConstant:
TREE_NAME = 'treeName'
PARENT_MENU_ID = 'parentMenuId'
PARENT_MENU_NAME = 'parentMenuName'
COLUMNTYPE_STR = (
['character varying', 'varchar', 'character', 'char']
if DataBaseConfig.db_type == 'postgresql'
else ['char', 'varchar', 'nvarchar', 'varchar2']
)
COLUMNTYPE_TEXT = (
['text', 'citext'] if DataBaseConfig.db_type == 'postgresql' else ['tinytext', 'text', 'mediumtext', 'longtext']
)
COLUMNTYPE_TIME = (
[
'date',
'time',
'time with time zone',
'time without time zone',
'timestamp',
'timestamp with time zone',
'timestamp without time zone',
'interval',
]
if DataBaseConfig.db_type == 'postgresql'
else ['datetime', 'time', 'date', 'timestamp']
)
COLUMNTYPE_GEOMETRY = (
['point', 'line', 'lseg', 'box', 'path', 'polygon', 'circle']
if DataBaseConfig.db_type == 'postgresql'
else [
'geometry',
'point',
'linestring',
'polygon',
'multipoint',
'multilinestring',
'multipolygon',
'geometrycollection',
]
)
COLUMNTYPE_STR = ['char', 'varchar', 'nvarchar', 'varchar2']
COLUMNTYPE_TEXT = ['tinytext', 'text', 'mediumtext', 'longtext']
COLUMNTYPE_TIME = ['datetime', 'time', 'date', 'timestamp']
COLUMNTYPE_GEOMETRY = [
'geometry',
'point',
'linestring',
'polygon',
'multipoint',
'multilinestring',
'multipolygon',
'geometrycollection',
]
COLUMNTYPE_NUMBER = [
'tinyint',
'smallint',
@@ -646,211 +595,95 @@ class GenConstant:
QUERY_LIKE = 'LIKE'
QUERY_EQ = 'EQ'
REQUIRE = '1'
DB_TO_SQLALCHEMY_TYPE_MAPPING = (
{
'boolean': 'Boolean',
'smallint': 'SmallInteger',
'integer': 'Integer',
'bigint': 'BigInteger',
'real': 'Float',
'double precision': 'Float',
'numeric': 'Numeric',
'character varying': 'String',
'character': 'String',
'text': 'Text',
'bytea': 'LargeBinary',
'date': 'Date',
'time': 'Time',
'time with time zone': 'Time',
'time without time zone': 'Time',
'timestamp': 'DateTime',
'timestamp with time zone': 'DateTime',
'timestamp without time zone': 'DateTime',
'interval': 'Interval',
'json': 'JSON',
'jsonb': 'JSONB',
'uuid': 'Uuid',
'inet': 'INET',
'cidr': 'CIDR',
'macaddr': 'MACADDR',
'point': 'Geometry',
'line': 'Geometry',
'lseg': 'Geometry',
'box': 'Geometry',
'path': 'Geometry',
'polygon': 'Geometry',
'circle': 'Geometry',
'bit': 'Bit',
'bit varying': 'Bit',
'tsvector': 'TSVECTOR',
'tsquery': 'TSQUERY',
'xml': 'String',
'array': 'ARRAY',
'composite': 'JSON',
'enum': 'Enum',
'range': 'Range',
'money': 'Numeric',
'pg_lsn': 'BigInteger',
'txid_snapshot': 'String',
'oid': 'BigInteger',
'regproc': 'String',
'regclass': 'String',
'regtype': 'String',
'regrole': 'String',
'regnamespace': 'String',
'int2vector': 'ARRAY',
'oidvector': 'ARRAY',
'pg_node_tree': 'Text',
}
if DataBaseConfig.db_type == 'postgresql'
else {
# 数值类型
'TINYINT': 'SmallInteger',
'SMALLINT': 'SmallInteger',
'MEDIUMINT': 'Integer',
'INT': 'Integer',
'INTEGER': 'Integer',
'BIGINT': 'BigInteger',
'FLOAT': 'Float',
'DOUBLE': 'Float',
'DECIMAL': 'DECIMAL',
'BIT': 'Integer',
# 日期和时间类型
'DATE': 'Date',
'TIME': 'Time',
'DATETIME': 'DateTime',
'TIMESTAMP': 'TIMESTAMP',
'YEAR': 'Integer',
# 字符串类型
'CHAR': 'CHAR',
'VARCHAR': 'String',
'TINYTEXT': 'Text',
'TEXT': 'Text',
'MEDIUMTEXT': 'Text',
'LONGTEXT': 'Text',
'BINARY': 'BINARY',
'VARBINARY': 'VARBINARY',
'TINYBLOB': 'LargeBinary',
'BLOB': 'LargeBinary',
'MEDIUMBLOB': 'LargeBinary',
'LONGBLOB': 'LargeBinary',
# 枚举和集合类型
'ENUM': 'Enum',
'SET': 'String',
# JSON 类型
'JSON': 'JSON',
# 空间数据类型(需要扩展支持,如 GeoAlchemy2)
'GEOMETRY': 'Geometry', # 需要安装 geoalchemy2
'POINT': 'Geometry',
'LINESTRING': 'Geometry',
'POLYGON': 'Geometry',
'MULTIPOINT': 'Geometry',
'MULTILINESTRING': 'Geometry',
'MULTIPOLYGON': 'Geometry',
'GEOMETRYCOLLECTION': 'Geometry',
}
)
DB_TO_PYTHON_TYPE_MAPPING = (
{
'boolean': 'bool',
'smallint': 'int',
'integer': 'int',
'bigint': 'int',
'real': 'float',
'double precision': 'float',
'numeric': 'Decimal',
'character varying': 'str',
'character': 'str',
'text': 'str',
'bytea': 'bytes',
'date': 'date',
'time': 'time',
'time with time zone': 'time',
'time without time zone': 'time',
'timestamp': 'datetime',
'timestamp with time zone': 'datetime',
'timestamp without time zone': 'datetime',
'interval': 'timedelta',
'json': 'dict',
'jsonb': 'dict',
'uuid': 'str',
'inet': 'str',
'cidr': 'str',
'macaddr': 'str',
'point': 'list',
'line': 'list',
'lseg': 'list',
'box': 'list',
'path': 'list',
'polygon': 'list',
'circle': 'list',
'bit': 'int',
'bit varying': 'int',
'tsvector': 'str',
'tsquery': 'str',
'xml': 'str',
'array': 'list',
'composite': 'dict',
'enum': 'str',
'range': 'list',
'money': 'Decimal',
'pg_lsn': 'int',
'txid_snapshot': 'str',
'oid': 'int',
'regproc': 'str',
'regclass': 'str',
'regtype': 'str',
'regrole': 'str',
'regnamespace': 'str',
'int2vector': 'list',
'oidvector': 'list',
'pg_node_tree': 'str',
}
if DataBaseConfig.db_type == 'postgresql'
else {
# 数值类型
'TINYINT': 'int',
'SMALLINT': 'int',
'MEDIUMINT': 'int',
'INT': 'int',
'INTEGER': 'int',
'BIGINT': 'int',
'FLOAT': 'float',
'DOUBLE': 'float',
'DECIMAL': 'Decimal',
'BIT': 'int',
# 日期和时间类型
'DATE': 'date',
'TIME': 'time',
'DATETIME': 'datetime',
'TIMESTAMP': 'datetime',
'YEAR': 'int',
# 字符串类型
'CHAR': 'str',
'VARCHAR': 'str',
'TINYTEXT': 'str',
'TEXT': 'str',
'MEDIUMTEXT': 'str',
'LONGTEXT': 'str',
'BINARY': 'bytes',
'VARBINARY': 'bytes',
'TINYBLOB': 'bytes',
'BLOB': 'bytes',
'MEDIUMBLOB': 'bytes',
'LONGBLOB': 'bytes',
# 枚举和集合类型
'ENUM': 'str',
'SET': 'str',
# JSON 类型
'JSON': 'dict',
# 空间数据类型(通常需要特殊处理)
'GEOMETRY': 'bytes',
'POINT': 'bytes',
'LINESTRING': 'bytes',
'POLYGON': 'bytes',
'MULTIPOINT': 'bytes',
'MULTILINESTRING': 'bytes',
'MULTIPOLYGON': 'bytes',
'GEOMETRYCOLLECTION': 'bytes',
}
)
DB_TO_SQLALCHEMY_TYPE_MAPPING = {
# 数值类型
'TINYINT': 'SmallInteger',
'SMALLINT': 'SmallInteger',
'MEDIUMINT': 'Integer',
'INT': 'Integer',
'INTEGER': 'Integer',
'BIGINT': 'BigInteger',
'FLOAT': 'Float',
'DOUBLE': 'Float',
'DECIMAL': 'DECIMAL',
'BIT': 'Integer',
# 日期和时间类型
'DATE': 'Date',
'TIME': 'Time',
'DATETIME': 'DateTime',
'TIMESTAMP': 'TIMESTAMP',
'YEAR': 'Integer',
# 字符串类型
'CHAR': 'CHAR',
'VARCHAR': 'String',
'TINYTEXT': 'Text',
'TEXT': 'Text',
'MEDIUMTEXT': 'Text',
'LONGTEXT': 'Text',
'BINARY': 'BINARY',
'VARBINARY': 'VARBINARY',
'TINYBLOB': 'LargeBinary',
'BLOB': 'LargeBinary',
'MEDIUMBLOB': 'LargeBinary',
'LONGBLOB': 'LargeBinary',
# 枚举和集合类型
'ENUM': 'Enum',
'SET': 'String',
# JSON 类型
'JSON': 'JSON',
# 空间数据类型(需要扩展支持,如 GeoAlchemy2)
'GEOMETRY': 'Geometry', # 需要安装 geoalchemy2
'POINT': 'Geometry',
'LINESTRING': 'Geometry',
'POLYGON': 'Geometry',
'MULTIPOINT': 'Geometry',
'MULTILINESTRING': 'Geometry',
'MULTIPOLYGON': 'Geometry',
'GEOMETRYCOLLECTION': 'Geometry',
}
DB_TO_PYTHON_TYPE_MAPPING = {
# 数值类型
'TINYINT': 'int',
'SMALLINT': 'int',
'MEDIUMINT': 'int',
'INT': 'int',
'INTEGER': 'int',
'BIGINT': 'int',
'FLOAT': 'float',
'DOUBLE': 'float',
'DECIMAL': 'Decimal',
'BIT': 'int',
# 日期和时间类型
'DATE': 'date',
'TIME': 'time',
'DATETIME': 'datetime',
'TIMESTAMP': 'datetime',
'YEAR': 'int',
# 字符串类型
'CHAR': 'str',
'VARCHAR': 'str',
'TINYTEXT': 'str',
'TEXT': 'str',
'MEDIUMTEXT': 'str',
'LONGTEXT': 'str',
'BINARY': 'bytes',
'VARBINARY': 'bytes',
'TINYBLOB': 'bytes',
'BLOB': 'bytes',
'MEDIUMBLOB': 'bytes',
'LONGBLOB': 'bytes',
# 枚举和集合类型
'ENUM': 'str',
'SET': 'str',
# JSON 类型
'JSON': 'dict',
# 空间数据类型(通常需要特殊处理)
'GEOMETRY': 'bytes',
'POINT': 'bytes',
'LINESTRING': 'bytes',
'POLYGON': 'bytes',
'MULTIPOINT': 'bytes',
'MULTILINESTRING': 'bytes',
'MULTIPOLYGON': 'bytes',
'GEOMETRYCOLLECTION': 'bytes',
}
@@ -4,6 +4,7 @@ from typing import Literal
from exceptions.exception import LoginException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.domain.test_batch_scope import TestBatchScope
# 定义上下文变量
# 存储当前请求的编译后的排除路由模式列表
@@ -12,6 +13,8 @@ current_exclude_patterns: ContextVar[
] = ContextVar('current_exclude_patterns', default=None)
# 存储当前用户信息
current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None)
# 存储当前请求已校验的测试批次作用域
current_test_batch_scope: ContextVar[TestBatchScope | None] = ContextVar('current_test_batch_scope', default=None)
class RequestContext:
@@ -69,6 +72,21 @@ class RequestContext:
raise LoginException(data='', message='当前用户信息为空,请检查是否已登录')
return _current_user
@staticmethod
def set_current_test_batch_scope(scope: TestBatchScope) -> Token:
"""
设置当前请求已校验的测试批次作用域
:param scope: 测试批次作用域
:return: 上下文变量令牌,用于重置
"""
return current_test_batch_scope.set(scope)
@staticmethod
def get_current_test_batch_scope() -> TestBatchScope | None:
"""获取当前请求已校验的测试批次作用域。"""
return current_test_batch_scope.get()
@staticmethod
def reset_current_exclude_patterns(token: Token) -> None:
"""
@@ -87,6 +105,11 @@ class RequestContext:
"""
current_user.reset(token)
@staticmethod
def reset_current_test_batch_scope(token: Token) -> None:
"""重置当前请求的测试批次作用域。"""
current_test_batch_scope.reset(token)
@staticmethod
def clear_all() -> None:
"""
@@ -94,3 +117,4 @@ class RequestContext:
"""
current_exclude_patterns.set(None)
current_user.set(None)
current_test_batch_scope.set(None)
@@ -13,11 +13,6 @@ def build_async_sqlalchemy_database_url() -> str:
:return: 异步 SQLAlchemy 数据库连接 URL
"""
if DataBaseConfig.db_type == 'postgresql':
return (
f'postgresql+asyncpg://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
)
return (
f'mysql+asyncmy://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
@@ -33,11 +28,6 @@ def build_sync_sqlalchemy_database_url() -> str:
:return: 同步 SQLAlchemy 数据库连接 URL
"""
if DataBaseConfig.db_type == 'postgresql':
return (
f'postgresql+psycopg2://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
)
return (
f'mysql+pymysql://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
+18 -3
View File
@@ -47,7 +47,7 @@ class DataBaseSettings(BaseSettings):
数据库配置
"""
db_type: Literal['mysql', 'postgresql'] = 'mysql'
db_type: Literal['mysql'] = 'mysql'
db_host: str = '127.0.0.1'
db_port: int = 3306
db_username: str = 'root'
@@ -62,8 +62,6 @@ class DataBaseSettings(BaseSettings):
@computed_field
@property
def sqlglot_parse_dialect(self) -> str:
if self.db_type == 'postgresql':
return 'postgres'
return self.db_type
@@ -144,6 +142,15 @@ class TransportCryptoSettings(BaseSettings):
)
class TestBatchSettings(BaseSettings):
"""
测试批次安全配置
"""
test_batch_preview_ttl_seconds: int = 600
test_batch_online_purge_max_rows: int = 0
class GenSettings:
"""
代码生成配置
@@ -265,6 +272,12 @@ class GetConfig:
"""
return TransportCryptoSettings()
def get_test_batch_config(self) -> TestBatchSettings:
"""
获取测试批次安全配置
"""
return TestBatchSettings()
def get_gen_config(self) -> GenSettings:
"""
获取代码生成配置
@@ -328,6 +341,8 @@ RedisConfig = get_config.get_redis_config()
LogConfig = get_config.get_log_config()
# 传输层加解密配置
TransportCryptoConfig = get_config.get_transport_crypto_config()
# 测试批次安全配置
TestBatchConfig = get_config.get_test_batch_config()
# 代码生成配置
GenConfig = get_config.get_gen_config()
# 上传配置
+5 -13
View File
@@ -38,23 +38,16 @@ shierfeng --help
### 2.2 安装依赖
MySQL 版本:
安装依赖(统一使用 MySQL):
```bash
cd shierfeng-fastapi-backend
pip3 install -r requirements.txt
```
PostgreSQL 版本:
```bash
cd shierfeng-fastapi-backend
pip3 install -r requirements-pg.txt
```
说明:
- `requirements*.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `pip install -e .`
- `requirements.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `pip install -e .`
- 安装完成后,`shierfeng` 会随当前 Python 环境一起可用
如果本地使用 Conda,推荐先进入项目环境再执行命令:
@@ -65,7 +58,7 @@ cd shierfeng-fastapi-backend
shierfeng --help
```
`textual` 已包含在现有依赖文件中,因此安装 `requirements.txt` 或 `requirements-pg.txt` 后即可直接使用 TUI。
`textual` 已包含在现有依赖文件中,安装 `requirements.txt` 后即可直接使用 TUI。
### 2.3 第一个命令
@@ -104,7 +97,6 @@ CLI 不单独维护配置系统,仍然复用项目原有的 `config/env.py`
- `--env=dev` -> `.env.dev`
- `--env=prod` -> `.env.prod`
- `--env=dockermy` -> `.env.dockermy`
- `--env=dockerpg` -> `.env.dockerpg`
### 3.3 帮助命令
@@ -494,7 +486,7 @@ shierfeng tui --env=prod
- 当前 TUI 是只读巡检工作台,页面内的写操作入口会通过确认弹窗或向导二次确认
- 页面切换快捷键为 `D/A/O/B/C/T/G/P/E`,分别对应总览、应用、运维、数据库、缓存、任务、代码生成、参数配置、加密
- 通用快捷键包括 `R` 刷新、`Q` 退出、`S` 聚焦侧栏、`←/→` 切换焦点或区域、`J/K` 滚动、`PgUp/PgDn` 翻页、`Home/End` 首尾跳转
- 若当前 Python 环境缺少 TUI 依赖,`shierfeng tui` 会返回失败结果并提示重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`
- 若当前 Python 环境缺少 TUI 依赖,`shierfeng tui` 会返回失败结果并提示重新执行 `pip install -r requirements.txt`
## 6. 危险命令清单
@@ -814,7 +806,7 @@ shierfeng ops health --env=dev --output=json
请依次确认:
- 当前目录是否为 `shierfeng-fastapi-backend`
- 当前 Python 环境是否执行过 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`
- 当前 Python 环境是否执行过 `pip install -r requirements.txt`
- 当前终端是否真的使用了安装依赖的那个 Python/Conda 环境
### 8.2 命令报数据库或 Redis 连接失败
@@ -64,23 +64,20 @@ TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH=4096
当前项目的 Docker 部署使用:
- `shierfeng-fastapi-test/docker-compose.test.my.yml` + `shierfeng-fastapi-backend/Dockerfile.my`
- `shierfeng-fastapi-test/docker-compose.test.pg.yml` + `shierfeng-fastapi-backend/Dockerfile.pg`
后端容器启动命令分别是:
- `shierfeng app run --env=dockermy`
- `shierfeng app run --env=dockerpg`
所以 Docker 环境需要直接在以下文件中配置传输层密钥:
- `shierfeng-fastapi-backend/.env.dockermy`
- `shierfeng-fastapi-backend/.env.dockerpg`
配置方式与生产环境相同;`.env.dockermy` / `.env.dockerpg` 里也已经默认提供一套可用示例值,正式部署前请替换为正式密钥。
配置方式与生产环境相同;`.env.dockermy` 里已经默认提供一套可用示例值,正式部署前请替换为正式密钥。
使用时只需要:
1. 修改对应的 `.env.dockermy` 或 `.env.dockerpg`
1. 修改 `.env.dockermy`
2. 重新构建并启动 Docker 服务
## 密钥生成
@@ -14,10 +14,10 @@ class ContextCleanupMiddleware(BaseHTTPMiddleware):
"""
在每个请求处理完成后清理上下文信息
"""
response = await call_next(request)
# 请求处理完成后清理所有上下文变量
RequestContext.clear_all()
return response
try:
return await call_next(request)
finally:
RequestContext.clear_all()
def add_context_cleanup_middleware(app: FastAPI) -> None:
@@ -60,6 +60,22 @@ class OperationLogDao:
return operation_log_list
@classmethod
async def get_scan_exception_log_list(
cls, db: AsyncSession, oper_name: str | None, page_size: int
) -> PageModel:
"""按扫码日志标题和精确操作人查询首页异常记录。"""
query = (
select(SysOperLog)
.where(
SysOperLog.title == '扫码操作',
SysOperLog.status == 1,
SysOperLog.oper_name == oper_name if oper_name is not None else True,
)
.order_by(desc(SysOperLog.oper_time))
)
return await PageUtil.paginate(db, query, page_num=1, page_size=page_size, is_page=True)
@classmethod
async def add_operation_log_dao(cls, db: AsyncSession, operation_log: OperLogModel) -> SysOperLog:
"""
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import CHAR, Column, DateTime, Integer, LargeBinary, String
from sqlalchemy import CHAR, Column, DateTime, Integer, String
from sqlalchemy.dialects import mysql
from config.database import Base
@@ -20,7 +20,7 @@ class SysNotice(Base):
notice_title = Column(String(50), nullable=False, comment='公告标题')
notice_type = Column(CHAR(1), nullable=False, comment='公告类型(1通知 2公告)')
notice_content = Column(
mysql.LONGBLOB if DataBaseConfig.db_type == 'mysql' else LargeBinary,
mysql.LONGBLOB,
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type, False),
comment='公告内容',
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, SmallInteger, String
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String
from sqlalchemy.dialects import mysql
from config.database import Base
@@ -27,13 +27,13 @@ class SysRole(Base):
comment='数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)',
)
menu_check_strictly = Column(
mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger,
mysql.TINYINT(display_width=1),
nullable=True,
server_default='1',
comment='菜单树选择项是否关联显示',
)
dept_check_strictly = Column(
mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger,
mysql.TINYINT(display_width=1),
nullable=True,
server_default='1',
comment='部门树选择项是否关联显示',
@@ -1,164 +0,0 @@
from typing import Annotated
from fastapi import Body, Path, Request, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.cache_annotation import ApiCache, ApiCacheEvict
from common.annotation.log_annotation import Log
from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.constant import ApiGroup, ApiNamespace
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_ai.entity.vo.ai_chat_vo import (
AiChatConfigModel,
AiChatRequestModel,
AiChatSessionBaseModel,
AiChatSessionModel,
)
from module_ai.service.ai_chat_service import AiChatService
from utils.log_util import logger
from utils.response_util import ResponseUtil
ai_chat_controller = APIRouterPro(
prefix='/ai/chat', order_num=19, tags=['AI管理-AI对话'], dependencies=[PreAuthDependency()]
)
@ai_chat_controller.post(
'/send',
summary='发送对话消息',
description='流式返回对话结果',
response_class=StreamingResponse,
responses={
200: {
'description': '流式返回对话结果',
'content': {
'text/event-stream': {},
},
}
},
)
@ApiRateLimit(namespace=ApiNamespace.AI_CHAT_SEND, preset=ApiRateLimitPreset.USER_INTERACTIVE_HIGH_FREQ)
async def send_chat_message(
request: Request,
chat_req: AiChatRequestModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> StreamingResponse:
user_id = current_user.user.user_id if current_user and current_user.user else 1
chat_stream = AiChatService.chat_services(query_db, chat_req, user_id)
logger.info(f'用户{user_id}发送对话消息成功')
return StreamingResponse(content=chat_stream, media_type='text/event-stream')
@ai_chat_controller.get(
'/config',
summary='获取用户对话配置',
description='获取当前用户的AI对话配置',
response_model=DataResponseModel[AiChatConfigModel],
)
@ApiCache(namespace=ApiNamespace.AI_CHAT_CONFIG)
async def get_user_chat_config(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
user_id = current_user.user.user_id
ai_chat_config_detail_result = await AiChatService.ai_chat_config_detail_services(query_db, user_id)
logger.info(f'获取user_id为{user_id}的对话配置成功')
return ResponseUtil.success(data=ai_chat_config_detail_result)
@ai_chat_controller.put(
'/config',
summary='保存用户对话配置',
description='保存当前用户的AI对话配置',
response_model=DataResponseModel[AiChatConfigModel],
)
@ApiCacheEvict(namespaces=ApiGroup.AI_CHAT_CONFIG_MUTATION)
@Log(title='AI对话配置管理', business_type=BusinessType.INSERT)
async def save_user_chat_config(
request: Request,
ai_chat_config: AiChatConfigModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
user_id = current_user.user.user_id if current_user and current_user.user else 1
save_ai_chat_config_result = await AiChatService.save_ai_chat_config_services(query_db, user_id, ai_chat_config)
logger.info(save_ai_chat_config_result.message)
return ResponseUtil.success(msg=save_ai_chat_config_result.message)
@ai_chat_controller.get(
'/session/list',
summary='获取会话列表',
description='获取用户的会话列表',
response_model=DataResponseModel[list[AiChatSessionBaseModel]],
)
async def get_chat_session_list(
request: Request,
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await AiChatService.get_chat_session_list_services(current_user.user.user_id)
logger.info('获取成功')
return ResponseUtil.success(data=result)
@ai_chat_controller.delete(
'/session/{session_id}',
summary='删除会话',
description='删除指定会话',
response_model=ResponseBaseModel,
)
@Log(title='AI对话会话管理', business_type=BusinessType.DELETE)
async def delete_chat_session(
request: Request,
session_id: Annotated[str, Path(description='会话ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
delete_chat_session_result = await AiChatService.delete_chat_session_services(session_id)
logger.info(delete_chat_session_result.message)
return ResponseUtil.success(msg=delete_chat_session_result.message)
@ai_chat_controller.get(
'/session/{session_id}',
summary='获取会话消息详情',
description='获取指定会话的消息详情',
response_model=DataResponseModel[AiChatSessionModel],
)
async def get_chat_session_detail(
request: Request,
session_id: Annotated[str, Path(description='会话ID')],
) -> Response:
chat_session_detail_result = await AiChatService.get_chat_session_detail_services(session_id)
logger.info(f'获取session_id为{session_id}的信息成功')
return ResponseUtil.success(data=chat_session_detail_result)
@ai_chat_controller.post(
'/cancel',
summary='取消对话',
description='取消正在进行的对话',
response_model=ResponseBaseModel,
)
@ApiRateLimit(namespace=ApiNamespace.AI_CHAT_CANCEL, preset=ApiRateLimitPreset.USER_INTERACTIVE_HIGH_FREQ)
async def cancel_chat_run(
request: Request,
run_id: Annotated[str, Body(embed=True, description='运行ID', alias='runId')],
) -> Response:
cancel_result = await AiChatService.cancel_run_services(run_id)
logger.info(cancel_result.message)
return ResponseUtil.success(msg=cancel_result.message)
@@ -1,188 +0,0 @@
from datetime import datetime
from typing import Annotated
from fastapi import Path, Query, Request, Response
from pydantic_validation_decorator import ValidateFields
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.cache_annotation import ApiCache, ApiCacheEvict
from common.annotation.log_annotation import Log, RequestLogFieldRoot
from common.aspect.data_scope import DataScopeDependency
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.constant import ApiGroup, ApiNamespace
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_ai.entity.do.ai_model_do import AiModels
from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel
from module_ai.service.ai_model_service import AiModelService
from utils.log_util import logger
from utils.response_util import ResponseUtil
ai_model_controller = APIRouterPro(
prefix='/ai/model', order_num=18, tags=['AI管理-模型管理'], dependencies=[PreAuthDependency()]
)
@ai_model_controller.get(
'/list',
summary='获取AI模型分页列表接口',
description='用于获取AI模型分页列表',
response_model=PageResponseModel[AiModelModel],
dependencies=[UserInterfaceAuthDependency('ai:model:list')],
)
@ApiCache(namespace=ApiNamespace.AI_MODEL_LIST)
async def get_ai_model_list(
request: Request,
ai_model_page_query: Annotated[AiModelPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
) -> Response:
# 获取分页数据
result = await AiModelService.get_ai_model_list_services(
query_db, ai_model_page_query, data_scope_sql, is_page=True
)
logger.info('获取成功')
return ResponseUtil.success(model_content=result)
@ai_model_controller.get(
'/all',
summary='获取AI模型不分页列表接口',
description='用于获取AI模型不分页列表',
response_model=DataResponseModel[AiModelModel],
)
@ApiCache(namespace=ApiNamespace.AI_MODEL_ALL)
async def get_ai_model_all(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
) -> Response:
# 获取不分页数据
ai_model_page_query = AiModelPageQueryModel(status='0')
result = await AiModelService.get_ai_model_list_services(
query_db, ai_model_page_query, data_scope_sql, is_page=False
)
logger.info('获取成功')
return ResponseUtil.success(data=result)
@ai_model_controller.post(
'',
summary='新增AI模型接口',
description='用于新增AI模型',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('ai:model:add')],
)
@ValidateFields(validate_model='add_ai_model')
@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION)
@Log(
title='AI模型管理',
business_type=BusinessType.INSERT,
request_log_mode='exclude',
request_exclude_fields=(RequestLogFieldRoot.JSON_BODY.field('api_key'),),
)
async def add_ai_model(
request: Request,
add_ai_model: AiModelModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
add_ai_model.user_id = current_user.user.user_id
add_ai_model.dept_id = current_user.user.dept_id
add_ai_model.create_by = current_user.user.user_name
add_ai_model.create_time = datetime.now()
add_ai_model.update_by = current_user.user.user_name
add_ai_model.update_time = datetime.now()
add_ai_model_result = await AiModelService.add_ai_model_services(query_db, add_ai_model)
logger.info(add_ai_model_result.message)
return ResponseUtil.success(msg=add_ai_model_result.message)
@ai_model_controller.put(
'',
summary='编辑AI模型接口',
description='用于编辑AI模型',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('ai:model:edit')],
)
@ValidateFields(validate_model='edit_ai_model')
@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION)
@Log(
title='AI模型管理',
business_type=BusinessType.UPDATE,
request_log_mode='exclude',
request_exclude_fields=(RequestLogFieldRoot.JSON_BODY.field('api_key'),),
)
async def edit_ai_model(
request: Request,
edit_ai_model: AiModelModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
) -> Response:
if not current_user.user.admin:
await AiModelService.check_ai_model_data_scope_services(query_db, edit_ai_model.model_id, data_scope_sql)
edit_ai_model.update_by = current_user.user.user_name
edit_ai_model.update_time = datetime.now()
edit_ai_model_result = await AiModelService.edit_ai_model_services(query_db, edit_ai_model)
logger.info(edit_ai_model_result.message)
return ResponseUtil.success(msg=edit_ai_model_result.message)
@ai_model_controller.delete(
'/{model_ids}',
summary='删除AI模型接口',
description='用于删除AI模型',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('ai:model:remove')],
)
@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION)
@Log(title='AI模型管理', business_type=BusinessType.DELETE)
async def delete_ai_model(
request: Request,
model_ids: Annotated[str, Path(description='需要删除的模型ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
) -> Response:
model_id_list = model_ids.split(',')
for model_id in model_id_list:
if not current_user.user.admin:
await AiModelService.check_ai_model_data_scope_services(query_db, int(model_id), data_scope_sql)
delete_ai_model = DeleteAiModelModel(modelIds=model_ids)
delete_ai_model_result = await AiModelService.delete_ai_model_services(query_db, delete_ai_model)
logger.info(delete_ai_model_result.message)
return ResponseUtil.success(msg=delete_ai_model_result.message)
@ai_model_controller.get(
'/{model_id}',
summary='获取AI模型详情接口',
description='用于获取指定AI模型的详细信息',
response_model=DataResponseModel[AiModelModel],
dependencies=[UserInterfaceAuthDependency('ai:model:query')],
)
@ApiCache(namespace=ApiNamespace.AI_MODEL_DETAIL)
async def get_ai_model_detail(
request: Request,
model_id: Annotated[int, Path(description='模型ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
) -> Response:
if not current_user.user.admin:
await AiModelService.check_ai_model_data_scope_services(query_db, model_id, data_scope_sql)
ai_model_detail_result = await AiModelService.ai_model_detail_services(query_db, model_id)
logger.info(f'获取model_id为{model_id}的信息成功')
return ResponseUtil.success(data=ai_model_detail_result)
@@ -1,52 +0,0 @@
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from module_ai.entity.do.ai_chat_do import AiChatConfig
from module_ai.entity.vo.ai_chat_vo import AiChatConfigModel
class AiChatConfigDao:
"""
AI对话配置数据库操作层
"""
@classmethod
async def get_chat_config_detail_by_user_id(cls, db: AsyncSession, user_id: int) -> AiChatConfig | None:
"""
根据用户ID获取配置
:param db: orm对象
:param user_id: 用户ID
:return: 配置对象
"""
ai_chat_config = (
(await db.execute(select(AiChatConfig).where(AiChatConfig.user_id == user_id))).scalars().first()
)
return ai_chat_config
@classmethod
async def add_chat_config_dao(cls, db: AsyncSession, chat_config: AiChatConfigModel) -> AiChatConfig:
"""
新增对话配置数据库操作
:param db: orm对象
:param chat_config: 对话配置对象
:return: 配置对象
"""
db_chat_config = AiChatConfig(**chat_config.model_dump(exclude_unset=True))
db.add(db_chat_config)
await db.flush()
return db_chat_config
@classmethod
async def edit_chat_config_dao(cls, db: AsyncSession, chat_config: dict) -> None:
"""
编辑对话配置数据库操作
:param db: orm对象
:param chat_config: 需要更新的对话配置字典
:return:
"""
await db.execute(update(AiChatConfig), [chat_config])
@@ -1,96 +0,0 @@
from typing import Any
from sqlalchemy import ColumnElement, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_ai.entity.do.ai_model_do import AiModels
from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel
from utils.page_util import PageUtil
class AiModelDao:
"""
AI模型管理数据库操作层
"""
@classmethod
async def get_ai_model_detail_by_id(cls, db: AsyncSession, model_id: int) -> AiModels | None:
"""
根据AI模型id获取AI模型详细信息
:param db: orm对象
:param model_id: AI模型id
:return: AI模型信息对象
"""
ai_model_info = (await db.execute(select(AiModels).where(AiModels.model_id == model_id))).scalars().first()
return ai_model_info
@classmethod
async def get_ai_model_list(
cls, db: AsyncSession, query_object: AiModelPageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False
) -> PageModel | list[dict[str, Any]]:
"""
根据查询参数获取AI模型列表信息
:param db: orm对象
:param query_object: 查询参数对象
:param data_scope_sql: 数据权限对应的查询sql语句
:param is_page: 是否开启分页
:return: AI模型列表信息对象
"""
query = (
select(AiModels)
.where(
AiModels.model_id == query_object.model_id if query_object.model_id else True,
AiModels.model_name.like(f'%{query_object.model_name}%') if query_object.model_name else True,
AiModels.model_code.like(f'%{query_object.model_code}%') if query_object.model_code else True,
AiModels.provider == query_object.provider if query_object.provider else True,
AiModels.status == query_object.status if query_object.status else True,
data_scope_sql,
)
.order_by(AiModels.model_sort)
)
ai_model_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate(
db, query, query_object.page_num, query_object.page_size, is_page
)
return ai_model_list
@classmethod
async def add_ai_model_dao(cls, db: AsyncSession, ai_model: AiModelModel) -> AiModels:
"""
新增AI模型数据库操作
:param db: orm对象
:param ai_model: AI模型对象
:return: AI模型信息对象
"""
db_model = AiModels(**ai_model.model_dump(exclude_unset=True))
db.add(db_model)
await db.flush()
return db_model
@classmethod
async def edit_ai_model_dao(cls, db: AsyncSession, ai_model: dict) -> None:
"""
编辑AI模型数据库操作
:param db: orm对象
:param ai_model: 需要更新的AI模型字典
:return:
"""
await db.execute(update(AiModels), [ai_model])
@classmethod
async def delete_ai_model_dao(cls, db: AsyncSession, ai_model: AiModelModel) -> None:
"""
删除AI模型数据库操作
:param db: orm对象
:param ai_model: AI模型对象
:return:
"""
await db.execute(delete(AiModels).where(AiModels.model_id.in_([ai_model.model_id])))
@@ -1,26 +0,0 @@
from datetime import datetime
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Float, Integer, Text
from config.database import Base
class AiChatConfig(Base):
"""
AI对话配置表
"""
__tablename__ = 'ai_chat_config'
__table_args__ = {'comment': 'AI对话配置表'}
chat_config_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='配置主键')
user_id = Column(BigInteger, nullable=False, unique=True, comment='用户ID')
temperature = Column(Float, nullable=True, comment='默认温度')
add_history_to_context = Column(CHAR(1), server_default='0', comment='是否添加历史记录(0是, 1否)')
num_history_runs = Column(Integer, nullable=True, comment='历史记录条数')
system_prompt = Column(Text, nullable=True, comment='系统提示词')
metrics_default_visible = Column(CHAR(1), server_default='0', comment='默认显示指标(0是, 1否)')
vision_enabled = Column(CHAR(1), server_default='1', comment='是否开启视觉(0是, 1否)')
image_max_size_mb = Column(Integer, nullable=True, comment='图片最大大小(MB)')
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
update_time = Column(DateTime, nullable=True, default=datetime.now, comment='更新时间')
@@ -1,62 +0,0 @@
from datetime import datetime
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Float, Integer, String
from config.database import Base
from config.env import DataBaseConfig
from utils.common_util import SqlalchemyUtil
class AiModels(Base):
"""
AI模型表
"""
__tablename__ = 'ai_models'
__table_args__ = {'comment': 'AI模型表'}
model_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='模型主键')
model_code = Column(String(100), nullable=False, comment='模型编码')
model_name = Column(
String(100),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
comment='模型名称',
)
provider = Column(String(50), nullable=False, comment='提供商')
model_sort = Column(Integer, nullable=False, comment='显示顺序')
api_key = Column(
String(255),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
comment='API Key',
)
base_url = Column(
String(255),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
comment='Base URL',
)
model_type = Column(
String(50),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
comment='模型类型',
)
max_tokens = Column(Integer, nullable=True, comment='最大输出token')
temperature = Column(Float, nullable=True, comment='默认温度')
support_reasoning = Column(CHAR(1), server_default='N', comment='是否支持推理')
support_images = Column(CHAR(1), server_default='N', comment='是否支持图片')
status = Column(CHAR(1), server_default='0', comment='模型状态')
user_id = Column(BigInteger, nullable=True, comment='用户ID')
dept_id = Column(BigInteger, nullable=True, comment='部门ID')
create_by = Column(String(64), nullable=True, server_default="''", comment='创建者')
create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间')
update_by = Column(String(64), nullable=True, server_default="''", comment='更新者')
update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间')
remark = Column(
String(500),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
comment='备注',
)
@@ -1,159 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
class AiChatRequestModel(BaseModel):
"""
AI对话请求模型
"""
model_config = ConfigDict(alias_generator=to_camel)
session_id: str | None = Field(default=None, description='会话ID')
model_id: int = Field(description='模型ID')
message: str = Field(description='用户消息')
is_reasoning: bool | None = Field(default=None, description='本次是否开启深度思考')
images: list[str] | None = Field(default=None, description='图片URL列表')
class AiChatConfigModel(BaseModel):
"""
AI对话配置模型
"""
model_config = ConfigDict(alias_generator=to_camel)
chat_config_id: int | None = Field(default=None, description='配置主键')
user_id: int | None = Field(default=None, description='用户ID')
temperature: float | None = Field(default=None, description='默认温度')
add_history_to_context: Literal['0', '1'] | None = Field(default=None, description='是否添加历史记录')
num_history_runs: int | None = Field(default=3, description='历史记录条数')
system_prompt: str | None = Field(default=None, description='系统提示词')
metrics_default_visible: Literal['0', '1'] | None = Field(default=None, description='默认显示指标')
vision_enabled: Literal['0', '1'] | None = Field(default=None, description='是否开启视觉')
image_max_size_mb: int | None = Field(default=None, description='图片最大大小(MB)')
create_time: datetime | None = Field(default=None, description='创建时间')
update_time: datetime | None = Field(default=None, description='更新时间')
class AiChatSessionBaseModel(BaseModel):
"""
AI对话会话基础模型
"""
model_config = ConfigDict(alias_generator=to_camel)
session_id: str = Field(description='会话ID')
session_title: str | None = Field(default=None, description='会话标题')
session_type: str | None = Field(default=None, description='会话类型')
user_id: str | None = Field(default=None, description='用户ID')
created_at: datetime | None = Field(default=None, description='创建时间')
updated_at: datetime | None = Field(default=None, description='更新时间')
class ModelInfoModel(BaseModel):
"""
对话会话数据模型-模型信息模型
"""
model_config = ConfigDict(alias_generator=to_camel)
id: str | None = Field(default=None, description='模型ID')
name: str | None = Field(default=None, description='模型名称')
provider: str | None = Field(default=None, description='模型提供者')
temperature: float | None = Field(default=None, description='模型温度')
class AgentDataModel(BaseModel):
"""
对话会话数据模型-智能体数据模型
"""
model_config = ConfigDict(alias_generator=to_camel)
model: ModelInfoModel | None = Field(default=None, description='模型信息')
agent_id: str | None = Field(default=None, description='智能体ID')
class SessionMetricsModel(BaseModel):
"""
对话会话数据模型-会话指标模型
"""
model_config = ConfigDict(alias_generator=to_camel)
cost: float | None = Field(default=None, description='成本(元)')
timer: float | None = Field(default=None, description='运行时长(秒)')
duration: float | None = Field(default=None, description='运行时长(秒)')
input_tokens: int | None = Field(default=None, description='输入Token数量')
total_tokens: int | None = Field(default=None, description='总Token数量')
output_tokens: int | None = Field(default=None, description='输出Token数量')
provider_metrics: dict | None = Field(default=None, description='提供者指标')
reasoning_tokens: int | None = Field(default=None, description='推理Token数量')
cache_read_tokens: int | None = Field(default=None, description='缓存读取Token数量')
additional_metrics: dict | None = Field(default=None, description='其他指标')
audio_input_tokens: int | None = Field(default=None, description='音频输入Token数量')
audio_total_tokens: int | None = Field(default=None, description='音频总Token数量')
cache_write_tokens: int | None = Field(default=None, description='缓存写入Token数量')
audio_output_tokens: int | None = Field(default=None, description='音频输出Token数量')
time_to_first_token: float | None = Field(default=None, description='到第一个Token的时间(秒)')
class SessionDataModel(BaseModel):
"""
对话会话数据模型
"""
model_config = ConfigDict(alias_generator=to_camel)
session_state: dict | None = Field(default=None, description='会话状态')
session_metrics: SessionMetricsModel | None = Field(default=None, description='会话指标')
class MessageMetrics(BaseModel):
"""
对话消息模型-消息指标模型
"""
model_config = ConfigDict(alias_generator=to_camel)
input_tokens: int | None = Field(default=None, description='输入Token数量')
total_tokens: int | None = Field(default=None, description='总Token数量')
output_tokens: int | None = Field(default=None, description='输出Token数量')
reasoning_tokens: int | None = Field(default=None, description='推理Token数量')
duration: float | None = Field(default=None, description='运行时长(秒)')
time_to_first_token: float | None = Field(default=None, description='到第一个Token的时间(秒)')
class ChatMessageModel(BaseModel):
"""
对话消息模型
"""
model_config = ConfigDict(alias_generator=to_camel)
id: str | None = Field(default=None, description='消息ID')
role: str | None = Field(default=None, description='角色')
content: str | None = Field(default=None, description='内容')
images: list[str] | None = Field(default=None, description='图片列表')
metrics: MessageMetrics | None = Field(default=None, description='Token使用统计')
created_at: datetime | None = Field(default=None, description='创建时间')
from_history: bool | None = Field(default=None, description='是否来自历史记录')
reasoning_content: str | None = Field(default=None, description='推理/思考内容')
stop_after_tool_call: bool | None = Field(default=None, description='是否在工具调用后停止')
class AiChatSessionModel(AiChatSessionBaseModel):
"""
AI对话会话模型
"""
model_config = ConfigDict(alias_generator=to_camel)
agent_id: str | None = Field(default=None, description='智能体ID')
session_data: SessionDataModel | None = Field(default=None, description='会话数据')
agent_data: AgentDataModel | None = Field(default=None, description='智能体数据')
messages: list[ChatMessageModel] | None = Field(default=None, description='消息列表')
@@ -1,64 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from pydantic_validation_decorator import NotBlank, Size
class AiModelModel(BaseModel):
"""
AI模型表对应pydantic模型
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
model_id: int | None = Field(default=None, description='模型主键')
model_code: str | None = Field(default=None, description='模型编码')
model_name: str | None = Field(default=None, description='模型名称')
provider: str | None = Field(default=None, description='提供商')
model_sort: int | None = Field(default=None, description='显示顺序')
api_key: str | None = Field(default=None, description='API Key')
base_url: str | None = Field(default=None, description='Base URL')
max_tokens: int | None = Field(default=None, description='最大输出token')
temperature: float | None = Field(default=None, description='默认温度')
support_reasoning: Literal['Y', 'N'] | None = Field(default=None, description='是否支持推理(深度思考)')
support_images: Literal['Y', 'N'] | None = Field(default=None, description='是否支持图片')
model_type: str | None = Field(default=None, description='模型类型')
status: Literal['0', '1'] | None = Field(default=None, description='模型状态')
user_id: int | None = Field(default=None, description='用户ID')
dept_id: int | None = Field(default=None, description='部门ID')
create_by: str | None = Field(default=None, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间')
update_by: str | None = Field(default=None, description='更新者')
update_time: datetime | None = Field(default=None, description='更新时间')
remark: str | None = Field(default=None, description='备注')
@NotBlank(field_name='model_code', message='模型编码不能为空')
@Size(field_name='model_code', min_length=0, max_length=100, message='模型编码长度不能超过100个字符')
def get_model_code(self) -> str | None:
return self.model_code
@NotBlank(field_name='base_url', message='Base URL不能为空')
@Size(field_name='base_url', min_length=0, max_length=255, message='Base URL长度不能超过255个字符')
def get_base_url(self) -> str | None:
return self.base_url
class AiModelPageQueryModel(AiModelModel):
"""
AI模型管理分页查询模型
"""
page_num: int = Field(default=1, description='当前页码')
page_size: int = Field(default=10, description='每页记录数')
class DeleteAiModelModel(BaseModel):
"""
删除AI模型模型
"""
model_config = ConfigDict(alias_generator=to_camel)
model_ids: str = Field(description='需要删除的模型主键')
@@ -1,483 +0,0 @@
import json
import os
import uuid
from collections.abc import AsyncGenerator, AsyncIterator
from datetime import datetime
from typing import TYPE_CHECKING, Any
from agno.agent import Agent
from agno.db.base import SessionType
from agno.media import Image
from agno.run.agent import RunEvent, RunOutput, RunOutputEvent
from agno.run.cancel import acancel_run
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel
from config.env import UploadConfig
from exceptions.exception import ServiceException
from module_ai.dao.ai_chat_dao import AiChatConfigDao
from module_ai.dao.ai_model_dao import AiModelDao
from module_ai.entity.do.ai_chat_do import AiChatConfig
from module_ai.entity.vo.ai_chat_vo import (
AgentDataModel,
AiChatConfigModel,
AiChatRequestModel,
AiChatSessionBaseModel,
AiChatSessionModel,
ChatMessageModel,
MessageMetrics,
SessionDataModel,
SessionMetricsModel,
)
from module_ai.entity.vo.ai_model_vo import AiModelModel
from utils.ai_util import AiUtil
from utils.common_util import CamelCaseUtil
from utils.crypto_util import CryptoUtil
if TYPE_CHECKING:
from agno.models.message import Message
from agno.run.team import TeamRunOutput
from agno.run.workflow import WorkflowRunOutput
from agno.session import Session
class AiChatService:
"""
AI对话服务层
"""
@classmethod
def _resolve_temperature(cls, user_config: AiChatConfigModel, model_config: AiModelModel) -> float:
"""
解析温度配置,优先级为 用户配置 > 模型配置
:param user_config: 用户配置对象
:param model_config: 模型配置对象
:return: 解析后的温度值
"""
temperature = user_config.temperature or model_config.temperature
return temperature
@classmethod
def _resolve_is_reasoning(cls, chat_req: AiChatRequestModel, model_config: AiModelModel) -> bool:
"""
解析深度思考开关,结合请求参数与模型配置确定最终是否开启
:param chat_req: 对话请求对象
:param model_config: 模型配置对象
:return: 是否开启深度思考
"""
if model_config.support_reasoning != 'Y':
return False
return bool(chat_req.is_reasoning)
@classmethod
def _resolve_history_config(cls, user_config: AiChatConfigModel) -> tuple[bool, int]:
"""
解析历史消息配置,确定是否附带历史以及轮数
:param user_config: 用户配置对象
:return: (是否附带历史, 历史轮数)
"""
add_history = user_config.add_history_to_context == '0'
num_history = user_config.num_history_runs or 3
return bool(add_history), int(num_history)
@classmethod
def _build_agent(
cls,
model_config: AiModelModel,
temperature: float,
system_prompt: str | None,
user_id: int,
session_id: str,
add_history: bool,
num_history: int,
) -> Agent:
"""
构建对话Agent对象
:param model_config: 模型配置对象
:param temperature: 对话温度
:param system_prompt: 系统提示词
:param user_id: 用户ID
:param session_id: 会话ID
:param add_history: 是否附带历史消息
:param num_history: 历史消息轮数
:return: Agent对象
"""
real_api_key = CryptoUtil.decrypt(model_config.api_key)
model = AiUtil.get_model_from_factory(
provider=model_config.provider,
model_code=model_config.model_code,
model_name=model_config.model_name,
api_key=real_api_key,
base_url=model_config.base_url,
temperature=temperature,
max_tokens=model_config.max_tokens,
)
storage = AiUtil.get_storage_engine()
return Agent(
model=model,
id='chat-agent',
description=system_prompt or 'You are a helpful AI assistant.',
db=storage,
user_id=str(user_id),
session_id=session_id,
add_history_to_context=add_history,
num_history_runs=num_history,
markdown=True,
)
@classmethod
def _build_run_kwargs(
cls,
chat_req: AiChatRequestModel,
user_config: AiChatConfigModel,
) -> dict[str, Any]:
"""
构造Agent运行参数
:param chat_req: 对话请求对象
:param user_config: 用户配置对象
:return: 运行参数字典
"""
run_kwargs: dict[str, Any] = {'stream': True, 'stream_events': True}
if not chat_req.images or not user_config.vision_enabled:
return run_kwargs
processed_images: list[Image] = []
for img in chat_req.images:
if img and img.startswith(UploadConfig.UPLOAD_PREFIX):
relative_path = img[len(UploadConfig.UPLOAD_PREFIX) :]
if relative_path.startswith('/'):
relative_path = relative_path[1:]
file_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path)
abs_path = os.path.abspath(file_path)
if os.path.exists(abs_path):
processed_images.append(Image(filepath=abs_path))
run_kwargs['images'] = processed_images
return run_kwargs
@classmethod
def _convert_images_to_upload_paths(cls, images: list[Image] | None) -> list[str] | None:
"""
将Agno Image对象列表转换为前端可访问的上传路径列表
:param images: Image对象列表
:return: 上传路径列表
"""
if not images:
return None
result = []
for img in images:
# 如果是本地文件路径
if hasattr(img, 'filepath') and img.filepath:
try:
# 使用 abspath 确保路径标准化
abs_filepath = os.path.abspath(img.filepath)
abs_upload_path = os.path.abspath(UploadConfig.UPLOAD_PATH)
if abs_filepath.startswith(abs_upload_path):
relative_path = os.path.relpath(abs_filepath, abs_upload_path)
# 转换路径分隔符为URL格式
url_path = relative_path.replace(os.sep, '/')
# 拼接前缀
full_url = f'{UploadConfig.UPLOAD_PREFIX}/{url_path}'.replace('//', '/')
result.append(full_url)
else:
result.append(img.filepath)
except Exception:
result.append(img.filepath)
# 如果是URL
elif hasattr(img, 'url') and img.url:
result.append(img.url)
return result if result else None
@classmethod
async def _stream_agent(
cls,
agent: Agent,
chat_req: AiChatRequestModel,
run_kwargs: dict[str, Any],
is_reasoning: bool,
session_id: str,
) -> AsyncGenerator[str, None]:
"""
将Agent输出流式转换为前端SSE消息
:param agent: Agent实例
:param chat_req: 对话请求对象
:param run_kwargs: 运行参数字典
:param is_reasoning: 是否输出推理内容
:param session_id: 会话ID
:return: SSE消息生成器
"""
full_response = ''
full_reasoning = ''
try:
yield json.dumps({'session_id': session_id, 'type': 'meta'}) + '\n'
response_stream: AsyncIterator[RunOutputEvent] = agent.arun(chat_req.message, **run_kwargs)
async for chunk in response_stream:
content = None
reasoning = None
if chunk.event == RunEvent.run_started and chunk.run_id:
yield json.dumps({'run_id': chunk.run_id, 'type': 'run_info'}) + '\n'
if chunk.event == RunEvent.run_content:
content = chunk.content
if hasattr(chunk, 'reasoning_content') and chunk.reasoning_content:
reasoning = chunk.reasoning_content
if reasoning and is_reasoning:
full_reasoning += reasoning
yield json.dumps({'content': reasoning, 'type': 'reasoning'}) + '\n'
if chunk.event == RunEvent.run_completed and chunk.metrics:
yield (
json.dumps(
{'metrics': CamelCaseUtil.transform_result(chunk.metrics.to_dict()), 'type': 'metrics'}
)
+ '\n'
)
if content:
full_response += content
yield json.dumps({'content': content, 'type': 'content'}) + '\n'
except Exception as e:
yield json.dumps({'error': str(e), 'type': 'error'}) + '\n'
@classmethod
async def chat_services(
cls, query_db: AsyncSession, chat_req: AiChatRequestModel, user_id: int
) -> AsyncGenerator[str, None]:
"""
流式对话
:param query_db: orm对象
:param chat_req: 对话请求对象
:param user_id: 用户ID
:return: 对话响应流
"""
ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, chat_req.model_id)
model_config = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel()
if not model_config:
raise ServiceException(message='模型不存在')
user_config = await cls.ai_chat_config_detail_services(query_db, user_id)
session_id = chat_req.session_id
if not session_id:
session_id = str(uuid.uuid4())
temperature = cls._resolve_temperature(user_config, model_config)
is_reasoning = cls._resolve_is_reasoning(chat_req, model_config)
add_history, num_history = cls._resolve_history_config(user_config)
system_prompt = user_config.system_prompt
agent = cls._build_agent(
model_config=model_config,
temperature=temperature,
system_prompt=system_prompt,
user_id=user_id,
session_id=session_id,
add_history=add_history,
num_history=num_history,
)
run_kwargs = cls._build_run_kwargs(chat_req, user_config)
async for chunk in cls._stream_agent(
agent=agent,
chat_req=chat_req,
run_kwargs=run_kwargs,
is_reasoning=is_reasoning,
session_id=session_id,
):
yield chunk
@classmethod
async def ai_chat_config_detail_services(cls, query_db: AsyncSession, user_id: int) -> AiChatConfigModel:
"""
获取用户配置
:param query_db: orm对象
:param user_id: 用户ID
:return: 配置模型
"""
chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id)
result = AiChatConfigModel(**CamelCaseUtil.transform_result(chat_config)) if chat_config else AiChatConfig()
return result
@classmethod
async def save_ai_chat_config_services(
cls, query_db: AsyncSession, user_id: int, page_object: AiChatConfigModel
) -> CrudResponseModel:
"""
保存用户配置
:param query_db: orm对象
:param user_id: 用户ID
:param page_object: AI对话配置对象
:return: 更新后的配置模型
"""
chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id)
if page_object.user_id is None:
page_object.user_id = user_id
try:
if chat_config:
if chat_config.chat_config_id != page_object.chat_config_id:
raise ServiceException(message='只允许修改当前用户的配置')
page_object.update_time = datetime.now()
edit_ai_chat_config = page_object.model_dump(exclude_unset=True)
await AiChatConfigDao.edit_chat_config_dao(query_db, edit_ai_chat_config)
else:
page_object.create_time = datetime.now()
await AiChatConfigDao.add_chat_config_dao(query_db, page_object)
await query_db.commit()
except Exception as e:
await query_db.rollback()
raise e
return CrudResponseModel(is_success=True, message='保存成功')
@classmethod
async def get_chat_session_list_services(cls, user_id: int) -> list[AiChatSessionBaseModel]:
"""
获取用户会话列表
:param user_id: 用户ID
:return: 用户会话列表
"""
# 获取Agno会话列表
storage = AiUtil.get_storage_engine()
sessions: list[Session] = await storage.get_sessions(
user_id=str(user_id),
component_id='chat-agent',
session_type=SessionType.AGENT,
)
result = []
for s in sessions:
created_at = datetime.fromtimestamp(s.created_at) if s.created_at else None
updated_at = datetime.fromtimestamp(s.updated_at) if s.updated_at else None
title_limit = 20
session_title = s.runs[0].input.input_content[:title_limit] + '...' if s.runs else ''
result.append(
AiChatSessionBaseModel(
sessionId=s.session_id,
sessionTitle=session_title if len(session_title) <= title_limit else session_title[:title_limit],
userId=s.user_id,
createdAt=created_at,
updatedAt=updated_at,
)
)
return result
@classmethod
async def delete_chat_session_services(cls, session_id: str) -> CrudResponseModel:
"""
删除会话
:param session_id: 会话ID
:return: 删除结果
"""
storage = AiUtil.get_storage_engine()
delete_result = await storage.delete_session(session_id=session_id)
if not delete_result:
raise ServiceException(message='删除会话失败')
return CrudResponseModel(is_success=True, message='删除成功')
@classmethod
async def get_chat_session_detail_services(cls, session_id: str) -> AiChatSessionModel:
"""
获取会话消息详情
:param session_id: 会话ID
:return: 会话消息详情
"""
storage = AiUtil.get_storage_engine()
session: Session | None = await storage.get_session(session_id=session_id, session_type=SessionType.AGENT)
if not session:
raise ServiceException(message='会话不存在')
session_data: dict[str, Any] = session.session_data
agent_data: dict[str, Any] = session.agent_data
runs: list[RunOutput | TeamRunOutput | WorkflowRunOutput] = session.runs
messages: list[Message] = session.get_messages(skip_roles=['system'])
run_metrics_map = {}
if runs:
for run in runs:
if run.model_provider_data and (provider_id := run.model_provider_data.get('id')):
run_metrics_map[provider_id] = run.metrics
chat_messages = []
for m in messages:
if hasattr(m, 'provider_data') and m.provider_data:
provider_id = m.provider_data.get('id')
if provider_id and provider_id in run_metrics_map:
m.metrics = run_metrics_map[provider_id]
metrics_model = None
if getattr(m, 'metrics', None) and hasattr(m.metrics, 'to_dict'):
metrics_dict = m.metrics.to_dict()
if metrics_dict:
metrics_model = MessageMetrics(**CamelCaseUtil.transform_result(metrics_dict))
chat_messages.append(
ChatMessageModel(
id=m.id,
role=m.role,
content=m.content,
images=cls._convert_images_to_upload_paths(m.images),
metrics=metrics_model,
createdAt=datetime.fromtimestamp(m.created_at) if m.created_at else None,
reasoningContent=m.reasoning_content,
fromHistory=m.from_history,
stopAfterToolCall=m.stop_after_tool_call,
)
)
session_detail = AiChatSessionModel(
sessionId=session.session_id,
sessionTitle=session.runs[0].input.input_content[:20] + '...' if session.runs else '',
userId=session.user_id,
createdAt=datetime.fromtimestamp(session.created_at) if session.created_at else None,
updatedAt=datetime.fromtimestamp(session.updated_at) if session.updated_at else None,
agentId=session.agent_id,
sessionData=SessionDataModel(
sessionState=session_data.get('session_state'),
sessionMetrics=SessionMetricsModel(
**CamelCaseUtil.transform_result(session_data.get('session_metrics'))
),
),
agentData=AgentDataModel(**CamelCaseUtil.transform_result(agent_data)),
messages=chat_messages,
)
return session_detail
@classmethod
async def cancel_run_services(cls, run_id: str) -> CrudResponseModel:
"""
取消运行
:param run_id: 运行ID
:return: 取消结果
"""
cancel_result = await acancel_run(run_id)
if not cancel_result:
raise ServiceException(message='取消运行失败')
return CrudResponseModel(is_success=True, message='取消成功')
@@ -1,155 +0,0 @@
from typing import Any
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from exceptions.exception import ServiceException
from module_ai.dao.ai_model_dao import AiModelDao
from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel
from utils.common_util import CamelCaseUtil
from utils.crypto_util import CryptoUtil
class AiModelService:
"""
AI模型管理服务层
"""
@classmethod
async def get_ai_model_list_services(
cls,
query_db: AsyncSession,
query_object: AiModelPageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> PageModel | list[dict[str, Any]]:
"""
获取AI模型列表信息service
:param query_db: orm对象
:param query_object: 查询参数对象
:param data_scope_sql: 数据权限对应的查询sql语句
:param is_page: 是否开启分页
:return: AI模型列表信息对象
"""
ai_model_list_result = await AiModelDao.get_ai_model_list(query_db, query_object, data_scope_sql, is_page)
rows = ai_model_list_result.rows if isinstance(ai_model_list_result, PageModel) else ai_model_list_result
for row in rows:
if 'apiKey' in row:
row['apiKey'] = '********' * 3
return ai_model_list_result
@classmethod
async def check_ai_model_data_scope_services(
cls,
query_db: AsyncSession,
model_id: int,
data_scope_sql: ColumnElement,
) -> CrudResponseModel:
"""
校验用户是否有AI模型数据权限service
:param query_db: orm对象
:param model_id: 模型主键
:param data_scope_sql: 数据权限对应的查询sql语句
:return: 校验结果
"""
ai_models = await AiModelDao.get_ai_model_list(
query_db, AiModelModel(modelId=model_id), data_scope_sql, is_page=False
)
if ai_models:
return CrudResponseModel(is_success=True, message='校验通过')
raise ServiceException(message='没有权限访问AI模型数据')
@classmethod
async def add_ai_model_services(cls, query_db: AsyncSession, page_object: AiModelModel) -> CrudResponseModel:
"""
新增AI模型信息service
:param request: Request对象
:param query_db: orm对象
:param page_object: 新增AI模型对象
:return: 新增AI模型校验结果
"""
try:
if page_object.api_key:
page_object.api_key = CryptoUtil.encrypt(page_object.api_key)
await AiModelDao.add_ai_model_dao(query_db, page_object)
await query_db.commit()
return CrudResponseModel(is_success=True, message='新增成功')
except Exception as e:
await query_db.rollback()
raise e
@classmethod
async def edit_ai_model_services(cls, query_db: AsyncSession, page_object: AiModelModel) -> CrudResponseModel:
"""
编辑AI模型信息service
:param query_db: orm对象
:param page_object: 编辑AI模型对象
:return: 编辑AI模型校验结果
"""
edit_ai_model = page_object.model_dump(exclude_unset=True)
if page_object.api_key:
if page_object.api_key == '********' * 3:
if 'api_key' in edit_ai_model:
del edit_ai_model['api_key']
else:
edit_ai_model['api_key'] = CryptoUtil.encrypt(page_object.api_key)
ai_model_info = await cls.ai_model_detail_services(query_db, page_object.model_id)
if ai_model_info.model_id:
try:
await AiModelDao.edit_ai_model_dao(query_db, edit_ai_model)
await query_db.commit()
return CrudResponseModel(is_success=True, message='修改成功')
except Exception as e:
await query_db.rollback()
raise e
else:
raise ServiceException(message='AI模型不存在')
@classmethod
async def delete_ai_model_services(
cls, query_db: AsyncSession, page_object: DeleteAiModelModel
) -> CrudResponseModel:
"""
删除AI模型信息service
:param query_db: orm对象
:param page_object: 删除AI模型对象
:return: 删除AI模型校验结果
"""
if page_object.model_ids:
model_id_list = page_object.model_ids.split(',')
try:
for model_id in model_id_list:
await AiModelDao.delete_ai_model_dao(query_db, AiModelModel(modelId=model_id))
await query_db.commit()
return CrudResponseModel(is_success=True, message='删除成功')
except Exception as e:
await query_db.rollback()
raise e
else:
raise ServiceException(message='传入AI模型id为空')
@classmethod
async def ai_model_detail_services(cls, query_db: AsyncSession, model_id: int) -> AiModelModel:
"""
获取AI模型详细信息service
:param query_db: orm对象
:param model_id: AI模型id
:return: AI模型id对应的信息
"""
ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, model_id=model_id)
result = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel()
if result.api_key:
result.api_key = '********' * 3
return result
@@ -0,0 +1,129 @@
from datetime import datetime
from typing import Annotated
from fastapi import Path, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel
from module_business.service.master_service import MasterService
from utils.response_util import ResponseUtil
customer_controller = APIRouterPro(
prefix='/master/customer', tags=['基础资料-客户管理'], dependencies=[PreAuthDependency()]
)
@customer_controller.get(
'/list',
summary='获取客户分页列表接口',
description='用于获取客户分页列表',
response_model=PageResponseModel[CustomerModel],
dependencies=[UserInterfaceAuthDependency('master:customer:list')],
)
async def get_master_customer_list(
request: Request,
customer_page_query: Annotated[CustomerPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True)
return ResponseUtil.success(model_content=customer_page_query_result)
@customer_controller.get(
'/{customer_id}',
summary='获取客户详情接口',
description='用于获取指定客户的详细信息',
response_model=DataResponseModel[CustomerModel],
dependencies=[UserInterfaceAuthDependency('master:customer:query')],
)
async def query_detail_master_customer(
request: Request,
customer_id: Annotated[int, Path(gt=0, description='客户ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope)
return ResponseUtil.success(data=customer_detail_result)
@customer_controller.post(
'',
summary='新增客户接口',
description='用于新增客户',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:customer:add')],
)
@Log(title='客户管理', business_type=BusinessType.INSERT)
async def add_master_customer(
request: Request,
add_customer: CustomerModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
current_time = datetime.now()
add_customer.create_by = current_user.user.user_name
add_customer.create_time = current_time
add_customer.update_by = current_user.user.user_name
add_customer.update_time = current_time
add_customer_result = await MasterService.add_customer(query_db, add_customer, current_user, scope)
return ResponseUtil.success(msg=add_customer_result.message)
@customer_controller.put(
'',
summary='编辑客户接口',
description='用于编辑客户',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:customer:edit')],
)
@Log(title='客户管理', business_type=BusinessType.UPDATE)
async def edit_master_customer(
request: Request,
edit_customer: CustomerModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
edit_customer.update_by = current_user.user.user_name
edit_customer.update_time = datetime.now()
edit_customer_result = await MasterService.edit_customer(query_db, edit_customer, current_user, scope)
return ResponseUtil.success(msg=edit_customer_result.message)
@customer_controller.delete(
'/{customer_ids}',
summary='兼容旧接口,固定拒绝,请使用测试数据批次清理',
description='兼容旧接口,固定拒绝单条物理删除,请使用测试数据批次清理',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:customer:list')],
)
@Log(title='客户管理', business_type=BusinessType.DELETE)
async def delete_master_customer(
request: Request,
customer_ids: Annotated[
str,
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的客户ID'),
],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
delete_customer = DeleteCustomerModel(customerIds=[int(customer_id) for customer_id in customer_ids.split(',')])
delete_customer_result = await MasterService.remove_customer(query_db, delete_customer)
return ResponseUtil.success(msg=delete_customer_result.message)
@@ -0,0 +1,37 @@
"""首页运营驾驶舱接口。"""
from typing import Annotated
from fastapi import Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.router import APIRouterPro
from common.vo import PageResponseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.entity.vo.dashboard_vo import ScanExceptionModel
from module_business.service.dashboard_service import DashboardService
from utils.response_util import ResponseUtil
dashboard_controller = APIRouterPro(
prefix='/business/dashboard',
tags=['业务管理-运营驾驶舱'],
dependencies=[PreAuthDependency()],
)
@dashboard_controller.get(
'/scan-exceptions',
summary='获取首页扫码异常分页列表接口',
description='普通用户仅查看本人扫码异常,超级管理员查看全部扫码异常',
response_model=PageResponseModel[ScanExceptionModel],
)
async def get_business_dashboard_scan_exceptions(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await DashboardService.get_scan_exception_list_services(query_db, current_user)
return ResponseUtil.success(model_content=result)
@@ -0,0 +1,141 @@
from typing import Annotated
from fastapi import Path, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
FillingCreateModel,
FillingModel,
FillingPageQueryModel,
PalletScanResponseModel,
)
from module_business.service.filling_service import FillingService
from utils.response_util import ResponseUtil
filling_controller = APIRouterPro(
prefix='/business/filling',
tags=['业务管理-托盘装填'],
dependencies=[PreAuthDependency()],
)
@filling_controller.get(
'/list',
summary='获取托盘装填记录分页列表接口',
description='按当前用户仓库数据范围获取托盘装填记录分页列表',
response_model=PageResponseModel[FillingModel],
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
)
async def get_business_filling_list(
request: Request,
filling_page_query: Annotated[FillingPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.get_filling_list_services(
query_db,
filling_page_query,
current_user,
test_scope,
is_page=True,
)
return ResponseUtil.success(model_content=result)
@filling_controller.get(
'/strain-options',
summary='获取装填菌种选项接口',
description='获取全部启用菌种的业务选项',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
async def get_business_filling_strain_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.get_strain_options_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@filling_controller.get(
'/scan/{qr_token:path}',
summary='扫描待装填托盘接口',
description='按托盘当前仓库数据范围判断空托盘是否可装填',
response_model=DataResponseModel[PalletScanResponseModel],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
@Log(
title='扫码操作',
business_type=BusinessType.OTHER,
request_log_mode='none',
response_log_mode='summary',
oper_url_mode='route',
)
async def scan_business_filling_pallet(
request: Request,
qr_token: Annotated[
str,
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.scan_filling_pallet_services(
query_db,
qr_token,
current_user,
test_scope,
)
return ResponseUtil.success(data=result)
@filling_controller.get(
'/{filling_id}',
summary='获取托盘装填记录详情接口',
description='按仓库数据范围获取指定托盘装填记录',
response_model=DataResponseModel[FillingModel],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
async def query_detail_business_filling(
request: Request,
filling_id: Annotated[int, Path(gt=0, description='装填记录ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.filling_detail_services(query_db, filling_id, current_user, test_scope)
return ResponseUtil.success(data=result)
@filling_controller.post(
'',
summary='单托盘装填接口',
description='在仓库内即时确认单个空托盘装填',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:filling:add')],
)
@Log(title='托盘装填', business_type=BusinessType.INSERT)
async def add_business_filling(
request: Request,
add_filling: FillingCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await FillingService.fill_pallet_services(query_db, add_filling, current_user, test_scope)
return ResponseUtil.success(msg=result.message)
@@ -0,0 +1,218 @@
from typing import Annotated
from fastapi import Path, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
ConfirmCommandModel,
InboundCreateModel,
InboundModel,
InboundPageQueryModel,
InboundScanQueryModel,
InboundUpdateModel,
PalletScanResponseModel,
)
from module_business.service.inbound_service import InboundService
from utils.response_util import ResponseUtil
inbound_controller = APIRouterPro(
prefix='/business/inbound',
tags=['业务管理-仓库入库'],
dependencies=[PreAuthDependency()],
)
@inbound_controller.get(
'/list',
summary='获取首次入库单分页列表接口',
description='按当前用户仓库数据范围获取首次入库单分页列表',
response_model=PageResponseModel[InboundModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
)
async def get_business_inbound_list(
request: Request,
inbound_page_query: Annotated[InboundPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await InboundService.get_inbound_list_services(
query_db,
inbound_page_query,
current_user,
test_scope,
is_page=True,
)
return ResponseUtil.success(model_content=result)
@inbound_controller.get(
'/warehouse-options',
summary='获取首次入库仓库选项接口',
description='仅返回当前用户有权访问的启用仓库',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
)
async def get_business_inbound_warehouse_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await InboundService.get_warehouse_options_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@inbound_controller.get(
'/scan/{qr_token:path}',
summary='扫描首次入库托盘接口',
description='查询托盘是否可加入所选仓库的首次入库单',
response_model=DataResponseModel[PalletScanResponseModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
)
@Log(
title='扫码操作',
business_type=BusinessType.OTHER,
request_log_mode='none',
response_log_mode='summary',
oper_url_mode='route',
)
async def scan_business_inbound_pallet(
request: Request,
qr_token: Annotated[
str,
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
],
inbound_scan_query: Annotated[InboundScanQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await InboundService.scan_inbound_pallet_services(
query_db,
qr_token,
inbound_scan_query,
current_user,
test_scope,
)
return ResponseUtil.success(data=result)
@inbound_controller.get(
'/{inbound_id}',
summary='获取首次入库单详情接口',
description='按仓库数据范围获取指定首次入库单详情',
response_model=DataResponseModel[InboundModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
)
async def query_detail_business_inbound(
request: Request,
inbound_id: Annotated[int, Path(gt=0, description='入库单ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await InboundService.inbound_detail_services(query_db, inbound_id, current_user, test_scope)
return ResponseUtil.success(data=result)
@inbound_controller.post(
'',
summary='新增首次入库草稿接口',
description='新增空托盘首次入库草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:add')],
)
@Log(title='仓库入库', business_type=BusinessType.INSERT)
async def add_business_inbound(
request: Request,
add_inbound: InboundCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await InboundService.add_inbound_services(query_db, add_inbound, current_user, test_scope)
return ResponseUtil.success(msg=result.message)
@inbound_controller.put(
'',
summary='编辑首次入库草稿接口',
description='编辑尚未确认的首次入库草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:edit')],
)
@Log(title='仓库入库', business_type=BusinessType.UPDATE)
async def edit_business_inbound(
request: Request,
edit_inbound: InboundUpdateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await InboundService.edit_inbound_services(query_db, edit_inbound, current_user, test_scope)
return ResponseUtil.success(msg=result.message)
@inbound_controller.delete(
'/{inbound_ids}',
summary='不支持单条物理删除,请通过测试数据批次清理',
description='不支持单条物理删除,请通过测试数据批次清理',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
)
@Log(title='仓库入库', business_type=BusinessType.DELETE)
async def delete_business_inbound(
request: Request,
inbound_ids: Annotated[
str,
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的入库单ID'),
],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await InboundService.delete_inbound_services(
query_db,
[int(inbound_id) for inbound_id in inbound_ids.split(',')],
current_user,
test_scope,
)
return ResponseUtil.success(msg=result.message)
@inbound_controller.put(
'/{inbound_id}/confirm',
summary='确认首次入库单接口',
description='原子确认整张首次入库单并追加托盘流转事件',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:confirm')],
)
@Log(title='仓库入库', business_type=BusinessType.UPDATE)
async def confirm_business_inbound(
request: Request,
inbound_id: Annotated[int, Path(gt=0, description='入库单ID')],
confirm_command: ConfirmCommandModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await InboundService.confirm_inbound_services(
query_db,
inbound_id,
confirm_command,
current_user,
test_scope,
)
return ResponseUtil.success(msg=result.message)

Some files were not shown because too many files have changed in this diff Show More