Compare commits

...
Author SHA1 Message Date
yujiangandClaude Opus 5.5 2a4119c78c Docs: 新增 Web 端改版修改记录 readmeupdate.md
分前端、后端说明本次改版的全部改动与目的,并列出与设计稿的差异和待确认事项。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00
yujiangandClaude Opus 5.5 3050ccdc4e Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:10 -07:00
焦龙言 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
531 changed files with 59518 additions and 133399 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>
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
-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>
+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)。
+37 -20
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版本:
@@ -43,14 +43,24 @@
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
* Android PDA 可直接通过浏览器使用响应式 Web 页面,并支持 HID 扫码枪扫码后回车提交。
* Android PDA 可通过基于 uni-app 的 H5 页面使用,并支持 HID 扫码枪扫码后回车提交。
本轮交付不是 APP/uni-app 原生端,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./docs/pallet-workflow-deployment.md)。
本轮交付包含 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>
## 项目开发及发布相关
@@ -110,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
@@ -131,12 +139,12 @@ shierfeng app run --env=dev
#### 访问
```bash
# 默认账号密码
# 本地账号
账号:admin
密码:admin123
密码:请在目标环境安全配置中设置后手工输入,不在仓库中记录
# 浏览器访问
地址:http://localhost:80
地址:http://localhost:8080
```
### 发布
@@ -163,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
-86
View File
@@ -1,86 +0,0 @@
# 托盘流转业务部署说明
## 适用范围与当前状态
本文适用于 MySQL 增量补丁:
`shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql`
2026-07-27 已在本地主工作区 `.env.dev` 指向的 MySQL 8.0.36 执行该补丁,且完成下述本地核对。其他环境尚未执行,Redis 缓存也未清理;本记录不能替代其他环境的上线审批、备份及恢复演练。
## 上线前门禁
执行补丁前必须完成以下准备:
1. 对目标 MySQL 实例进行完整备份,至少覆盖库结构、业务数据、菜单和角色关联数据,并验证备份可读。
2. 在隔离环境完成恢复演练,确认恢复步骤、耗时和责任人,不能只以“备份命令成功”代替可恢复性验证。
3. 由 DBA 人工审查补丁、目标库现状、已有同名表结构、固定菜单 ID 和回退方案。
4. 申请维护窗口,在补丁执行和缓存清理完成前保持后端停止,不接入业务流量。
任一门禁未满足时,不得执行补丁。
## MySQL 补丁执行与核对
MySQL DDL 会隐式提交,因此必须将补丁的 DDL、DML 分节执行,并在每一节后逐项核对结果。执行失败时不能假设整个补丁已自动回滚,应立即停止后续步骤,由 DBA 根据实际已提交状态决定人工修复或从已验证备份恢复。
### 1. DDL 节
1. 先执行补丁中的 DDL 节,逐表核对表结构、索引、外键和约束。
2. `CREATE TABLE IF NOT EXISTS` 只会跳过已经存在的同名表,不会修复已存在但结构发生漂移的表。发现同名表时,必须先人工比对实际结构;存在差异时停止执行并制定独立修复方案。
3. DDL 节核对通过前,不得继续执行 DML 节。
### 2. DML 节
1. DML 节包含固定 64 行菜单 seed。执行后必须核对结果恰为 64 行,并确认稳定字段与补丁定义一致。
2. 任一固定 ID 与未知既有数据冲突时,必须停止并由 DBA、业务负责人共同人工处理;不得覆盖、改写或复用未知 ID,也不得绕过冲突继续执行。
3. DML 节核对失败时,不得清理缓存或启动后端。
### 3. 2026-07-27 本地执行记录
1. 执行前备份:`/private/tmp/shierfeng-fastapi-before-pallet-workflow-20260727-1925.sql`,文件权限 `600`,大小 69,180 字节,SHA-256 为 `a217a7b071c7e3ecda0d4c94dd1010711b6061025148689156405b70a02f6c7e`。已校验文件非空、可读且包含核心系统表;未执行恢复演练。
2. DDL 分节执行成功:新增 18 张 `biz_*` InnoDB 表,业务域幂等键的复合唯一索引列顺序已核对。
3. DML 分节使用标准 `mysql` 批处理执行,未使用 `--force` 或 continue-on-error 模式。64 条新业务菜单已写入,旧 AI/外链菜单、AI 字典及旧角色映射已核对为 0。
4. 新菜单没有自动授予普通角色;后续应通过现有角色菜单机制按实际职责分配。
## 历史 AI/Agno 遗留表
曾运行过历史 AI/Agno 版本的数据库可能残留以下 11 张表:
* `ai_models`
* `ai_chat_config`
* `ai_sessions`
* `ai_memories`
* `ai_metrics`
* `ai_eval_runs`
* `ai_knowledge`
* `ai_culture`
* `ai_traces`
* `ai_spans`
* `ai_schema_versions`
本补丁不会删除或修改这些表。未经另行确认,不得处理这些历史表。后续如需清理,应单独盘点表结构、数据量和外部依赖,完成备份与恢复验证,确认无消费者后,再通过独立变更审批和维护窗口人工执行。
## Redis 缓存清理
只有 MySQL 补丁全部执行成功且核对通过后,才能在目标 Redis 实例定向清理以下缓存:
* `api_cache:login:user:routers:*`
* `api_cache:login:user:info:*`
* `sys_dict:ai_provider_type`
对前两类模式应使用 `SCAN` 定位后逐项删除,只处理明确匹配的键;禁止使用 `FLUSHALL`。本轮未执行任何 Redis 缓存清理,不能把上述清单视为已清理证明。
## 启动与运行态验收
正式环境只有在 MySQL 补丁成功、逐节核对通过并完成上述定向缓存清理后,才可启动后端并开展运行态验收。至少应核对 `/getRouters` 返回的托盘业务菜单、真实角色权限,以及真实用户与仓库/客户现场映射下的业务访问范围,再验证响应式 Web 页面和 Android PDA HID 扫码回车流程。
本地环境已使用独立查询参数验证管理员 `/getRouters`:包含 3 个新业务目录和 17 个业务页面,不包含 AI 管理或“施尔丰仓库”。该结果不等于已完成 Redis 清理,也不代表普通角色已完成授权验收。
## 本轮未验证项
以下事项需要在后续真实环境中验证,本轮没有完成:
* 备份恢复演练。
* 真实角色与仓库/客户现场映射的端到端(E2E)流程。
* MySQL 行锁、并发和事务回滚行为。
* Redis 缓存清理。
File diff suppressed because it is too large Load Diff
@@ -1,774 +0,0 @@
# 业务菜单一级展示 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
**Goal:** 将“基础资料维护”“业务管理”“报表查询”提升为独立一级菜单,并让六个目标一级目录按 121、122、123、1、2、3 的顺序展示。
**Architecture:** Web 菜单继续由后端 /getRouters 根据 sys_menu 动态生成,不增加前端拍平或重排代码。新建库通过 MySQL/PostgreSQL 初始化 SQL 得到目标层级,已有 MySQL 库通过独立、幂等、fail-closed 的 20260727 增量补丁迁移;已执行的 20260724 历史补丁保持不变。
**Tech Stack:** MySQL 8、PostgreSQL、Python 3.10 unittest、Ruff、Vue 3、Vite 6、Node 20。
---
## 文件结构与保护边界
- 修改 shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py:区分初始化器目标状态与 20260724 补丁历史状态,并新增 20260727 补丁契约。
- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:新建 MySQL 数据库的六个一级菜单层级与排序。
- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql:新建 PostgreSQL 数据库的六个一级菜单层级与排序。
- 创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql:迁移现有 MySQL 数据库。
- 不修改 shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql。
- 不修改 Web 前端源码、APP、API、权限标识、角色菜单关联、业务子菜单或数据库表结构。
- 当前工作区已有 APP、后端/前端环境文件和已暂存 .idea/git_toolbox_prj.xml;每次暂存和提交都必须按路径执行,禁止 git add -A。
## Task 1:用契约固定初始化器的目标层级,并保留历史补丁语义
**Files:**
- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql
- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
- Preserve: shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql
- [ ] **Step 1:先修改契约测试,表达目标状态和历史状态**
在测试文件顶部保留旧补丁路径,并新增新补丁路径:
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
把 BUSINESS_MENU_SPECS 的前三行改为初始化器目标值,其余 61 行原样保留:
BUSINESS_MENU_SPECS = """
121|0|基础资料维护|businessMaster||1||<NULL>
122|0|业务管理|businessOperation||2||<NULL>
123|0|报表查询|businessReport||3||<NULL>
502|121|菌种资料|strain||1|master:strain:list|master/strain/index
在 EXPECTED_BUSINESS_MENU_FIELDS 和 BUSINESS_MENU_IDS 定义之后加入:
LEGACY_PATCH_BUSINESS_MENU_FIELDS = {
**EXPECTED_BUSINESS_MENU_FIELDS,
121: (1, '基础资料维护', 'businessMaster', '', 10, '', None),
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
123: (1, '报表查询', 'businessReport', '', 12, '', None),
}
EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
121: ('基础资料维护', 0, 1),
122: ('业务管理', 0, 2),
123: ('报表查询', 0, 3),
1: ('系统管理', 0, 4),
2: ('系统监控', 0, 5),
3: ('系统工具', 0, 6),
}
MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
不要把 1、2、3 加入 BUSINESS_MENU_IDS;该集合还被“新业务菜单不默认授权角色”的现有测试使用。
把菜单提取和字段断言改为以下实现:
def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]:
rows = _insert_rows(sql, 'sys_menu')
selected = [
row
for row in rows
if row and str(row[0]).isdigit() and int(row[0]) in menu_ids
]
counts = {
menu_id: sum(int(row[0]) == menu_id for row in selected)
for menu_id in menu_ids
}
if any(count != 1 for count in counts.values()):
raise AssertionError(f'menu row counts differ: {counts}')
return {int(row[0]): row for row in selected}
def _business_menu_rows(sql: str) -> dict[int, list]:
return _menu_rows(sql, BUSINESS_MENU_IDS)
def _assert_business_menu_fields(
test_case: unittest.TestCase,
sql: str,
expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS,
) -> None:
rows = _business_menu_rows(sql)
invalid_widths = {
menu_id: len(row)
for menu_id, row in rows.items()
if len(row) != SYS_MENU_VALUE_COUNT
}
test_case.assertEqual({}, invalid_widths)
actual = {
menu_id: (
int(row[2]),
row[1],
row[4],
row[7],
int(row[3]),
row[13],
row[5],
)
for menu_id, row in rows.items()
}
test_case.assertEqual(expected_fields, actual)
在 test_initializers_define_fixed_business_menus_and_permissions 中把三个根目录断言改成:
self.assertEqual(
('基础资料维护', '0', '1', 'businessMaster', 'M', ''),
tuple(rows[121][index] for index in (1, 2, 3, 4, 10, 13)),
)
self.assertEqual(
('业务管理', '0', '2', 'businessOperation', 'M', ''),
tuple(rows[122][index] for index in (1, 2, 3, 4, 10, 13)),
)
self.assertEqual(
('报表查询', '0', '3', 'businessReport', 'M', ''),
tuple(rows[123][index] for index in (1, 2, 3, 4, 10, 13)),
)
紧接该测试新增六个一级目录的顺序契约:
def test_initializers_define_expected_top_level_menu_order(self) -> None:
for filename in INITIALIZERS:
with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
rows = _menu_rows(sql, MENU_PROMOTION_IDS)
actual = {
menu_id: (row[1], int(row[2]), int(row[3]))
for menu_id, row in rows.items()
}
self.assertEqual(EXPECTED_TOP_LEVEL_MENU_POSITIONS, actual)
在 test_patch_is_documented_idempotent_and_contains_full_contract 中,将旧补丁字段断言改成:
_assert_business_menu_fields(
self,
raw,
LEGACY_PATCH_BUSINESS_MENU_FIELDS,
)
同一测试中,旧补丁与当前 MySQL 初始化器的比较只比较不含 parent_id、order_num 的稳定字段:
compared_indexes = (0, 1, *range(4, 15))
self.assertEqual(
{
menu_id: tuple(row[index] for index in compared_indexes)
for menu_id, row in mysql_rows.items()
},
{
menu_id: tuple(row[index] for index in compared_indexes)
for menu_id, row in patch_rows.items()
},
)
- [ ] **Step 2:运行初始化器契约,确认 RED**
工作目录:shierfeng-fastapi-backend
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \
-v
预期:FAIL。MySQL/PostgreSQL 初始化器仍将 121/122/123 放在 parent_id=1、order_num=10/11/12,1/2/3 仍使用 order_num=1/2/3。
- [ ] **Step 3:对两份初始化 SQL 做六行最小修改**
MySQL 的六行目标内容:
insert into sys_menu values('1', '系统管理', '0', '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', sysdate(), '', null, '系统管理目录');
insert into sys_menu values('2', '系统监控', '0', '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', null, '系统监控目录');
insert into sys_menu values('3', '系统工具', '0', '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', null, '系统工具目录');
insert into sys_menu values('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '基础资料维护目录');
insert into sys_menu values('122', '业务管理', '0', '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '业务管理目录');
insert into sys_menu values('123', '报表查询', '0', '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '报表查询目录');
把 MySQL 注释“托盘业务目录”同步改为“托盘业务一级目录”。
PostgreSQL 的六行目标内容:
insert into sys_menu values(1, '系统管理', 0, '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', current_timestamp, '', null, '系统管理目录');
insert into sys_menu values(2, '系统监控', 0, '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', current_timestamp, '', null, '系统监控目录');
insert into sys_menu values(3, '系统工具', 0, '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', current_timestamp, '', null, '系统工具目录');
(121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
(122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
(123, '报表查询', 0, '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
把 PostgreSQL 注释“初始化-托盘业务菜单和权限”同步改为“初始化-托盘业务一级菜单和权限”。
- [ ] **Step 4:运行 GREEN 和历史补丁兼容回归**
工作目录:shierfeng-fastapi-backend
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \
tests.module_business.test_sql_contract.SqlContractTest.test_mysql_and_postgresql_business_menu_fields_are_identical \
tests.module_business.test_sql_contract.SqlContractTest.test_patch_is_documented_idempotent_and_contains_full_contract \
-v
预期:4 项均通过;旧 20260724 补丁仍按历史层级通过契约,不发生文件改动。
- [ ] **Step 5:提交初始化器和对应契约**
从仓库根目录执行:
git add -- \
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \
shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
git commit --only -m '调整:提升业务菜单为一级目录' -- \
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \
shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
提交后确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区。
## Task 2:新增幂等、fail-closed 的 MySQL 前向补丁
**Files:**
- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
- Create: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
- [ ] **Step 1:先写新补丁的失败契约**
在 SqlContractTest 中新增:
def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None:
self.assertTrue(
MENU_PROMOTION_PATCH_PATH.is_file(),
f'missing patch: {MENU_PROMOTION_PATCH_PATH}',
)
raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8')
self.assertIn('备份', raw)
self.assertIn('未自动执行', raw)
self.assertIn('可幂等重跑', raw)
self.assertIn('禁止使用 mysql --force', raw)
statements = _sql_statements(raw)
normalized = [_normalized_sql_statement(statement) for statement in statements]
expected_table = 'promote_business_menus_expected'
guard_table = 'promote_business_menus_guard'
expected_insert_indexes = [
index
for index, statement in enumerate(normalized)
if statement.startswith(f'insert into {expected_table} ')
]
self.assertEqual(1, len(expected_insert_indexes))
expected_rows = _insert_rows(
statements[expected_insert_indexes[0]],
expected_table,
)
self.assertEqual(6, len(expected_rows))
actual_expected_rows = {
int(row[0]): tuple(row[1:])
for row in expected_rows
}
self.assertEqual(
{
1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system', '0', '1', '0', '4'),
2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor', '0', '2', '0', '5'),
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool', '0', '3', '0', '6'),
121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '10', '0', '1'),
122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '11', '0', '2'),
123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '12', '0', '3'),
},
actual_expected_rows,
)
starts = [
index
for index, statement in enumerate(normalized)
if statement == 'start transaction'
]
commits = [
index
for index, statement in enumerate(normalized)
if statement == 'commit'
]
self.assertEqual(1, len(starts))
self.assertEqual(1, len(commits))
start_transaction = starts[0]
commit = commits[0]
row_locks = [
index
for index, statement in enumerate(normalized)
if re.fullmatch(
r'select menu_id from sys_menu '
r'where menu_id in \(1, 2, 3, 121, 122, 123\) '
r'order by menu_id for update',
statement,
)
]
self.assertEqual(1, len(row_locks))
guard_indexes = [
index
for index, statement in enumerate(normalized)
if statement.startswith(f'insert into {guard_table} ')
]
self.assertEqual(2, len(guard_indexes))
precondition_guard = normalized[guard_indexes[0]]
postcondition_guard = normalized[guard_indexes[1]]
self.assertIn('as precondition_guard_rows', precondition_guard)
self.assertIn('as postcondition_guard_rows', postcondition_guard)
self.assertEqual(
2,
precondition_guard.count(
"'promote-business-menus-precondition-conflict'"
),
)
self.assertEqual(
2,
postcondition_guard.count(
"'promote-business-menus-postcondition-conflict'"
),
)
identity_columns = (
('menu_name', 'menu_name'),
('path', 'path'),
('component', 'component'),
('query', 'query_value'),
('route_name', 'route_name'),
('is_frame', 'is_frame'),
('is_cache', 'is_cache'),
('menu_type', 'menu_type'),
('visible', 'visible'),
('status', 'status'),
('perms', 'perms'),
('icon', 'icon'),
)
for actual_column, expected_column in identity_columns:
comparison = (
f'actual.{actual_column} <=> expected.{expected_column}'
)
self.assertIn(comparison, precondition_guard)
self.assertIn(comparison, postcondition_guard)
compact_precondition = re.sub(r'\s+', '', precondition_guard)
self.assertIn('count(actual.menu_id)=6', compact_precondition)
self.assertIn(
'sum((actual.parent_id<=>expected.old_parent_id)'
'and(actual.order_num<=>expected.old_order_num))=6',
compact_precondition,
)
self.assertIn(
'sum((actual.parent_id<=>expected.new_parent_id)'
'and(actual.order_num<=>expected.new_order_num))=6',
compact_precondition,
)
compact_postcondition = re.sub(r'\s+', '', postcondition_guard)
self.assertIn('count(actual.menu_id)=6', compact_postcondition)
self.assertIn(
'sum((actual.parent_id<=>expected.new_parent_id)'
'and(actual.order_num<=>expected.new_order_num))=6',
compact_postcondition,
)
expected_update = (
'update sys_menu set parent_id = case '
'when menu_id in (121, 122, 123) then 0 '
'else parent_id end, order_num = case menu_id '
'when 121 then 1 when 122 then 2 when 123 then 3 '
'when 1 then 4 when 2 then 5 when 3 then 6 '
'else order_num end '
'where menu_id in (1, 2, 3, 121, 122, 123)'
)
update_indexes = [
index
for index, statement in enumerate(normalized)
if statement == expected_update
]
self.assertEqual(1, len(update_indexes))
update_index = update_indexes[0]
permanent_mutations = [
statement
for statement in normalized
if re.match(
r'(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_',
statement,
)
]
self.assertEqual([expected_update], permanent_mutations)
self.assertEqual(
(
start_transaction,
row_locks[0],
guard_indexes[0],
update_index,
guard_indexes[1],
commit,
),
tuple(
sorted(
(
start_transaction,
row_locks[0],
guard_indexes[0],
update_index,
guard_indexes[1],
commit,
)
)
),
)
expected_verification = (
'select menu_id, menu_name, parent_id, order_num, path, component, '
'menu_type, visible, status, perms from sys_menu '
'where menu_id in (1, 2, 3, 121, 122, 123) '
'order by order_num, menu_id'
)
self.assertEqual(expected_verification, normalized[-1])
- [ ] **Step 2:运行新补丁契约,确认 RED**
工作目录:shierfeng-fastapi-backend
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \
-v
预期:FAIL,错误指出缺少 20260727_promote_business_menus_mysql.sql。
- [ ] **Step 3:创建最小 MySQL 前向补丁**
创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql,内容如下:
-- 将托盘业务目录 121/122/123 提升为一级菜单,并将系统菜单 1/2/3 后移。
-- 本补丁只更新 sys_menu.parent_id 和 sys_menu.order_num。
-- 本文件未自动执行;执行前必须备份目标数据库。
--
-- 必须在独立连接中使用标准 mysql batch 执行,并确保客户端遇错立即退出。
-- 禁止使用 mysql --force;guard 的 Duplicate entry 是故意设计的 fail-closed 错误,
-- 不能忽略错误继续执行到 commit。
--
-- 允许的写前状态只有两种:
-- 1. 20260724 旧状态;
-- 2. 本补丁目标状态,因此可幂等重跑。
-- 缺行、固定 ID 身份冲突或新旧状态混杂都会在永久更新前中止。
drop temporary table if exists promote_business_menus_expected;
create temporary table promote_business_menus_expected (
menu_id bigint not null,
menu_name varchar(50) not null,
path varchar(200),
component varchar(255),
query_value varchar(255),
route_name varchar(50),
is_frame int,
is_cache int,
menu_type char(1),
visible char(1),
status char(1),
perms varchar(100),
icon varchar(100),
old_parent_id bigint not null,
old_order_num int not null,
new_parent_id bigint not null,
new_order_num int not null,
primary key (menu_id)
) engine=innodb;
insert into promote_business_menus_expected (
menu_id, menu_name, path, component, query_value, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon,
old_parent_id, old_order_num, new_parent_id, new_order_num
) values
(1, '系统管理', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 0, 1, 0, 4),
(2, '系统监控', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 0, 2, 0, 5),
(3, '系统工具', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 0, 3, 0, 6),
(121, '基础资料维护', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 10, 0, 1),
(122, '业务管理', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 11, 0, 2),
(123, '报表查询', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 12, 0, 3);
drop temporary table if exists promote_business_menus_guard;
create temporary table promote_business_menus_guard (
guard_token varchar(64) not null,
primary key (guard_token)
) engine=innodb;
start transaction;
-- 锁定目标行,防止身份校验与更新之间被并发修改。
select menu_id
from sys_menu
where menu_id in (1, 2, 3, 121, 122, 123)
order by menu_id
for update;
-- 写前身份和整体状态校验。失败时重复 token 触发主键冲突并终止 batch。
insert into promote_business_menus_guard (guard_token)
select guard_token
from (
select 'promote-business-menus-precondition-conflict' as guard_token
union all
select if(
count(actual.menu_id) = 6
and sum(
(actual.menu_name <=> expected.menu_name)
and (actual.path <=> expected.path)
and (actual.component <=> expected.component)
and (actual.query <=> expected.query_value)
and (actual.route_name <=> expected.route_name)
and (actual.is_frame <=> expected.is_frame)
and (actual.is_cache <=> expected.is_cache)
and (actual.menu_type <=> expected.menu_type)
and (actual.visible <=> expected.visible)
and (actual.status <=> expected.status)
and (actual.perms <=> expected.perms)
and (actual.icon <=> expected.icon)
) = 6
and (
sum(
(actual.parent_id <=> expected.old_parent_id)
and (actual.order_num <=> expected.old_order_num)
) = 6
or sum(
(actual.parent_id <=> expected.new_parent_id)
and (actual.order_num <=> expected.new_order_num)
) = 6
),
'promote-business-menus-precondition-verified',
'promote-business-menus-precondition-conflict'
) as guard_token
from promote_business_menus_expected as expected
left join sys_menu as actual
on actual.menu_id = expected.menu_id
) as precondition_guard_rows;
-- 永久变更只涉及 parent_id 和 order_num。
update sys_menu
set parent_id = case
when menu_id in (121, 122, 123) then 0
else parent_id
end,
order_num = case menu_id
when 121 then 1
when 122 then 2
when 123 then 3
when 1 then 4
when 2 then 5
when 3 then 6
else order_num
end
where menu_id in (1, 2, 3, 121, 122, 123);
-- 提交前再次校验目标状态,捕获未覆盖行或触发器造成的意外变化。
insert into promote_business_menus_guard (guard_token)
select guard_token
from (
select 'promote-business-menus-postcondition-conflict' as guard_token
union all
select if(
count(actual.menu_id) = 6
and sum(
(actual.menu_name <=> expected.menu_name)
and (actual.path <=> expected.path)
and (actual.component <=> expected.component)
and (actual.query <=> expected.query_value)
and (actual.route_name <=> expected.route_name)
and (actual.is_frame <=> expected.is_frame)
and (actual.is_cache <=> expected.is_cache)
and (actual.menu_type <=> expected.menu_type)
and (actual.visible <=> expected.visible)
and (actual.status <=> expected.status)
and (actual.perms <=> expected.perms)
and (actual.icon <=> expected.icon)
) = 6
and sum(
(actual.parent_id <=> expected.new_parent_id)
and (actual.order_num <=> expected.new_order_num)
) = 6,
'promote-business-menus-postcondition-verified',
'promote-business-menus-postcondition-conflict'
) as guard_token
from promote_business_menus_expected as expected
left join sys_menu as actual
on actual.menu_id = expected.menu_id
) as postcondition_guard_rows;
commit;
drop temporary table promote_business_menus_guard;
drop temporary table promote_business_menus_expected;
-- 固定 ID 事后核对:顺序应为 121、122、123、1、2、3。
select menu_id, menu_name, parent_id, order_num, path, component,
menu_type, visible, status, perms
from sys_menu
where menu_id in (1, 2, 3, 121, 122, 123)
order by order_num, menu_id;
- [ ] **Step 4:运行新补丁契约与完整 SQL 契约**
工作目录:shierfeng-fastapi-backend
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \
-v
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
tests.module_business.test_sql_contract \
-v
conda run -n ShiErFeng python -m ruff format --check \
tests/module_business/test_sql_contract.py
conda run -n ShiErFeng python -m ruff check \
tests/module_business/test_sql_contract.py
预期:新补丁定向契约通过,完整 SQL 契约全部通过,Ruff format/check 均为成功退出。
- [ ] **Step 5:提交新补丁和对应契约**
从仓库根目录执行:
git add -- \
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
git commit --only -m '迁移:新增业务菜单层级补丁' -- \
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
提交后再次确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区,APP 和环境文件指纹未变化。
## Task 3:完成代码级回归验证
**Files:**
- Verify only: shierfeng-fastapi-backend
- Verify only: shierfeng-fastapi-frontend
- [ ] **Step 1:运行后端业务测试和 SQL 契约**
工作目录:shierfeng-fastapi-backend
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest discover \
-s tests/module_business -p 'test_*.py' -v
预期:全部业务测试通过,不生成新的 Python 字节码。
- [ ] **Step 2:运行后端格式和静态检查**
工作目录:shierfeng-fastapi-backend
conda run -n ShiErFeng python -m ruff format --check \
module_business tests/module_business
conda run -n ShiErFeng python -m ruff check \
module_business tests/module_business
预期:两条命令均成功退出,无格式或 lint 错误。
- [ ] **Step 3:运行 Web 契约与生产构建**
工作目录:shierfeng-fastapi-frontend
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \
--test scripts/verify-business-contract.mjs
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/lib/node_modules/npm/bin/npm-cli.js \
run build:prod
预期:Node 业务契约全部通过,Vite 生产构建成功。构建会读取用户已有的前端环境文件,但本任务不得修改或提交这些文件。
- [ ] **Step 4:检查 diff、提交边界和受保护改动**
从仓库根目录执行:
git diff --check
git status --short --branch
git diff --cached --name-status
预期:git diff --check 无输出;本任务代码已按路径提交;.idea/git_toolbox_prj.xml 仍保持用户原有暂存状态;APP、环境文件、缓存、日志和构建产物未被纳入提交。
## Task 4:经再次确认后迁移本地 MySQL 并验收菜单
**Files:**
- Read configuration: shierfeng-fastapi-backend/.env.dev
- Execute: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
- Backup output pattern: /private/tmp/shierfeng-fastapi-before-promote-business-menus-YYYYMMDD-HHMMSS.sql
- [ ] **Step 1:停止并取得数据库写入确认**
向用户列出:目标数据库来自 .env.dev、将创建新的全库备份、将执行的补丁文件、只更新 sys_menu 六行的 parent_id/order_num、禁止 mysql --force。没有本次明确确认时不得执行后续步骤。
- [ ] **Step 2:确认后创建新的数据库备份并执行补丁**
工作目录:shierfeng-fastapi-backend。以下命令不打印密码,且 set -e 保证备份或补丁遇错立即退出:
set -euo pipefail
task_db_type=$(sed -nE "s/^[[:space:]]*DB_TYPE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
task_db_host=$(sed -nE "s/^[[:space:]]*DB_HOST[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
task_db_port=$(sed -nE "s/^[[:space:]]*DB_PORT[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p" .env.dev)
task_db_user=$(sed -nE "s/^[[:space:]]*DB_USERNAME[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
task_db_password=$(sed -nE "s/^[[:space:]]*DB_PASSWORD[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
task_db_name=$(sed -nE "s/^[[:space:]]*DB_DATABASE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
test "$task_db_type" = 'mysql'
test -n "$task_db_host"
test -n "$task_db_port"
test -n "$task_db_user"
test -n "$task_db_password"
test -n "$task_db_name"
task_backup_path="/private/tmp/shierfeng-fastapi-before-promote-business-menus-$(date +%Y%m%d-%H%M%S).sql"
MYSQL_PWD="$task_db_password" mysqldump \
--host="$task_db_host" \
--port="$task_db_port" \
--user="$task_db_user" \
--single-transaction \
--routines \
--triggers \
--events \
--no-tablespaces \
"$task_db_name" > "$task_backup_path"
test -s "$task_backup_path"
shasum -a 256 "$task_backup_path"
MYSQL_PWD="$task_db_password" mysql \
--host="$task_db_host" \
--port="$task_db_port" \
--user="$task_db_user" \
--database="$task_db_name" \
< sql/patches/20260727_promote_business_menus_mysql.sql
unset task_db_password
预期:生成新的非空备份并输出其 SHA-256;mysql 使用独立连接、未带 --force;补丁最后输出六个目标菜单,parent_id 均为 0,order_num 依次为 1、2、3、4、5、6。
- [ ] **Step 3:只读核对数据库菜单和不变量**
继续使用 .env.dev 解析出的连接参数,在新的 mysql 连接执行:
select menu_id, menu_name, parent_id, order_num, path, component,
menu_type, visible, status, perms
from sys_menu
where menu_id in (1, 2, 3, 121, 122, 123)
order by order_num, menu_id;
select parent_id, count(*) as child_count
from sys_menu
where parent_id in (121, 122, 123)
group by parent_id
order by parent_id;
select count(*) as business_permission_count
from sys_menu
where perms regexp '^(master|business|report):';
预期:六个根目录顺序为 121、122、123、1、2、3;121/122/123 的直属子菜单数为 6、5、6;业务权限菜单总数为 61。
- [ ] **Step 4:重新登录并核对 /getRouters 与 Web 侧边栏**
保持现有前后端启动方式不变,退出 Web 登录后重新登录,使现有登录缓存刷新。确认 /getRouters 返回的根节点依次包含“基础资料维护”“业务管理”“报表查询”“系统管理”“系统监控”“系统工具”,三个业务目录不再出现在“系统管理”的 children 中;侧边栏顺序与返回结果一致。不得执行 Redis FLUSHALL,也不修改 APP。
## 最终验收
- 新建 MySQL/PostgreSQL 数据库直接得到目标一级菜单层级。
- 已有 MySQL 数据库只能从完整旧状态或完整目标状态执行 20260727 补丁;缺行、ID 复用、身份漂移和新旧状态混杂均在永久更新前失败。
- 永久 DML 只更新六个 sys_menu 行的 parent_id/order_num,并在提交前复核目标状态。
- 20260724 历史补丁、子菜单、API、权限标识、角色关联、Web 动态路由机制与 APP 均保持不变。
- 后端业务测试、SQL 契约、Ruff、Web 业务契约和生产构建均通过。
@@ -1,440 +0,0 @@
# 智链云枢托盘业务系统设计
## 1. 背景与目标
当前项目是基于 Vue 3、Element Plus、Pinia、Vite、FastAPI 和异步 SQLAlchemy 的通用管理后台。Web 菜单由后端 `/getRouters` 根据 `sys_menu`、`sys_role_menu` 动态生成,按钮权限由前端 `v-hasPermi` 控制,真实接口权限由后端 `UserInterfaceAuthDependency` 校验。
本次以用户提供的流程图为最终业务依据,完成以下目标:
- 完整移除“AI管理”专属功能。
- 删除“施尔丰仓库”外链菜单。
- 在“系统管理”下新增托盘基础资料、五类业务操作和六类报表。
- 保证菜单、页面、API、按钮权限、后端权限与数据范围一致。
- 建立可审计、可并发控制的托盘库存和追溯模型。
## 2. 已确认的范围
### 2.1 本次包含
- Web 前端页面、动态菜单、按钮权限、API 请求和类型约定。
- FastAPI 后端 Controller、Service、DAO、DO、VO、状态机和权限校验。
- MySQL、PostgreSQL 初始化脚本同步。
- 一份仅供审查、不自动执行的 MySQL 增量补丁。
- 直接相关的测试、README、CHANGELOG 和依赖声明。
- 适配 Android PDA 浏览器的 Web 操作页面。
### 2.2 本次明确排除
- 不修改 `shierfeng-fastapi-app` 下任何文件,也不兼容其现有接口逻辑。
- 不连接、不写入、不迁移本地 MySQL。
- 不生成或执行 PostgreSQL 增量补丁。
- 不删除现有数据库中的 AI 业务表、模型配置、聊天配置或 Agno 运行数据。
- 不新增在途、签收、拒收、冲销、库存调整、导入、导出或摄像头扫码功能。
- 不预置新的业务角色,不给现有普通角色自动分配新增权限。
- 不删除用户、角色、菜单、部门、岗位、字典、参数、通知、日志、监控或系统工具等通用功能。
## 3. 总体架构
### 3.1 后端
新增独立的 `module_business`,继续采用现有分层:
```text
module_business/
├── controller/
├── service/
├── dao/
├── entity/
│ ├── do/
│ └── vo/
└── domain/
```
模块内部职责划分:
- `master`:菌种、客户、客户现场、产线、仓库、托盘及用户授权。
- `operation`:首次入库、工厂装填、发货、现场使用、退回。
- `report`:六类只读报表和托盘追溯。
- `domain`:托盘状态枚举、状态迁移规则和跨业务公共校验。
Controller 只负责协议、参数、认证和权限依赖;Service 负责事务、状态机、数据范围和幂等;DAO 负责持久化查询;前端不得直接指定最终库存或最终状态。
### 3.2 Web
新增以下目录,沿用现有页面与请求风格:
```text
src/api/master/
src/api/business/
src/api/report/
src/views/master/
src/views/business/
src/views/report/
src/components/PalletScanInput/
```
PDA 页面属于 Web 页面,使用响应式布局。扫码设备按 HID 键盘输入处理:输入框自动聚焦,扫码头输入二维码后回车提交;不调用摄像头,不引入前端扫码依赖。
### 3.3 库存一致性
托盘是业务聚合根。每次状态变更必须在一个数据库事务内同时完成:
1. 校验接口权限和仓库/客户现场数据范围。
2. 锁定托盘当前库存行。
3. 校验当前状态、位置、数量和幂等键。
4. 写入或确认业务单据。
5. 更新托盘当前库存快照。
6. 追加不可变托盘事件。
任何一步失败都整体回滚,多托盘单据不允许部分成功。
## 4. 主数据模型
### 4.1 `biz_strain`
- 菌种是基础品种/SKU,不代表生产批次。
- `strain_code` 非空且唯一。
- 生产批次、包装规格和本次装填包数保存在装填记录中。
- 已被业务引用后只能停用,不能删除。
### 4.2 `biz_warehouse`
- 保存仓库编码、名称、地址和启停状态。
- `warehouse_code` 非空且唯一。
- 通过 `biz_user_warehouse` 与现有 `sys_user` 多对多关联。
### 4.3 客户、现场与产线
- `biz_customer`:客户主数据,客户编码唯一。
- `biz_customer_site`:归属于客户,现场编码在同一客户下唯一。
- `biz_production_line`:仅表示客户现场使用/消耗产线,产线编码在同一现场下唯一。
- `biz_user_site`:现有用户与客户现场的多对多关联。
- 本次不建立工厂内部装填产线。
### 4.4 `biz_pallet`
- 保存托盘编号、容量包数、资产启停状态和固定二维码标识。
- `pallet_code`、`qr_token` 非空且唯一,创建后不可修改。
- 托盘创建时同时建立 `biz_pallet_inventory`,初始流转状态为 `UNSTOCKED`。
- 二维码只编码不可推测的随机 `qr_token`,不包含菌种、批次、数量、客户或权限信息。
- 使用后端 `qrcode` 和现有 Pillow 生成可打印二维码;二维码损坏时补打原标识,不允许换码。
## 5. 业务与库存模型
### 5.1 单据与事实表
| 表 | 用途和核心约束 |
|---|---|
| `biz_inbound_order` | 首次入库单,状态仅为 `DRAFT`、`CONFIRMED`,单号唯一。 |
| `biz_inbound_item` | 入库单托盘明细,同一单据内托盘唯一。 |
| `biz_filling_record` | 单托盘即时装填;保存菌种、批次、包装规格、初始整数包数、仓库和操作人。 |
| `biz_shipment_order` | 发货单,状态仅为 `DRAFT`、`CONFIRMED`,单号唯一。 |
| `biz_shipment_item` | 多托盘整托发货明细;包数由确认时的当前余量快照生成,不允许手填拆分。 |
| `biz_usage_record` | 每次现场使用事实;保存产线、本次使用包数、使用后余量和幂等键。 |
| `biz_return_order` | 两阶段退回单,状态为 `REQUESTED`、`CONFIRMED`。 |
| `biz_return_item` | 退回托盘及发起时余量快照;同一退回单内托盘唯一。 |
| `biz_pallet_inventory` | 每托盘唯一一行当前状态、位置、余量、当前装填记录、当前绑定产线和版本号。 |
| `biz_pallet_event` | 只追加追溯流水;记录来源单据、前后状态、位置、数量、操作人、时间和幂等键。 |
### 5.2 数量约束
- 库存最小单位为“包”,所有包数均为整数。
- 装填量和使用量必须大于零。
- 剩余量必须满足 `0 <= remaining_packages <= pallet.capacity_packages`。
- 一个托盘的同一装填周期只能包含一个菌种和一个生产批次,禁止混装。
- 当前装填记录在余量归零后关闭;下一次装填形成新的独立记录,不覆盖历史。
### 5.3 位置约束
`biz_pallet_inventory` 在任意时刻只能表达一个有效位置:
- 位于仓库时只能有 `warehouse_id`。
- 位于客户现场时只能有 `site_id`。
- `UNSTOCKED` 时两者都为空。
- `current_line_id` 只能在客户现场第一次使用后设置,并在仓库确认退回时清空。
## 6. 托盘状态机
```text
UNSTOCKED
└─ 首次入库确认 → EMPTY_IN_WAREHOUSE
└─ 单托盘装填 → FILLED_IN_WAREHOUSE
└─ 发货确认 → AT_SITE_UNUSED
├─ 部分使用 → AT_SITE_PARTIAL
├─ 全部用完 → AT_SITE_EMPTY
└─ 发起退回 → RETURNING
AT_SITE_PARTIAL
├─ 继续部分使用 → AT_SITE_PARTIAL
├─ 全部用完 → AT_SITE_EMPTY
└─ 发起退回 → RETURNING
AT_SITE_EMPTY
└─ 发起退回 → RETURNING
RETURNING
└─ 仓库确认收货:
├─ remaining_packages > 0 → FILLED_IN_WAREHOUSE
└─ remaining_packages = 0 → EMPTY_IN_WAREHOUSE
```
补充规则:
- 发货确认后直接归属目标客户现场,不增加在途或签收状态。
- 客户现场第一次使用时选择产线,本次驻场周期内不可变更。
- 客户发起退回后冻结使用和再次发货,直到仓库确认。
- 仓库确认退回后解除产线绑定。
- 带料退回保留原菌种、批次和余量,可再次整托发货;再次到达现场后重新绑定产线。
- 空托盘必须退回仓库后才能再次装填。
## 7. 五类业务流程
### 7.1 仓库入库
- 只处理已建档空托盘的首次入库。
- 创建草稿,选择目标仓库,连续扫描 `UNSTOCKED` 托盘。
- 草稿可编辑或删除;确认后锁定全部托盘并整单转为 `EMPTY_IN_WAREHOUSE`。
- 流程图中连续出现的第二个“创建入库单”按重复节点处理。
### 7.2 工厂装填
- 扫描 `EMPTY_IN_WAREHOUSE` 托盘。
- 校验当前用户拥有该仓库数据权限。
- 选择菌种,填写生产批次、包装规格和正整数包数。
- 一次操作只处理一个托盘并即时确认。
- 成功后创建装填记录,状态转为 `FILLED_IN_WAREHOUSE`。
### 7.3 发货出库
- 创建草稿,选择来源仓库和目标客户现场。
- 一张发货单允许多个托盘,只允许整托发货。
- 当前用户必须同时拥有来源仓库和目标客户现场数据权限。
- 确认时按托盘 ID 排序加锁;全部成功后转为 `AT_SITE_UNUSED`。
- 不增加在途、签收或拒收流程。
### 7.4 客户现场使用
- 用户必须具备 `business:usage:add` 且关联当前客户现场。
- 扫码后后端返回托盘、菌种、批次、当前余量和允许操作。
- 第一次使用选择该现场下的产线,并锁定至本次退回完成。
- 使用包数必须为正整数且不得超过当前余量。
- 使用后余量大于零时转为 `AT_SITE_PARTIAL`,等于零时转为 `AT_SITE_EMPTY`。
- 使用请求必须携带幂等键,PDA 重复提交不得重复扣减。
### 7.5 退回仓库
- 客户现场用户扫描一个或多个同现场托盘,选择目标仓库并发起退回。
- 发起后保存余量快照,托盘转为 `RETURNING`,禁止继续使用。
- 仓库人员实际收货扫码确认,必须拥有目标仓库数据权限。
- 确认时重新校验状态和余量快照,整单成功或整单回滚。
- 有余料时转为 `FILLED_IN_WAREHOUSE`,无余料时转为 `EMPTY_IN_WAREHOUSE`。
## 8. 菜单、页面、API 与权限
### 8.1 菜单结构
```text
系统管理
├─ 基础资料维护
│ ├─ 菌种资料
│ ├─ 客户资料
│ ├─ 客户现场
│ ├─ 产线资料
│ ├─ 仓库资料
│ └─ 托盘资料
├─ 业务管理
│ ├─ 仓库入库
│ ├─ 工厂装填
│ ├─ 发货出库
│ ├─ 客户现场使用
│ └─ 退回仓库
└─ 报表查询
├─ 托盘库存
├─ 装填记录
├─ 发货出库
├─ 产线消耗
├─ 退回仓库
└─ 托盘追溯
```
三个目录节点不设置权限字符串。页面菜单使用 `*:list`,按钮节点使用具体操作权限。
### 8.2 权限映射
| 页面 | 资源前缀 | 动作 |
|---|---|---|
| 菌种资料 | `master:strain` | `list/query/add/edit/remove` |
| 客户资料 | `master:customer` | `list/query/add/edit/remove` |
| 客户现场 | `master:site` | `list/query/add/edit/remove/assign` |
| 产线资料 | `master:line` | `list/query/add/edit/remove` |
| 仓库资料 | `master:warehouse` | `list/query/add/edit/remove/assign` |
| 托盘资料 | `master:pallet` | `list/query/add/edit/remove/print` |
| 仓库入库 | `business:inbound` | `list/query/add/edit/remove/confirm` |
| 工厂装填 | `business:filling` | `list/query/add` |
| 发货出库 | `business:shipment` | `list/query/add/edit/remove/confirm` |
| 客户现场使用 | `business:usage` | `list/query/add` |
| 退回仓库 | `business:return` | `list/query/request/confirm` |
| 托盘库存 | `report:palletinventory` | `list` |
| 装填记录 | `report:filling` | `list` |
| 发货出库 | `report:shipment` | `list` |
| 产线消耗 | `report:consumption` | `list` |
| 退回仓库 | `report:return` | `list` |
| 托盘追溯 | `report:pallettrace` | `list` |
每个权限字符串必须同时出现在:
1. `sys_menu.perms`。
2. Web 的 `v-hasPermi` 或页面权限判断。
3. 后端 `UserInterfaceAuthDependency`。
4. 对应权限测试。
未授权用户绕过 Web 直接调用接口时返回现有规范的 403。拥有功能权限但没有目标仓库或客户现场关联时,同样拒绝访问。
### 8.3 API 前缀
- 主数据:`/master/strain`、`/master/customer`、`/master/site`、`/master/line`、`/master/warehouse`、`/master/pallet`。
- 业务:`/business/inbound`、`/business/filling`、`/business/shipment`、`/business/usage`、`/business/return`。
- 报表:`/report/pallet-inventory`、`/report/filling`、`/report/shipment`、`/report/consumption`、`/report/return`、`/report/pallet-trace`。
各业务 Controller 提供自己的扫码查询接口,避免现场操作员被迫获得托盘主数据管理权限。所有写接口由后端重新查询当前状态,不信任前端提交的库存、位置或状态。
### 8.4 角色
- 不新增固定业务角色。
- 不写入新增菜单对应的普通角色种子。
- 超级管理员沿用现有特例获得全部菜单和数据。
- 其他用户通过现有角色管理分配菜单和按钮,并通过仓库/客户现场页面分配数据范围。
- 角色授权必须包含“系统管理 → 分组目录 → 页面 → 按钮”的完整祖先链。
## 9. Web 页面行为
### 9.1 基础资料
- 沿用现有搜索、分页、表格和弹窗表单风格。
- 仓库和客户现场页面提供“分配用户”。
- 托盘页面提供二维码生成、查看、打印和补打。
- 被引用数据只允许停用;未引用数据才显示删除操作。
### 9.2 PDA Web
- 页面使用大尺寸输入区和操作按钮,适配 Android PDA 浏览器。
- 扫码输入框在成功、失败和页面恢复后重新聚焦。
- 回车触发扫码查询,重复扫码给出明确提示。
- 使用 `crypto.randomUUID()` 生成请求幂等键,不新增前端 UUID 依赖。
- 前端展示后端返回的状态和允许动作,不自行推算业务状态。
### 9.3 报表
六个独立页面只提供查询、筛选和分页:
- 托盘库存:当前状态、位置、菌种、批次、初始包数和剩余包数。
- 装填记录:装填时间、仓库、托盘、菌种、批次、规格和包数。
- 发货出库:发货单、客户现场、托盘和发货时余量。
- 产线消耗:客户、现场、产线、托盘、菌种、批次和使用包数。
- 退回仓库:退回申请、目标仓库、托盘、余量和确认时间。
- 托盘追溯:按事件时间展示托盘完整状态、位置和数量变化。
筛选条件从日期、仓库、客户、现场、产线、菌种、批次、托盘编号和状态中按页面需要选取。本次不提供 Excel 导出。
## 10. AI 管理与外链菜单清理
### 10.1 删除范围
- 删除后端 `module_ai` 全部源码。
- 删除 `utils/ai_util.py` 和仅被 AI 使用的 `utils/crypto_util.py`。
- 删除 Web `src/api/ai`、`src/views/ai` 和三个 AI 图标。
- 删除 13 个 `/ai/*` 接口及 `ai:model:*`、`ai:chat:*` 权限。
- 删除 AI 专属缓存命名空间、限流常量和 LiteLLM 日志配置。
- 从后端 requirements 删除 Agno 和模型提供商 SDK。
- 从前端 `package.json` 删除仅供 AI 使用的 Markdown、Monaco、UUID 等依赖,并删除 Monaco Vite 配置。
- README 删除当前 AI 功能说明;CHANGELOG 保留历史新增记录并增加移除记录。
### 10.2 保留范围
- 认证、JWT、接口权限、数据范围、自动路由扫描、缓存、日志、限流和通用上传。
- `utils/transport_crypto_util.py`。
- `@vueuse/core`、Pillow、PyJWT、cryptography 等共享依赖。
- 通用 API Key 日志脱敏测试。
- 导航栏独立“源码地址”和“文档地址”入口。
### 10.3 数据库残留策略
- 两套初始化脚本删除 AI 菜单、角色关联、AI 字典和 AI 表 DDL,确保新环境不再创建 AI 功能。
- MySQL 增量补丁删除 AI 菜单元数据、AI 字典元数据和“施尔丰仓库”菜单,但不执行。
- 增量补丁不得包含 `DROP TABLE ai_models`、`DROP TABLE ai_chat_config` 或任何 Agno 表删除语句。
- 现有 AI 表、行数、敏感配置和备份需求在最终交付中单独列出,后续获得明确确认后再清理。
## 11. SQL 交付策略
### 11.1 初始化脚本
MySQL 和 PostgreSQL 初始化脚本保持语义一致:
- 删除 AI 和外链菜单种子。
- 删除 AI 字典及 AI 表定义。
- 新增业务表、唯一约束、检查约束和索引。
- 新增三个目录、十一个业务/主数据页面、六个报表页面及按钮权限。
- 不重新编号现有无关菜单、字典或序列。
- 不给普通角色增加 `sys_role_menu` 记录。
### 11.2 MySQL 增量补丁
- 文件名使用日期和业务含义,作为一次性、可审查补丁。
- DDL 使用明确表名;菜单删除使用已审计的明确 ID,禁止通配删除。
- DDL 与菜单 DML 分节书写,明确 MySQL DDL 隐式提交边界。
- 包含业务表创建、菜单与权限插入、AI 元数据和菜单 ID 99 清理。
- 不连接、不执行本地 MySQL。
- 补丁应用后的 Redis 菜单、用户信息和 AI 字典缓存清理作为部署步骤记录,不在本次自动执行。
## 12. 错误、并发与审计
- 认证失败、功能越权和数据范围越权沿用项目现有异常与 403 处理。
- 非法数量、资源不存在、托盘状态冲突或单据状态冲突必须返回失败,不得伪成功。
- 所有确认、使用和退回请求使用唯一幂等键。
- 状态变更使用数据库行锁;多托盘按托盘 ID 排序加锁以降低死锁概率。
- `biz_pallet_inventory.version` 用于并发版本校验。
- `biz_pallet_event` 只追加、不修改、不删除;`sys_oper_log` 只作为补充审计,不能替代业务追溯。
- 已确认单据不可编辑或删除,本次不实现冲销或补偿事件入口。
## 13. 验证与验收
### 13.1 后端
- 运行 `ruff` 和相关 `pytest`。
- 验证应用可以导入,OpenAPI 中不存在 `/ai/*`。
- 覆盖状态机合法/非法迁移、整数数量边界、超用、幂等、数据范围和 403。
- 覆盖入库、装填、发货、使用、退回的事务回滚行为。
- 验证二维码生成接口输出有效图形。
### 13.2 Web
- 运行项目可用的 lint/check 命令和生产构建。
- 检查所有按钮权限和请求 URL 与后端一致。
- 检查 PDA 扫码输入、自动聚焦、重复提交和错误展示。
- 确认 `shierfeng-fastapi-app` 没有本次任务产生的改动。
### 13.3 静态和 SQL
- 使用 `rg` 检查 AI 页面、接口、权限、依赖和配置残留。
- 对比 MySQL、PostgreSQL 初始化脚本中的业务表和权限集合。
- 检查 MySQL 增量补丁不包含 AI 业务表删除。
- 检查菜单组件路径均对应真实 Web 页面。
### 13.4 本轮无法完成的运行态验收
由于用户选择只生成而不执行 MySQL 补丁,本轮不能真实验证:
- 业务表在 MySQL 中的实际建表结果。
- 运行态 `/getRouters` 是否返回新增菜单并移除旧菜单。
- 不同真实角色和仓库/现场授权数据下的完整端到端流程。
- 补丁执行后的 Redis 缓存失效行为。
这些项目必须在用户后续审查并应用补丁后执行,交付时不得表述为已通过。
## 14. 实施顺序
1. 保护现有未提交改动并建立验证基线。
2. 删除 AI 专属代码、配置、依赖和外链菜单种子。
3. 实现主数据、用户授权、二维码和状态机基础。
4. 实现首次入库、装填、发货、现场使用和两阶段退回。
5. 实现六类只读报表和托盘追溯。
6. 同步两套初始化脚本并生成不执行的 MySQL 增量补丁。
7. 执行后端、Web、权限、静态和 SQL 验证。
8. 交付修改清单、验证证据、AI 数据库残留和补丁应用后验收项。
@@ -1,66 +0,0 @@
# 业务菜单提升为一级菜单设计
## 目标
将“基础资料维护”、“业务管理”、“报表查询”从“系统管理”下提升为独立一级菜单,使业务入口与系统管理入口在侧边栏中并列展示。
## 目标层级与顺序
一级菜单按以下顺序展示:
1. 基础资料维护(`menu_id=121`)
2. 业务管理(`menu_id=122`)
3. 报表查询(`menu_id=123`)
4. 系统管理(`menu_id=1`)
5. 系统监控(`menu_id=2`)
6. 系统工具(`menu_id=3`)
对应的持久化字段为:
| 菜单 ID | 菜单名称 | `parent_id` | `order_num` |
|---|---|---:|---:|
| 121 | 基础资料维护 | 0 | 1 |
| 122 | 业务管理 | 0 | 2 |
| 123 | 报表查询 | 0 | 3 |
| 1 | 系统管理 | 0 | 4 |
| 2 | 系统监控 | 0 | 5 |
| 3 | 系统工具 | 0 | 6 |
## 架构与改动边界
项目的 Web 路由和侧边栏由后端 `/getRouters` 根据 `sys_menu` 动态生成,因此应修改菜单数据,不在前端增加拍平或重排逻辑。
本次仅调整六个目录菜单的层级和排序:
- 保持业务子菜单、组件路径、API 和权限标识不变。
- 保持角色菜单关联不变。
- 不修改 APP 端。
- 不新增依赖,不改动现有动态路由机制。
## SQL 与迁移设计
1. 同步修改 MySQL 和 PostgreSQL 初始化 SQL,使新建数据库直接得到目标层级。
2. 保留已执行的 `20260724_pallet_workflow_mysql.sql` 历史补丁,不改写已落库迁移的语义。
3. 新增一个 MySQL 增量补丁,同时支持“调整前”和“已调整”两种合法状态,保证可幂等重跑。
4. 增量补丁在第一条永久 DML 前校验六个固定 ID 的菜单身份和允许的层级状态。任一 ID 缺失、被复用或稳定字段异常时,在写入前中止。
5. 六条更新置于显式事务中,只修改 `parent_id` 和 `order_num`;执行器不得使用 `--force` 或 continue-on-error 模式。
6. 补丁末尾输出六个目录的实际层级和排序,用于人工核对。
## 缓存与运行态
数据库更新后,不使用 Redis `FLUSHALL`。本地验证通过重新登录触发现有登录缓存失效逻辑,并使用独立查询参数核对 `/getRouters` 的新层级。
## 测试与验收
- 先增加失败契约,要求 MySQL/PostgreSQL 初始化 SQL 定义上述六个一级菜单顺序。
- 增加增量补丁契约,覆盖写前校验、幂等性、显式事务、精确更新范围和事后查询。
- 运行后端 SQL 契约、业务测试、Ruff 检查、前端业务契约和生产构建。
- 数据库执行前重新创建备份,并只有在用户再次明确确认后才执行增量补丁。
- 数据库验收必须确认六个目录均为 `parent_id=0`,排序为 `1..6`,业务子菜单数量、页面组件和权限标识不变。
## 不在本次范围内
- 业务页面的功能、样式或交互调整。
- 普通角色的新授权或仓库/现场数据映射。
- 历史 AI 数据库表或其他数据清理。
- Redis 全库清理。
+275
View File
@@ -0,0 +1,275 @@
# 智链云枢 Web 端改版 · 修改记录
本文记录在提交 `80a71b4`(添加前后端筛选导出excel按钮权限)之后所做、**尚未提交**的全部改动,说明每项改了什么、为什么改。
- **改动范围**:后端 `shierfeng-fastapi-backend`、前端 `shierfeng-fastapi-frontend`。
- **未改动**:小程序 `shierfeng-fastapi-app`、登录页 `src/views/login.vue` 及 Logo 资源、`shierfeng-fastapi-backend/.env.dev`(该文件的改动是原有的,不属于本次工作)。
- **总原则**:后端只新增只读查询、扩展列表和详情的返回字段;**所有写接口、托盘状态机和幂等键规则都没有改**。新增的关联查询都沿用原接口的数据范围(仓库 / 现场授权),并受同一测试批次范围约束。没有新增权限标识,也不需要改菜单。
- **规模**:已跟踪文件 85 个改动(约 +14,400 / −2,500 行),新增文件 37 个(不含本文件)。
改动分几轮完成,顺序如下:
| 轮次 | 内容 |
|---|---|
| 1 | 全局外壳与导航、工作台、托盘档案、各页统一页头 |
| 2 | 发货出库页、侧边栏用户卡片、托盘追溯入口 |
| 3 | 基础资料维护:托盘、客户、仓库、导入、分配用户,以及菌种、现场、产线、隧道 |
| 4 | 客户、仓库指标条;7 个新增 / 编辑对话框 |
| 5 | 业务管理:共用部分(第 0 步)、仓库入库 |
| 6 | 退回仓库 |
| 7 | 工厂装填(同时修复入库页扫码反馈不显示的问题) |
| 8 | 客户现场使用 |
---
## 一、后端(shierfeng-fastapi-backend)
### 1. 新增接口一览
全部为 GET、只读。
| 接口 | 权限 | 用途 |
|---|---|---|
| `/master/site/customer-options` | `master:site:list` | 新增 / 编辑现场时选择所属客户 |
| `/master/line/site-options` | `master:line:list` | 新增 / 编辑产线时选择所属现场 |
| `/master/tunnel/site-options` | `master:tunnel:list` | 新增 / 编辑隧道时选择所属现场 |
| `/business/inbound/summary` | `business:inbound:list` | 入库页指标:草稿单数与托数、本月入库、待首次入库托盘、各仓库空托盘 |
| `/business/return/summary` | `business:return:list` | 退回页指标:待核对单数与托数、退回中带料托数与包数、本月确认与回仓去向、平均确认耗时 |
| `/business/return/site-options` | `business:return:list` | 退回页的来源现场筛选与名称显示 |
| `/business/filling/summary` | `business:filling:list` | 装填页指标:今日与本月装填、可装填空托盘、14 天内到期仍在仓的托盘 |
| `/business/filling/batches` | `business:filling:list` | 按「菌种 + 生产批次」汇总装填(分页,最近装填的在前) |
| `/business/filling/batches/detail` | `business:filling:query` | 批次详情:托盘当前去向分布与最近 10 条装填 |
| `/business/usage/site-options` | `business:usage:list` | 现场使用页的现场选择器(附隧道数、产线数) |
| `/business/usage/site-board?siteId=` | `business:usage:query` | 现场看板:库存、今日用量、隧道在用情况、跳过先到次数(不返回内部溯源号) |
| `/business/usage-fifo/queue?siteId=` | `business:usage:query` | 现场待使用托盘按菌种分组,顺序与先到先用建议一致 |
另外,基础资料的各个导入接口新增参数 `preview=true`,表示「只校验、不写入」,权限不变。
### 2. 扩展的已有接口
返回结构只增加字段,不删除、不改名。
| 接口 | 新增内容 | 目的 |
|---|---|---|
| 基础资料各列表(菌种 / 客户 / 现场 / 产线 / 隧道 / 仓库 / 托盘) | 名称、统计数、分配用户等汇总字段 | 页面显示名称而不是 ID,并提供指标数字 |
| 客户详情 | 现场树:产线、隧道、各状态托盘数、分配用户 | 客户页右侧详情面板 |
| 托盘列表 | `flowGroup` 参数(未入库 / 仓库 / 在途 / 现场 / 退回中);托盘号参数同时精确匹配二维码标识 | 托盘页的状态页签,以及扫码枪搜索 |
| 托盘详情 | 最近流转记录 | 托盘侧边面板 |
| 发货列表 | `keyword`、`receiptState` 参数;仓库、现场、客户、创建人名称,托盘数、包数、菌种构成 | 发货页的统一搜索、状态页签和路线显示 |
| 发货详情 | 明细补托盘号、菌种、批次 | 详情面板;也修复了编辑对话框只显示 `#123` 的问题 |
| 入库列表 / 详情 | 仓库、创建人、确认人名称,托盘数;明细补托盘号和当前状态 | 入库页列表与详情面板 |
| 退回列表 / 详情 | `keyword` 参数(单号、托盘号、现场、客户、仓库);来源现场、客户、目标仓库、申请人、确认人名称,托盘数、余量包数、带料托数;明细补托盘号和隧道名称 | 退回页列表、详情和到仓核对 |
| 装填列表 | `keyword` 参数(装填单号、托盘号、批次);托盘号、仓库、操作人名称 | 装填明细视图 |
| 使用记录列表 | `keyword`(记录号、托盘号)、`fifoSkipped` 参数;托盘、现场、隧道、产线、操作人名称,以及**被跳过的先到托盘号**(取自写入时已保存的先到先用快照) | 使用记录表和「只看跳过先到」开关 |
### 3. 按文件说明
**新增文件**
| 文件 | 说明 |
|---|---|
| `dao/master_summary_dao.py`、`service/master_summary_service.py`、`entity/vo/master_summary_vo.py` | 基础资料列表汇总、客户现场树、托盘最近流转、上级资料选项、导入预览模型 |
| `dao/shipment_summary_dao.py` | 发货列表名称、托盘数、包数与菌种构成 |
| `dao/inbound_summary_dao.py` | 入库列表名称与托盘数、明细托盘号与状态、页面概况 |
| `dao/return_summary_dao.py` | 退回列表汇总、明细托盘号与隧道、来源现场选项、页面概况 |
| `dao/filling_summary_dao.py` | 装填列表名称、批次汇总与详情、页面概况 |
| `dao/usage_summary_dao.py` | 使用记录名称、现场选项、现场看板 |
**修改文件**
| 文件 | 修改内容 |
|---|---|
| `controller/*_controller.py`(菌种、客户、现场、产线、隧道、仓库、托盘) | 列表和详情改用带汇总的返回模型;现场、产线、隧道新增上级选项路由,单独注册并排在 `/{id}` 详情路由之前,避免被详情路由误匹配 |
| `controller/master_excel_controller.py`、`service/master_excel_service.py` | 导入支持 `preview`:执行与真实导入相同的校验后整体回滚,返回逐行的新增 / 更新 / 跳过 / 错误结果 |
| `dao/master_dao.py`、`entity/vo/master_vo.py` | 托盘列表的 `flowGroup` 过滤与二维码精确匹配 |
| `controller/shipment_controller.py`、`service/shipment_service.py` | 发货列表汇总与筛选、详情明细补充字段 |
| `controller/inbound_controller.py`、`service/inbound_service.py` | 入库列表与详情补充字段,新增 `/summary` |
| `controller/return_controller.py`、`service/return_service.py` | 退回列表与详情补充字段,新增 `/summary`、`/site-options` |
| `controller/filling_controller.py`、`service/filling_service.py` | 装填列表补充字段,新增 `/summary`、`/batches`、`/batches/detail` |
| `controller/usage_controller.py`、`service/usage_service.py` | 使用记录列表补充字段,新增 `/site-options`、`/site-board` |
| `controller/usage_fifo_controller.py`、`service/usage_fifo_service.py` | 新增 `/queue`。原来权限写在路由组上,现改为每个接口各自声明(同为 `business:usage:query`),以符合「每个接口一个权限声明」的契约测试,实际效果不变 |
| `dao/usage_fifo_dao.py` | 把先到先用的候选条件、关联和排序抽成公共方法,供建议和队列共用,保证两边顺序一致。已逐条比对:改动前后建议查询(含写入时的加锁复核)在 12 种参数组合下生成的 SQL 完全相同 |
| `dao/operation_dao.py` | 发货、退回、装填、使用记录列表的 `keyword` 等筛选条件。原排序(按 ID 从旧到新)未改 |
| `entity/vo/operation_vo.py`、`entity/vo/usage_fifo_vo.py` | 上述列表行、概况、看板、队列等新模型;查询模型新增 `keyword`、`fifoSkipped` 等字段 |
### 4. 测试(`tests/module_business`)
- **新增**:`test_master_summary.py`、`test_shipment_list_summary.py`、`test_inbound_summary.py`、`test_return_summary.py`、`test_filling_summary.py`、`test_usage_summary.py`;另在 `test_usage_fifo.py` 增加 3 条待使用队列测试(内存数据库,实际执行查询)。覆盖内容为数据范围、测试批次隔离、SQL 条件,以及返回字段的驼峰命名。
- **同步更新**(接口变更是有意为之):
- `test_api_permission_contract.py`:登记新路由、权限映射、静态路由在详情路由之前、查询参数,路由权限条目从 119 条增至 131 条;
- `test_vo_contract.py`:新模型和新字段;
- 各 `test_*_service.py`:路由列表、返回模型,以及对新汇总查询的模拟。
- **结果**:`PYTHONPATH=. python -m unittest` 运行 `tests/module_business` 下全部用例,共 843 条,全部通过(2 条跳过)。
---
## 二、前端(shierfeng-fastapi-frontend)
### 1. 全局外壳与导航
| 文件 | 修改内容与目的 |
|---|---|
| `layout/index.vue`、`layout/components/AppMain.vue`、`Navbar.vue`、`TagsView/index.vue` | 新版外壳:内容区为白色内嵌面板,顶栏更简洁;手机宽度可用 |
| `layout/components/Sidebar/index.vue`、`SidebarItem.vue`、`Logo.vue` | 浅色侧边栏、分组菜单、统一图标,使用官方 Sylvan Logo |
| `layout/components/Sidebar/UserCard.vue`(新增) | 侧边栏左下角显示当前用户的头像、姓名、部门 · 角色,点击打开 个人中心 / 布局设置 / 退出登录;侧边栏折叠时只显示头像 |
| `layout/components/CommandPalette/index.vue`(新增) | Ctrl / ⌘K 搜索面板:输入页面名称直接跳转,输入托盘号打开托盘档案 |
| `layout/components/TestModeBanner/index.vue`(新增) | 测试数据模式下页面顶部显示琥珀色提示条,可直接退出测试批次 |
| `assets/styles/sf-shell.scss`(新增)、`index.scss`、`sidebar.scss`、`variables.module.scss` | 设计令牌(颜色、圆角、阴影),深色模式有单独配色;全屏作业区、分段切换、批量操作栏等共用样式 |
| `settings.js` | 默认浅色侧边栏、默认关闭页签栏。已保存过「布局设置」的用户仍沿用自己的设置 |
| `store/modules/user.js` | 读取部门名和角色名,供用户卡片使用 |
| `router/index.js`、`components/Breadcrumb/index.vue` | 「首页」改称「工作台」;新增托盘档案路由 `/pallet/passport`,需要托盘追溯权限 |
| `main.js` | 全局注册 `PageHeader`、`PalletCodeLink`、`DocNo` |
### 2. 新增的共用组件与工具
| 文件 | 用途 |
|---|---|
| `components/PageHeader`(含 `descriptions.js`) | 统一页头:标题、页面说明、右侧操作区;业务页用 `flow` 属性显示托盘流程条 |
| `components/FlowStepper` | 托盘流程 1 入库 → 2 装填 → 3 发货 → 4 现场使用 → 5 退回,高亮当前环节;没有菜单权限的环节只显示、不能点 |
| `components/ScanFeedback` | 扫码结果条,分成功、提醒、拒绝、等待等状态,屏幕阅读器会播报 |
| `components/DocNo`、`utils/docNo.js` | 长单号缩短显示(如 `SH-9F2C…E41B`),鼠标悬停显示完整单号 |
| `components/PalletCodeLink` | 托盘号链接到托盘档案;没有权限时显示为纯文本 |
| `components/SfIcon`(含 `icons.js`) | 统一线性图标,路径只取自本地常量表 |
| `components/MasterRefSelect` | 可搜索的上级资料选择框(所属客户 / 所属现场) |
| `assets/styles/sf-master.scss` | 指标条、状态页签、筛选行、列表加详情面板、时间线、状态胶囊等样式,基础资料页和业务页共用 |
| `utils/masterDisplay.js` | 数字格式化、天数计算、状态胶囊样式、按路由末段查找菜单路径 |
| `utils/masterRouteIntent.js` | 基础资料页之间的跳转意图(如从客户详情跳到现场页并带上所属客户);一次性意图处理后从地址栏移除,刷新不会重复触发 |
| `utils/palletPassport.js` | 托盘流转状态的中文名称与阶段分组 |
| `utils/palletRanges.js` | 把连续托盘号合并成编号段;解析批量粘贴的内容,支持 `10512-10518` 这类区间写法 |
### 3. 工作台、托盘档案、托盘追溯
- **`views/dashboard/index.vue` 工作台**:扫码 / 搜索栏、4 个关键指标、托盘 8 种状态的流转看板(点击某个状态可查看托盘)、装填与发货对比图、待办、临期托盘、仓库库存、最近动态。全部数据来自已有接口。同时修复了「最近出库单」实际显示的是最早单据的问题(后端列表按从旧到新排序)。
- **`views/pallet/passport/index.vue` 托盘档案**(新增):当前状态、位置、余量、有效期,本轮流转进度条,所有变动的时间线(往轮折叠),以及同一溯源号的其他托盘。
- **`views/report/palletTrace/index.vue` 托盘追溯**:顶部新增「查看单个托盘档案」入口,下方保留原来的跨托盘流转明细查询。
- **其余 5 个报表页**:加统一页头,托盘号可点击打开托盘档案;查询逻辑没变。
### 4. 基础资料维护页
| 页面 | 修改内容 |
|---|---|
| 托盘资料 `master/pallet` | 指标条;状态页签带数量;搜索框也支持扫码;每行显示当前位置、余量和周转次数;侧边面板含二维码标签、最近流转、打印 / 编辑 / 停用;勾选多行后可批量打印标签或停用 |
| 客户资料 `master/customer` | 客户列表加详情面板,每个现场展开后显示产线、隧道、各状态托盘数和分配用户;「新增现场 / 分配」等按钮跳到对应页面并带好上级 |
| 仓库资料 `master/warehouse` | 卡片 / 列表切换;显示库存、今日装填与发货、待处理单据、7 天内到期托盘、分配用户 |
| 菌种、现场、产线、隧道 | 显示名称而不是 ID,加指标条(多数指标只统计当前页,会标「本页」)和近期使用情况 |
| 7 个新增 / 编辑对话框 | 统一布局;状态做成两张说明卡,写明选择后的影响(已与后端校验逐条核对);所属客户 / 现场改为可搜索列表,只列启用的上级,加载失败时退回原来的 ID 输入;托盘容量固定显示 100 包;编辑托盘时托盘号和二维码标识只读 |
| `components/MasterExcelActions` 批量导入 | 改为三步:选择文件 → 校验预览(逐行显示新增 / 更新 / 跳过 / 错误,错误行可下载 CSV)→ 正式导入。导入请求现在会带上测试批次请求头(原来的上传绕开了统一请求层,不会带) |
| `components/BusinessUserAssignDialog` 分配用户 | 支持筛选和头像,用户旁标注「将新增 / 将移除」,并汇总本次变化 |
| `api/master/site.js`、`line.js`、`tunnel.js` | 新增上级选项接口的调用 |
### 5. 业务管理页
各页都只改界面和读取方式。**写入流程原样保留**:提交前的复核、幂等键、防串单的请求代次、确认前重新读取详情等。
**发货出库 `business/shipment`**
- 状态页签带数量:全部 / 草稿 / 待收货 / 已收货 / 历史单据。
- 一个搜索框覆盖单号、仓库、客户和现场,另加创建时间筛选;原来的仓库ID、现场ID 输入框去掉。
- 列表显示路线名称(`成品一仓 → 金禾菌业 · 南厂`)、托盘数与包数、菌种构成,超过 48 小时未收货会标出。
- 点一行打开详情面板:打印标签、编辑、确认发货,以及 创建 → 确认 → 收货核对 的进度线。
- 「发货单 / 收货核对」改为页头切换;扫码在全屏作业区进行。
**仓库入库 `business/inbound`**
- 指标条,全部 / 草稿 / 已确认 页签,按仓库和创建时间筛选,草稿等待 2 天以上会标出。
- 详情面板:时间线、编号段、托盘当前状态。
- 批量确认:逐张确认,每张用自己的幂等键,中途失败就停下。
- 作业区可切换「逐个扫码 / 批量粘贴」,粘贴支持区间写法,按编号段汇总便于核对实物,被拒托盘写明原因;确认后显示结果和「去工厂装填」链接。
- 修复:作业区扫码反馈条之前一直不显示(见下方「测试脚本」中的说明)。
**工厂装填 `business/filling`**
- 指标条;「按生产批次 / 装填明细」两种视图。
- 批次视图显示有效期剩余天数;批次面板显示托盘去向分布、最近装填,可「沿用此批参数继续装填」,有效期剩余不超过 14 天时禁用。
- 全屏装填工位:
- 先填写并锁定本批参数,锁定后才能扫码;有效期提供 +30 / +45 / +60 天快捷按钮,每托固定 100 包;
- 「扫码后确认 / 扫码即装填」两种模式;
- 「本班装填」列表和未装填的扫码记录;结束本班后显示结果和「去发货出库」链接。
- 每次扫码都先清空上一托的提交内容和幂等键,锁定的批次参数另外保存,扫码成功后再带入。
**客户现场使用 `business/usage`**
- 页头:现场选择器(按账号记住上次选择,只在主动切换时保存)和「开始现场作业」按钮。
- 看板:4 个指标;隧道卡片(点击按隧道筛选记录,再点取消);按菌种分页签的待使用队列,第一托标「建议优先」,并注明未登记到场时间的托数。
- 记录表:按关键字、隧道、产线、时间筛选,有「只看跳过先到」开关;显示被跳过的托盘号和原因;原来的现场ID、隧道ID、产线ID 输入框去掉。
- 全屏作业区分 4 步:扫码 → 确认托盘 · 入隧 → 登记用量 → 完成。
- 第 2 步:有更早到场的托盘时可一键「改用」它,或选择跳过原因快捷项再补充说明;隧道用卡片选择。
- 第 3 步:快捷包数 10 / 20 / 50 / 全部,实时显示提交后余量;托盘已绑定产线时产线锁定。
- 完成后可扫描下一托或继续使用这一托;托盘用完时给出退回仓库的链接。
- 有未确认的操作时,第 1 步显示恢复卡片并锁住扫码,页顶横幅也保留。
- 步骤只是从现有状态推导出来的。原有 34 个函数中 33 个一字未改,只有 `resetQuery` 增加了清空日期筛选。待确认记录的本机存储键和格式、恢复重试、人工解除、先到先用拒绝的处理都保持原样。
- 页面不显示内部溯源号:原有测试规定作业页不能出现溯源号,溯源号只在托盘追溯报表里查看。
**退回仓库 `business/return`**
- 去掉页面顶部的两张操作卡。页头放「发起退回申请」,待确认的行和详情面板放「到仓核对」。
- 指标条,全部 / 待确认 / 已确认 页签,按关键字、来源现场、目标仓库、申请时间筛选。
- 列表显示「现场 → 仓库」和「托数 · 余量」;详情面板显示确认后去向(几托带料回有料库存、几托成为空托)。
- 到仓核对作业区:进度 n/N、方块网格(扫到一托亮一格,可撤销),非本单托盘单独列出;全部对上之前「确认退回」按钮不能点。
- 发起申请作业区:来源现场由第一个托盘锁定并显示名称;被拒托盘写明后端原因。
**业务 API**:`api/business/inbound.js`、`return.js`、`filling.js`、`usage.js` 增加上述新接口的调用。
### 6. 测试脚本(`scripts/`)
- **新增**:`pallet-ranges.test.mjs`(8 条)、`return-behavior.test.mjs`(7 条)、`filling-behavior.test.mjs`(7 条)。后两个在 Node 中加载真实页面,只替换网络层。
- **`usage-behavior.test.mjs`**:
- 补充新组件的替身和新只读接口的默认响应。看板默认不带隧道,避免它的隧道名称让摘要断言白白通过。
- 新增 6 条测试:现场偏好、看板权限、隧道筛选、作业步骤、快捷包数、先到先用快捷原因。
- 有一条原断言用「最后一个请求」去找扫码请求;页面启动后还会读看板,所以改为按路径取最近一次扫码请求,对编码后地址的精确比对没变。
- **`filling-dates.test.mjs`**:只为页面启动时新增的读取接口补了替身,断言没动。
- **`verify-business-contract.mjs`**:
- 登记新接口,请求总数 74 → 86,并更新各页的 API 名称清单;
- 新增检查「导入的组件不被同名状态变量遮蔽」。原因:入库页曾有变量 `scanFeedback` 与组件 `ScanFeedback` 同名,模板编译时选中了变量,导致扫码反馈条一直不显示。这个问题已修复,此检查防止再出现。
- **`verify-dashboard-contract.mjs`**:工作台文案变了,更新了必需文案列表;接口和路由检查不变。
- **`package.json`**:`test:business` 加入 `pallet-ranges.test.mjs`。
- **结果**:`npm run test:business` 共 278 条,通过 260 条。失败的 18 条在本次改动之前就存在,与本次无关:
- 17 条是小程序测试,`shierfeng-fastapi-app` 未安装依赖,报找不到 `vue`;
- 1 条版权检查要求公司名为 "Shierfeng",而已提交的 `settings.js` 中是 "Sylvan"。
- `npm run build:prod`(即 `vite build`)通过。
---
## 三、其他
### 开发库演示数据(不在代码仓库内)
为便于查看页面效果,在开发库中写入了一批演示数据:
- **数据性质**:正式数据(`test_batch_id` 为空),切换模式后也能直接看到。
- **生成方式**:调用真实业务服务生成,模拟时间为 2026-09-01 至 2026-10-17。
- **主数据**:
- 菌种 6 个(T09 停用);
- 仓库 3 个:成品一仓、成品二仓、周转仓;
- 客户 4 个,现场 4 个:金禾菌业 南厂、北厂,绿源农业 一号基地,丰登食用菌合作社 东区;
- 产线 9 条,隧道 12 条;
- 托盘 `TP-10401`–`TP-10720`(`TP-10720` 停用)。
- **业务单据**:
- 入库单 8 张(6 张已确认、2 张草稿);
- 装填记录 309 条,分属 15 个批次;
- 发货单 14 张:11 张已收货、2 张在途、1 张草稿;
- 入隧 152 次,使用记录 496 条,其中 13 条跳过先到;
- 退回单 8 张:5 张已确认、3 张待确认。
- **账号授权调整**:
- niangao(id 2):成品一仓、成品二仓、南厂、北厂;
- test(id 102):成品二仓、周转仓、一号基地、东区。
- **注意**:生成脚本放在临时目录,不在仓库中,**重复运行会重复生成业务单据**;如需清除,只能手工执行 SQL。
### 设计稿
各页面的设计稿在:https://claude.ai/artifact/2aJi4pKkw9vMnepsNLFax9
### 与设计稿或计划不同之处
- **导出按钮**:业务页都没有「导出 Excel」,因为契约测试不允许业务页出现导入 / 导出按钮。
- **现场使用页的溯源号**:不显示,原因见上文。
- **新建的入库草稿不能在作业区一步确认**:新增接口不返回单据 ID。目前是保存后在草稿页签里确认;编辑已有草稿时有「保存并确认」。
- **装填**:本班列表不显示装填单号(新增接口不返回);沿用批次只带入参数,不自动锁定。
- **待使用队列**:只按 `siteId` 查询,一次返回该现场所有菌种的分组。
- **字体**:使用系统字体。Geist 字体包未安装(当时无法访问 npm 源),以后安装 `@fontsource-variable/geist` 即可生效。
### 待确认事项
1. **入库新增接口是否返回新单据 ID**:返回后新建草稿就能一步确认,但这需要改写接口。
2. **列表排序是否改为新的在前**:入库、退回、装填明细、使用记录等列表目前从旧到新排列,新单据会落在最后一页。
3. **手工验收**:页面只用自动化测试和本地模拟接口验证过,尚未用真实账号在浏览器里逐页走查。建议走查:
- 草稿到确认;
- 扫码被拒;
- 断网后重试不重复扣减(装填、现场使用、退回确认);
- 测试批次数据隔离;
- 无权限账号看不到对应按钮。
@@ -0,0 +1,11 @@
{
"version" : "1.0",
"configurations" : [
{
"customPlaygroundType" : "device",
"packageName" : "",
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}
+12
View File
@@ -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>
+3 -2
View File
@@ -1,6 +1,7 @@
// 应用全局配置
export default {
baseUrl: "http://localhost:9099",
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
// 应用信息
appInfo: {
// 应用名称
@@ -8,7 +9,7 @@ export default {
// 应用版本
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": "智链云枢管理系统",
"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": "智链云枢管理系统",
"router": {
"mode": "hash",
"base": "./"
}
}
"locale" : "zh-Hans",
"fallbackLocale" : "zh-Hans"
}
+40
View File
@@ -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": {
@@ -128,5 +158,15 @@
"navigationBarTextStyle": "black",
"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>
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"
>智链云枢管理系统登录</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,11 +2,13 @@
<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"
>智链云枢管理系统</text
>
+3 -3
View File
@@ -5,12 +5,12 @@
<!-- 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"
+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/);
});
+11 -3
View File
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
# -------- 数据库配置 --------
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
# 数据库类型,固定使用'mysql'
DB_TYPE = 'mysql'
# 数据库主机
DB_HOST = '127.0.0.1'
@@ -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'
+3 -1
View File
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
# -------- 数据库配置 --------
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
# 数据库类型,固定使用'mysql'
DB_TYPE = 'mysql'
# 数据库主机
DB_HOST = '127.0.0.1'
@@ -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,56 +0,0 @@
import os
from cli.runtime import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService
class AppBootstrapService:
"""
应用引导服务。
该服务负责解析应用原始入口路径、构建启动命令,并以 `exec`
方式将当前 CLI 进程切换为 FastAPI 应用入口进程。
"""
def __init__(self, *, runtime_environment: RuntimeEnvironmentService | None = None) -> None:
"""
初始化应用引导服务。
:param runtime_environment: 运行时环境服务
:return: None
"""
self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT
def get_app_entry_path(self) -> str:
"""
获取应用启动入口路径。
:return: 应用启动入口绝对路径
"""
return os.path.join(self.runtime_environment.get_backend_dir(), 'app.py')
def build_app_run_command(self, env: str) -> list[str]:
"""
构建应用启动命令。
:param env: 运行环境
:return: 应用启动命令参数列表
"""
return [
self.runtime_environment.get_python_executable(),
self.get_app_entry_path(),
'--env',
env,
]
def exec_app_run_command(self, env: str) -> None:
"""
以应用原始入口进程启动当前 FastAPI 应用。
:param env: 运行环境
:return: None
"""
command = self.build_app_run_command(env)
os.execvp(command[0], command)
APP_BOOTSTRAP = AppBootstrapService()
@@ -1,155 +0,0 @@
from dataclasses import dataclass, field
from pathlib import Path
import typer
from cli.completion.controller import CompletionCommandController
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
from cli.context import OutputOption
@dataclass(frozen=True)
class CompletionCommandRegistration:
"""
completion 子命令注册描述。
:param name: 子命令名称
:param help_text: 子命令帮助文本
"""
name: str
help_text: str
@dataclass
class CompletionSubcommandRegistrar:
"""
completion 子命令注册器。
该对象负责将 `show/install/doctor` 子命令挂载到 completion 应用,
将 Typer 命令声明细节从构建器主体中拆出。
:param completion_provider_gateway: completion 提供器对外网关
"""
completion_provider_gateway: CompletionProviderGateway = field(default_factory=lambda: COMPLETION_PROVIDER_GATEWAY)
@staticmethod
def build_registrations() -> tuple[CompletionCommandRegistration, ...]:
"""
返回 completion 子命令注册描述列表。
:return: 子命令注册描述元组
"""
return (
CompletionCommandRegistration(name='show', help_text='输出指定 shell 的 completion 脚本'),
CompletionCommandRegistration(name='install', help_text='安装指定 shell 的 completion 脚本'),
CompletionCommandRegistration(name='doctor', help_text='检查当前 completion 配置状态'),
)
def register(self, app: typer.Typer, *, controller: CompletionCommandController) -> None:
"""
向 completion 应用注册全部子命令。
:param app: completion 子应用
:param controller: completion 命令控制器
:return: None
"""
registrations = {registration.name: registration for registration in self.build_registrations()}
@app.command(registrations['show'].name, help=registrations['show'].help_text)
def show(
shell: str = typer.Argument(
...,
help='shell 名称,如 bash、zsh、fish',
autocompletion=self.completion_provider_gateway.complete_shell_names,
),
) -> None:
"""
输出指定 shell 的 completion 脚本。
:param shell: shell 名称
:return: None
"""
controller.show(shell)
@app.command(registrations['install'].name, help=registrations['install'].help_text)
def install(
shell: str | None = typer.Option(
None,
'--shell',
help='shell 名称,默认自动识别当前 shell',
autocompletion=self.completion_provider_gateway.complete_shell_names,
),
output: OutputOption = 'text',
target_file: Path | None = typer.Option(None, '--target-file', help='completion 脚本目标文件路径'),
activate: bool = typer.Option(False, '--activate', help='将激活命令写入 shell rc 文件'),
rc_file: Path | None = typer.Option(None, '--rc-file', help='自定义 shell rc 文件路径'),
force: bool = typer.Option(False, '--force', help='覆盖已存在且内容不同的目标文件'),
) -> None:
"""
安装指定 shell 的 completion 脚本。
:param shell: shell 名称,默认自动识别当前 shell
:param output: 输出格式
:param target_file: completion 脚本目标文件路径
:param activate: 是否写入 shell rc 文件激活命令
:param rc_file: 自定义 shell rc 文件路径
:param force: 是否强制覆盖已存在文件
:return: None
"""
controller.install(
output,
shell=shell,
target_file=target_file,
activate=activate,
rc_file=rc_file,
force=force,
)
@app.command(registrations['doctor'].name, help=registrations['doctor'].help_text)
def doctor(
output: OutputOption = 'text',
) -> None:
"""
检查当前 completion 配置状态。
:param output: 输出格式
:return: None
"""
controller.doctor(output)
@dataclass
class CompletionCommandBuilder:
"""
completion 子应用构建器。
该构建器负责装配 `completion` 子应用,并将控制器实例和子命令注册
细节收口到类式对象协作中。
:param completion_subcommand_registrar: completion 子命令注册器
"""
completion_subcommand_registrar: CompletionSubcommandRegistrar = field(
default_factory=CompletionSubcommandRegistrar
)
def build(self, root_cli: typer.Typer) -> typer.Typer:
"""
构建 completion 命令组。
:param root_cli: 根 Typer 应用
:return: completion 子应用
"""
app = typer.Typer(
help='shell completion 相关命令',
no_args_is_help=True,
context_settings={'help_option_names': ['-h', '--help']},
)
completion_command_controller = CompletionCommandController(root_cli)
self.completion_subcommand_registrar.register(app, controller=completion_command_controller)
return app
COMPLETION_COMMAND_BUILDER = CompletionCommandBuilder()
@@ -1,116 +0,0 @@
from pathlib import Path
import typer
from cli.completion.doctor import COMPLETION_DOCTOR, CompletionDoctorService
from cli.completion.installers import COMPLETION_INSTALLER, CompletionInstallerService
from cli.completion.presenter import CompletionCommandPresenter
from cli.core import (
DEFAULT_CORE_SERVICES,
CliContextFactory,
CliExecutionService,
)
class CompletionCommandController:
"""
completion 命令控制器。
该控制器负责组织 `completion` 命令组的上下文准备、payload 生成、
文本渲染与脚本输出收口。
:param root_cli: 根 Typer 应用
:param context_factory: CLI 上下文工厂
:param execution_service: CLI 执行服务
:param presenter: completion 命令文本渲染器
:param installer_service: completion 安装与脚本生成服务
:param doctor_service: completion 诊断服务
"""
def __init__(
self,
root_cli: typer.Typer,
*,
context_factory: CliContextFactory | None = None,
execution_service: CliExecutionService | None = None,
presenter: CompletionCommandPresenter | None = None,
installer_service: CompletionInstallerService | None = None,
doctor_service: CompletionDoctorService | None = None,
) -> None:
"""
初始化 completion 命令控制器。
:param root_cli: 根 Typer 应用
:param context_factory: CLI 上下文工厂
:param execution_service: CLI 执行服务
:param presenter: completion 命令文本渲染器
:param installer_service: completion 安装与脚本生成服务
:param doctor_service: completion 诊断服务
:return: None
"""
self.root_cli = root_cli
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
self.presenter = presenter or CompletionCommandPresenter()
self.installer_service = installer_service or COMPLETION_INSTALLER
self.doctor_service = doctor_service or COMPLETION_DOCTOR
def show(self, shell: str) -> None:
"""
输出指定 shell 的 completion 脚本。
:param shell: shell 名称
:return: None
"""
typer.echo(self.installer_service.render_completion_script(self.root_cli, shell), nl=False)
def install(
self,
output: str,
*,
shell: str | None,
target_file: Path | None,
activate: bool,
rc_file: Path | None,
force: bool,
) -> None:
"""
安装指定 shell 的 completion 脚本。
:param output: 输出格式
:param shell: shell 名称
:param target_file: completion 脚本目标文件路径
:param activate: 是否写入 shell rc 文件激活命令
:param rc_file: 自定义 shell rc 文件路径
:param force: 是否强制覆盖已存在文件
:return: None
"""
ctx = self.context_factory.build_readonly('dev', output)
payload = self.installer_service.install_completion_script(
self.root_cli,
shell,
target_file=target_file,
activate=activate,
rc_file=rc_file,
force=force,
)
self.execution_service.complete_payload_result(
ctx,
payload,
text_builder=self.presenter.build_completion_install_text,
)
def doctor(self, output: str) -> None:
"""
检查当前 completion 配置状态。
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly('dev', output)
payload = self.doctor_service.build_completion_doctor_payload()
self.execution_service.complete_payload_result(
ctx,
payload,
text_builder=self.presenter.build_completion_doctor_text,
)
@@ -1,95 +0,0 @@
from pathlib import Path
from typing import Any
from cli.completion.installers import (
CLICK_COMPLETE_ENV_VAR,
COMPLETION_INSTALLER,
CompletionInstallerService,
)
from cli.metadata import (
COMPLETION_SHELL_SPEC_REGISTRY,
ENVIRONMENT_OPTION_SERVICE,
CompletionShellSpecRegistry,
EnvironmentOptionService,
)
class CompletionDoctorService:
"""
completion 诊断服务。
该服务负责汇总活跃 shell、目标脚本路径、rc 文件状态、推荐安装命令
与环境候选列表,生成 `completion doctor` 所需的结构化结果。
:param installer_service: completion 安装服务
:param shell_spec_registry: shell 元数据注册表
:param environment_option_service: 环境选项服务
"""
def __init__(
self,
*,
installer_service: CompletionInstallerService | None = None,
shell_spec_registry: CompletionShellSpecRegistry | None = None,
environment_option_service: EnvironmentOptionService | None = None,
) -> None:
"""
初始化 completion 诊断服务。
:param installer_service: completion 安装服务
:param shell_spec_registry: shell 元数据注册表
:param environment_option_service: 环境选项服务
:return: None
"""
self.installer_service = installer_service or COMPLETION_INSTALLER
self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY
self.environment_option_service = environment_option_service or ENVIRONMENT_OPTION_SERVICE
def build_completion_doctor_payload(self) -> dict[str, Any]:
"""
构建 completion 诊断结果。
:return: 诊断结果字典
"""
active_shell = self.installer_service.detect_active_shell()
shells: dict[str, dict[str, Any]] = {}
for shell_name, shell_spec in self.shell_spec_registry.specs.items():
target_file = self.installer_service.resolve_completion_target(shell_name)
rc_file = self.installer_service.resolve_completion_rc_file(shell_name)
source_command = None
if shell_spec.supported and shell_spec.generator in {'click', 'custom'}:
source_command = self.installer_service.build_source_command(target_file, shell_name)
recommended_install_command = f'shierfeng completion install --shell={shell_name}'
if shell_spec.supported and not shell_spec.auto_discovery:
recommended_install_command = f'{recommended_install_command} --activate'
shells[shell_name] = {
'supported': shell_spec.supported,
'detected': shell_name == active_shell,
'description': shell_spec.description,
'targetFile': str(target_file),
'targetFileExists': target_file.exists(),
'rcFile': str(rc_file) if rc_file is not None else None,
'rcFileExists': rc_file.exists() if rc_file is not None else None,
'autoDiscovery': shell_spec.auto_discovery,
'sourceCommand': source_command,
'recommendedInstallCommand': recommended_install_command,
}
recommended_shell = active_shell if self.shell_spec_registry.get_spec(active_shell) is not None else None
recommended_install_command = None
if recommended_shell is not None:
recommended_install_command = shells[recommended_shell]['recommendedInstallCommand']
return {
'ok': True,
'message': 'completion 诊断信息已生成',
'activeShell': active_shell or None,
'projectDir': str(Path.cwd().resolve()),
'envChoices': self.environment_option_service.discover_env_names(),
'completeEnvVar': CLICK_COMPLETE_ENV_VAR,
'recommendedInstallCommand': recommended_install_command,
'shells': shells,
}
COMPLETION_DOCTOR = CompletionDoctorService()
@@ -1,465 +0,0 @@
import io
import os
from contextlib import redirect_stderr
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import click
import typer
import typer.main
from click.shell_completion import BashComplete, FishComplete, ZshComplete
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered
from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR
from cli.metadata import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry
CLICK_COMPLETE_ENV_VAR = '_SHIERFENG_COMPLETE'
class CompletionInstallerShellSupport:
"""
completion shell 差异支持对象。
该对象负责封装不同 shell 在脚本兼容处理和 source 命令构建上的差异,
供安装服务通过轻量 strategy 协作对象统一复用。
"""
@staticmethod
def keep_script_text(script_text: str) -> str:
"""
保持脚本文本原样返回。
:param script_text: 原始脚本文本
:return: 原样脚本文本
"""
return script_text
@staticmethod
def make_bash_completion_script_compatible(script_text: str) -> str:
"""
对 Click 生成的 Bash completion 脚本做旧版本兼容处理。
兼容目标主要包括 macOS 默认 Bash 3.2:
- `complete -o nosort` 在旧 Bash 上会在 `source` 阶段直接报错
- `compopt` 在旧 Bash 上可能不存在
:param script_text: Click 原始 Bash completion 脚本文本
:return: 兼容处理后的脚本文本
"""
compatible_lines: list[str] = []
for line in script_text.splitlines():
stripped_line = line.strip()
if stripped_line == 'compopt -o dirnames':
indent = line[: len(line) - len(line.lstrip(' '))]
compatible_lines.extend(
[
f'{indent}if command -v compopt >/dev/null 2>&1; then',
f'{indent} compopt -o dirnames',
f'{indent}fi',
]
)
continue
if stripped_line == 'compopt -o default':
indent = line[: len(line) - len(line.lstrip(' '))]
compatible_lines.extend(
[
f'{indent}if command -v compopt >/dev/null 2>&1; then',
f'{indent} compopt -o default',
f'{indent}fi',
]
)
continue
if stripped_line.startswith('complete -o nosort -F '):
compatibility_line = stripped_line.replace('complete -o nosort -F ', '', 1)
function_name, command_name = compatibility_line.split(' ', 1)
compatible_lines.extend(
[
f'if complete -o nosort -F {function_name} {command_name} 2>/dev/null; then',
' :',
'else',
f' complete -F {function_name} {command_name}',
'fi',
]
)
continue
compatible_lines.append(line)
return '\n'.join(compatible_lines) + '\n'
@staticmethod
def build_posix_source_command(target_file: Path) -> str:
"""
构建 POSIX shell 使用的 source 命令。
:param target_file: completion 脚本文件路径
:return: source 命令
"""
return f'source {target_file}'
@staticmethod
def build_fish_source_command(target_file: Path) -> str:
"""
构建 Fish shell 使用的 source 命令。
:param target_file: completion 脚本文件路径
:return: source 命令
"""
return f'status --is-interactive; and source {target_file}'
@staticmethod
def build_powershell_source_command(target_file: Path) -> str:
"""
构建 PowerShell 使用的 source 命令。
:param target_file: completion 脚本文件路径
:return: source 命令
"""
return f'. "{target_file}"'
@dataclass(frozen=True)
class CompletionShellRuntimePolicy:
"""
completion shell 运行时策略定义。
:param name: shell 名称
:param click_completion_class: Click completion 生成器类型
:param script_transformer: 脚本文本后处理函数
:param source_command_builder: 激活命令构建函数
"""
name: str
click_completion_class: type[Any]
script_transformer: Any
source_command_builder: Any
@dataclass(frozen=True)
class CompletionShellRuntimePolicyRegistry:
"""
completion shell 运行时策略注册表。
:param policies: 按 shell 名称索引的运行时策略映射
"""
policies: dict[str, CompletionShellRuntimePolicy]
def get(self, shell_name: str) -> CompletionShellRuntimePolicy | None:
"""
获取指定 shell 的运行时策略。
:param shell_name: shell 名称
:return: 运行时策略,不存在时返回 None
"""
return self.policies.get(shell_name)
DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES = CompletionShellRuntimePolicyRegistry(
policies={
'bash': CompletionShellRuntimePolicy(
name='bash',
click_completion_class=BashComplete,
script_transformer=CompletionInstallerShellSupport.make_bash_completion_script_compatible,
source_command_builder=CompletionInstallerShellSupport.build_posix_source_command,
),
'zsh': CompletionShellRuntimePolicy(
name='zsh',
click_completion_class=ZshComplete,
script_transformer=CompletionInstallerShellSupport.keep_script_text,
source_command_builder=CompletionInstallerShellSupport.build_posix_source_command,
),
'fish': CompletionShellRuntimePolicy(
name='fish',
click_completion_class=FishComplete,
script_transformer=CompletionInstallerShellSupport.keep_script_text,
source_command_builder=CompletionInstallerShellSupport.build_fish_source_command,
),
'powershell': CompletionShellRuntimePolicy(
name='powershell',
click_completion_class=PowerShellComplete,
script_transformer=CompletionInstallerShellSupport.keep_script_text,
source_command_builder=CompletionInstallerShellSupport.build_powershell_source_command,
),
}
)
class CompletionInstallerService:
"""
completion 安装与脚本生成服务。
该服务负责 shell 元数据解析、脚本生成、目标路径解析、激活命令构建、
rc 文件写入以及安装结果收口。
:param completion_provider_gateway: completion 提供器对外网关
:param shell_spec_registry: shell 元数据注册表
:param shell_runtime_policy_registry: shell 运行时策略注册表
"""
def __init__(
self,
*,
completion_provider_gateway: CompletionProviderGateway | None = None,
shell_spec_registry: CompletionShellSpecRegistry | None = None,
shell_runtime_policy_registry: CompletionShellRuntimePolicyRegistry | None = None,
) -> None:
"""
初始化 completion 安装服务。
:param completion_provider_gateway: completion 提供器对外网关
:param shell_spec_registry: shell 元数据注册表
:param shell_runtime_policy_registry: shell 运行时策略注册表
:return: None
"""
self.completion_provider_gateway = completion_provider_gateway or COMPLETION_PROVIDER_GATEWAY
self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY
self.shell_runtime_policy_registry = shell_runtime_policy_registry or DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES
ensure_custom_completion_classes_registered()
def resolve_completion_shell_spec(self, shell: str) -> CompletionShellSpec:
"""
获取指定 shell 的 completion 元数据。
:param shell: shell 名称
:return: shell 元数据
:raises typer.BadParameter: shell 不存在时抛出异常
"""
normalized_shell = shell.strip().lower()
shell_spec = self.shell_spec_registry.get_spec(normalized_shell)
if shell_spec is None:
supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells())
raise typer.BadParameter(f'不支持的 shell:{shell},可选值为 {supported_shells}')
return shell_spec
def resolve_shell_runtime_policy(self, shell: str) -> CompletionShellRuntimePolicy:
"""
获取指定 shell 的运行时策略。
:param shell: shell 名称
:return: shell 运行时策略
:raises typer.BadParameter: 当前 shell 未实现运行时策略时抛出异常
"""
shell_spec = self.resolve_completion_shell_spec(shell)
runtime_policy = self.shell_runtime_policy_registry.get(shell_spec.name)
if runtime_policy is None:
raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现')
return runtime_policy
@staticmethod
def build_completion_click_command(root_cli: typer.Typer) -> click.Command:
"""
将 Typer 根应用转换为 Click 命令对象,供 shell completion 生成使用。
:param root_cli: Typer 根应用
:return: Click 命令对象
"""
return typer.main.get_command(root_cli)
def render_completion_script(self, root_cli: typer.Typer, shell: str) -> str:
"""
生成指定 shell 的 completion 脚本文本。
:param root_cli: Typer 根应用
:param shell: shell 名称
:return: completion 脚本文本
:raises typer.BadParameter: 当前 shell 未实现脚本生成时抛出异常
"""
shell_spec = self.resolve_completion_shell_spec(shell)
if not shell_spec.supported or shell_spec.generator not in {'click', 'custom'}:
raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现')
runtime_policy = self.resolve_shell_runtime_policy(shell_spec.name)
click_command = self.build_completion_click_command(root_cli)
stderr_buffer = io.StringIO()
with redirect_stderr(stderr_buffer):
script_text = runtime_policy.click_completion_class(
click_command,
{},
'shierfeng',
CLICK_COMPLETE_ENV_VAR,
).source()
return runtime_policy.script_transformer(script_text)
def resolve_completion_target(self, shell: str, target_file: Path | None = None) -> Path:
"""
解析 completion 脚本目标文件路径。
:param shell: shell 名称
:param target_file: 用户显式指定的目标文件
:return: 目标文件绝对路径
"""
if target_file is not None:
return target_file.expanduser().resolve()
shell_spec = self.resolve_completion_shell_spec(shell)
return (Path.home() / shell_spec.default_target).expanduser().resolve()
def resolve_completion_rc_file(self, shell: str, rc_file: Path | None = None) -> Path | None:
"""
解析 completion 激活所使用的 rc 文件路径。
:param shell: shell 名称
:param rc_file: 用户显式指定的 rc 文件
:return: rc 文件绝对路径,若当前 shell 无 rc 文件则返回 None
"""
if rc_file is not None:
return rc_file.expanduser().resolve()
shell_spec = self.resolve_completion_shell_spec(shell)
if shell_spec.default_rc_file is None:
return None
return (Path.home() / shell_spec.default_rc_file).expanduser().resolve()
def build_source_command(self, target_file: Path, shell: str) -> str:
"""
构建当前 shell 对应的激活命令。
:param target_file: completion 脚本文件路径
:param shell: shell 名称
:return: 激活命令文本
"""
runtime_policy = self.resolve_shell_runtime_policy(shell)
return runtime_policy.source_command_builder(target_file)
@staticmethod
def detect_active_shell() -> str:
"""
检测当前进程环境下的活跃 shell 名称。
:return: shell 名称,未知时返回空字符串
"""
shell_path = os.environ.get('SHELL', '').strip()
if not shell_path:
return ''
return Path(shell_path).name.lower()
def resolve_install_shell(self, shell: str | None) -> str:
"""
解析安装命令实际使用的 shell。
:param shell: 用户显式指定的 shell,允许为空
:return: 实际使用的 shell 名称
:raises typer.BadParameter: 无法推断或不支持时抛出异常
"""
if shell and shell.strip():
return self.resolve_completion_shell_spec(shell).name
active_shell = self.detect_active_shell()
if not active_shell:
raise typer.BadParameter('未检测到当前 shell,请显式传入 --shell')
shell_spec = self.shell_spec_registry.get_spec(active_shell)
if shell_spec is None:
supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells())
raise typer.BadParameter(
f'当前 shell `{active_shell}` 不在支持列表中,请显式传入 --shell,可选值为 {supported_shells}'
)
return shell_spec.name
@staticmethod
def append_activation_line(rc_file: Path, source_command: str) -> bool:
"""
将激活命令追加到 rc 文件,若已存在则不重复写入。
:param rc_file: rc 文件路径
:param source_command: 激活命令文本
:return: 本次是否发生写入
"""
existing_text = ''
if rc_file.exists():
existing_text = rc_file.read_text(encoding='utf-8')
if source_command in existing_text:
return False
rc_file.parent.mkdir(parents=True, exist_ok=True)
with rc_file.open('a', encoding='utf-8') as file_object:
if existing_text and not existing_text.endswith('\n'):
file_object.write('\n')
file_object.write(f'{source_command}\n')
return True
def install_completion_script(
self,
root_cli: typer.Typer,
shell: str | None,
*,
target_file: Path | None = None,
activate: bool = False,
rc_file: Path | None = None,
force: bool = False,
) -> dict[str, Any]:
"""
安装指定 shell 的 completion 脚本文件。
:param root_cli: Typer 根应用
:param shell: shell 名称
:param target_file: completion 脚本目标文件
:param activate: 是否写入 rc 文件激活命令
:param rc_file: 自定义 rc 文件路径
:param force: 目标文件已存在时是否强制覆盖
:return: 安装结果字典
"""
resolved_shell = self.resolve_install_shell(shell)
shell_spec = self.resolve_completion_shell_spec(resolved_shell)
if not shell_spec.supported:
return {
'ok': False,
'message': f'{shell_spec.name} completion 当前版本未实现',
'shell': shell_spec.name,
'exit_code': ARGUMENT_ERROR,
}
resolved_target_file = self.resolve_completion_target(shell_spec.name, target_file)
script_text = self.render_completion_script(root_cli, shell_spec.name)
existing_text = resolved_target_file.read_text(encoding='utf-8') if resolved_target_file.exists() else None
if existing_text is not None and existing_text != script_text and not force:
return {
'ok': False,
'message': '目标文件已存在且内容不同,请传入 --force 覆盖',
'shell': shell_spec.name,
'targetFile': str(resolved_target_file),
'exit_code': RUNTIME_ERROR,
}
resolved_target_file.parent.mkdir(parents=True, exist_ok=True)
resolved_target_file.write_text(script_text, encoding='utf-8')
source_command = self.build_source_command(resolved_target_file, shell_spec.name)
activated = shell_spec.auto_discovery
rc_file_path = self.resolve_completion_rc_file(shell_spec.name, rc_file)
rc_file_updated = False
activation_required = not shell_spec.auto_discovery
detected_shell = self.detect_active_shell() or None
if activate and rc_file_path is not None:
rc_file_updated = self.append_activation_line(rc_file_path, source_command)
activated = True
next_step = '当前 shell 会自动发现 completion 脚本,无需额外 source 命令'
if activation_required and not activate:
next_step = f'请执行 `{source_command}`,或重新运行并传入 --activate'
elif activation_required and activate:
next_step = '请重启当前 shell,或手动执行 rc 文件中的 source 命令使其立即生效'
return {
'ok': True,
'message': 'completion 脚本已安装',
'shell': shell_spec.name,
'detectedShell': detected_shell,
'targetFile': str(resolved_target_file),
'activated': activated,
'activateRequested': activate,
'rcFile': str(rc_file_path) if rc_file_path is not None else None,
'rcFileUpdated': rc_file_updated,
'sourceCommand': source_command,
'autoDiscovery': shell_spec.auto_discovery,
'activationRequired': activation_required,
'nextStep': next_step,
'completeEnvVar': CLICK_COMPLETE_ENV_VAR,
}
COMPLETION_INSTALLER = CompletionInstallerService()
@@ -1,83 +0,0 @@
from typing import Any
class CompletionCommandPresenter:
"""
completion 命令文本渲染器。
该渲染器负责将 `completion` 命令组产生的结构化 payload 转换为稳定的文本摘要,
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
"""
def build_completion_doctor_text(self, payload: dict[str, Any]) -> str:
"""
将 completion 诊断结果渲染为文本摘要。
:param payload: completion 诊断结果字典
:return: 文本摘要
"""
lines = [
f'ok: {str(payload.get("ok", False)).lower()}',
f'message: {payload.get("message", "-")}',
f'active_shell: {payload.get("activeShell") or "-"}',
f'project_dir: {payload.get("projectDir", "-")}',
f'complete_env_var: {payload.get("completeEnvVar", "-")}',
]
if payload.get('recommendedInstallCommand'):
lines.append(f'recommended_install_command: {payload.get("recommendedInstallCommand")}')
env_choices = payload.get('envChoices')
if isinstance(env_choices, list) and env_choices:
lines.append('env_choices:')
lines.extend(f' - {env_name}' for env_name in env_choices)
shells = payload.get('shells')
if isinstance(shells, dict) and shells:
lines.append('shells:')
for shell_name, shell_payload in shells.items():
if not isinstance(shell_payload, dict):
continue
lines.extend(
[
f' {shell_name}:',
f' supported: {str(shell_payload.get("supported", False)).lower()}',
f' detected: {str(shell_payload.get("detected", False)).lower()}',
f' target_file: {shell_payload.get("targetFile", "-")}',
f' target_file_exists: {str(shell_payload.get("targetFileExists", False)).lower()}',
f' rc_file: {shell_payload.get("rcFile", "-") or "-"}',
f' rc_file_exists: {str(shell_payload.get("rcFileExists", False)).lower()}',
f' auto_discovery: {str(shell_payload.get("autoDiscovery", False)).lower()}',
f' source_command: {shell_payload.get("sourceCommand", "-")}',
f' recommended_install_command: {shell_payload.get("recommendedInstallCommand", "-")}',
]
)
return '\n'.join(lines)
@staticmethod
def build_completion_install_text(payload: dict[str, Any]) -> str:
"""
将 completion 安装结果渲染为文本摘要。
:param payload: completion 安装结果字典
:return: 文本摘要
"""
lines = [
f'ok: {str(payload.get("ok", False)).lower()}',
f'message: {payload.get("message", "-")}',
f'shell: {payload.get("shell", "-")}',
]
field_label_mapping = {
'detectedShell': 'detected_shell',
'targetFile': 'target_file',
'activated': 'activated',
'activateRequested': 'activate_requested',
'rcFile': 'rc_file',
'rcFileUpdated': 'rc_file_updated',
'sourceCommand': 'source_command',
'autoDiscovery': 'auto_discovery',
'activationRequired': 'activation_required',
'nextStep': 'next_step',
'completeEnvVar': 'complete_env_var',
}
for field_name, field_label in field_label_mapping.items():
if field_name in payload:
lines.append(f'{field_label}: {payload.get(field_name)}')
return '\n'.join(lines)
File diff suppressed because it is too large Load Diff
@@ -1,113 +0,0 @@
import os
from click.shell_completion import (
CompletionItem,
ShellComplete,
add_completion_class,
get_completion_class,
split_arg_string,
)
_SOURCE_POWERSHELL = """\
$%(complete_func)s = {
param($wordToComplete, $commandAst, $cursorPosition)
$previousCompWords = $env:COMP_WORDS
$previousCompCword = $env:COMP_CWORD
$previousCompleteInstruction = $env:%(complete_var)s
$commandLine = $commandAst.ToString()
if ($cursorPosition -lt $commandLine.Length) {
$commandLine = $commandLine.Substring(0, $cursorPosition)
}
$env:COMP_WORDS = $commandLine
$env:COMP_CWORD = $wordToComplete
$env:%(complete_var)s = "powershell_complete"
try {
%(prog_name)s | ForEach-Object {
$line = $_.ToString()
if ([string]::IsNullOrWhiteSpace($line)) {
return
}
$parts = $line -split "`t", 3
$completionValue = if ($parts.Length -ge 2) { $parts[1] } else { "" }
$completionHelp = if ($parts.Length -ge 3 -and $parts[2]) { $parts[2] } else { $completionValue }
[System.Management.Automation.CompletionResult]::new(
$completionValue,
$completionValue,
[System.Management.Automation.CompletionResultType]::ParameterValue,
$completionHelp
)
}
} finally {
if ($null -ne $previousCompWords) {
$env:COMP_WORDS = $previousCompWords
} else {
Remove-Item Env:\\COMP_WORDS -ErrorAction SilentlyContinue
}
if ($null -ne $previousCompCword) {
$env:COMP_CWORD = $previousCompCword
} else {
Remove-Item Env:\\COMP_CWORD -ErrorAction SilentlyContinue
}
if ($null -ne $previousCompleteInstruction) {
$env:%(complete_var)s = $previousCompleteInstruction
} else {
Remove-Item Env:\\%(complete_var)s -ErrorAction SilentlyContinue
}
}
}
Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $%(complete_func)s
"""
class PowerShellComplete(ShellComplete):
"""
PowerShell shell completion 支持。
基于 PowerShell `Register-ArgumentCompleter -Native` 协议,将当前
命令行和光标位置传回 Click completion 分发器,再把返回的候选项转换为
`CompletionResult` 对象。
"""
name = 'powershell'
source_template = _SOURCE_POWERSHELL
def get_completion_args(self) -> tuple[list[str], str]:
"""
从 PowerShell 注入的环境变量中恢复 CLI 上下文。
:return: 已解析的完整参数与当前不完整输入
"""
cwords = split_arg_string(os.environ.get('COMP_WORDS', ''))
incomplete = os.environ.get('COMP_CWORD', '')
if incomplete:
incomplete_parts = split_arg_string(incomplete)
incomplete = incomplete_parts[0] if incomplete_parts else incomplete
args = cwords[1:]
if incomplete and args and args[-1] == incomplete:
args.pop()
return args, incomplete
def format_completion(self, item: CompletionItem) -> str:
"""
将候选项格式化为 PowerShell 脚本可解析的文本行。
:param item: Click completion 候选项
:return: 格式化后的文本
"""
help_text = item.help or item.value
return f'{item.type}\t{item.value}\t{help_text}'
def ensure_custom_completion_classes_registered() -> None:
"""
确保自定义 shell completion 类已注册到 Click。
:return: None
"""
if get_completion_class(PowerShellComplete.name) is None:
add_completion_class(PowerShellComplete)
@@ -1,74 +0,0 @@
from dataclasses import dataclass
from typing import Annotated, Literal
import typer
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
@dataclass
class CliContext:
"""
CLI 运行上下文。
env: 当前命令使用的环境名称
output: 结果输出格式
operator: 操作者标识,后续用于审计
allow_prod: 是否显式允许生产环境执行危险命令
yes: 是否跳过二次确认
dry_run: 是否执行演练模式
"""
env: str = 'dev'
output: Literal['text', 'json'] = 'text'
operator: str | None = None
allow_prod: bool = False
yes: bool = False
dry_run: bool = False
class CliContextBuilder:
"""
CLI 上下文构建器。
该对象负责将命令层显式参数收口为统一的 `CliContext` 数据对象,
供上下文工厂与测试场景复用。
"""
@staticmethod
def build(
env: str,
output: Literal['text', 'json'],
allow_prod: bool,
yes: bool,
dry_run: bool,
) -> CliContext:
"""
构建命令执行上下文。
:param env: 当前命令环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过二次确认
:param dry_run: 是否执行演练模式
:return: 构建完成的 CLI 上下文
"""
return CliContext(
env=env,
output=output,
allow_prod=allow_prod,
yes=yes,
dry_run=dry_run,
)
CLI_CONTEXT_BUILDER = CliContextBuilder()
EnvOption = Annotated[
str,
typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values),
]
OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')]
AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')]
YesOption = Annotated[bool, typer.Option('--yes', help='跳过危险命令确认')]
DryRunOption = Annotated[bool, typer.Option('--dry-run', help='仅执行预演,不落地实际变更')]
@@ -1,51 +0,0 @@
from dataclasses import dataclass, field
from cli.core.app_builder import CliApplicationBuilder, ProjectRuntimeLocator
from cli.core.completion_dispatcher import CompletionDispatcher
from cli.core.context_factory import CliContextFactory, CliRuntimeState
from cli.core.execution import CliExecutionService
from cli.output import OutputRenderer
@dataclass
class CoreServiceContainer:
"""
CLI 核心服务容器。
该容器负责统一装配输出渲染器、运行期状态、上下文工厂与执行服务,
作为控制器、向导和 TUI 等编排层的默认依赖入口。
:param output_renderer: 输出渲染器
:param runtime_state: CLI 运行期状态对象
"""
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState)
context_factory: CliContextFactory = field(init=False)
execution_service: CliExecutionService = field(init=False)
def __post_init__(self) -> None:
"""
初始化核心服务依赖图。
:return: None
"""
self.context_factory = CliContextFactory(
runtime_state=self.runtime_state,
output_renderer=self.output_renderer,
)
self.execution_service = CliExecutionService(output_renderer=self.output_renderer)
DEFAULT_CORE_SERVICES = CoreServiceContainer()
__all__ = [
'DEFAULT_CORE_SERVICES',
'CliApplicationBuilder',
'CliContextFactory',
'CliExecutionService',
'CliRuntimeState',
'CompletionDispatcher',
'CoreServiceContainer',
'ProjectRuntimeLocator',
]
@@ -1,363 +0,0 @@
import importlib
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Annotated, Literal
import typer
from cli.output import OutputRenderer
from cli.runtime.base import RUNTIME_ENVIRONMENT
@dataclass(frozen=True)
class ProjectRuntimeLocator:
"""
CLI 项目运行时定位器。
该对象负责判断当前目录是否为后端项目根目录、注入 `sys.path`,
并为配置导入阶段提取最小化的 `argv` 参数。
"""
def is_backend_project_dir(self, project_dir: Path) -> bool:
"""
判断给定目录是否为后端项目根目录。
:param project_dir: 待检查目录
:return: 是否为后端项目根目录
"""
return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir)
def ensure_backend_dir_on_sys_path(self) -> Path:
"""
将当前后端项目根目录加入 `sys.path` 首位。
:return: 当前后端项目根目录
:raises typer.Exit: 当前目录不是后端项目根目录时退出命令
"""
project_dir = Path.cwd().resolve()
if not self.is_backend_project_dir(project_dir):
typer.echo('`shierfeng` 命令需在 `shierfeng-fastapi-backend` 目录下执行', err=True)
raise typer.Exit(code=2)
project_dir_str = str(project_dir)
if sys.path and sys.path[0] == project_dir_str:
return project_dir
sys.path.insert(0, project_dir_str)
return project_dir
def extract_import_argv(self, argv: list[str] | None = None) -> list[str]:
"""
提取供配置模块导入阶段使用的最小参数集。
:param argv: 原始命令行参数列表
:return: 仅保留程序名和 `--env` 的参数列表
"""
current_argv = argv or sys.argv
program_name = current_argv[0] if current_argv else 'shierfeng'
import_argv = [program_name]
env_value = ''
current_args = current_argv[1:]
for index, argument in enumerate(current_args):
if argument.startswith('--env='):
env_value = argument.split('=', 1)[1].strip()
break
if argument == '--env' and index + 1 < len(current_args):
env_value = current_args[index + 1].strip()
break
if env_value:
import_argv.extend(['--env', env_value])
return import_argv
@dataclass(frozen=True)
class CliCommandGroupRegistry:
"""
CLI 命令组注册表。
该注册表集中维护根 CLI 需要挂载的业务命令组模块路径,
避免命令组声明散落在应用构建器主体中。
:param command_modules: 命令组到模块路径的映射
"""
command_modules: dict[str, str]
@dataclass(frozen=True)
class CliExtensionRegistration:
"""
CLI 扩展入口注册描述。
:param module_path: 扩展模块导入路径
:param attach: 已加载模块挂载到根 CLI 的函数
"""
module_path: str
attach: Callable[[typer.Typer, object], None]
@dataclass(frozen=True)
class CliExtensionRegistry:
"""
CLI 扩展入口注册表。
该注册表集中维护 completion、wizard、TUI 等扩展入口的模块路径与
挂载方式,避免这些规则继续散落在注册器主体中。
:param registrations: 扩展注册描述列表
"""
registrations: tuple[CliExtensionRegistration, ...]
@dataclass(frozen=True)
class CliCommandGroupRegistrar:
"""
CLI 命令组注册器。
该对象负责根据命令组注册表向根 Typer 应用挂载业务命令组。
:param command_group_registry: CLI 命令组注册表
"""
command_group_registry: CliCommandGroupRegistry
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
def register(self, cli: typer.Typer) -> None:
"""
向根应用注册业务命令组。
:param cli: 根 Typer 应用
:return: None
"""
for command_name, module_path in self.command_group_registry.command_modules.items():
command_module = self.module_loader.load(module_path)
cli.add_typer(command_module.app, name=command_name)
class CliExtensionMountSupport:
"""
CLI 扩展入口挂载支持对象。
该对象负责封装不同扩展模块挂载到根 CLI 的差异,避免扩展注册器本体
继续堆积 `if/else` 或硬编码模块处理逻辑。
"""
@staticmethod
def attach_completion(cli: typer.Typer, module: object) -> None:
"""
挂载 completion 扩展入口。
:param cli: 根 Typer 应用
:param module: 已加载模块
:return: None
"""
cli.add_typer(module.COMPLETION_COMMAND_BUILDER.build(cli), name='completion')
@staticmethod
def attach_wizard(cli: typer.Typer, module: object) -> None:
"""
挂载 wizard 扩展入口。
:param cli: 根 Typer 应用
:param module: 已加载模块
:return: None
"""
cli.add_typer(module.WIZARD_COMMAND_BUILDER.build(), name='wizard')
@staticmethod
def attach_tui(cli: typer.Typer, module: object) -> None:
"""
挂载 TUI 扩展入口。
:param cli: 根 Typer 应用
:param module: 已加载模块
:return: None
"""
module.TUI_COMMAND_REGISTRATION.register(cli)
@dataclass(frozen=True)
class CliExtensionRegistrar:
"""
CLI 扩展入口注册器。
该对象负责向根 Typer 应用挂载 completion、wizard 与 TUI
等扩展入口。
"""
extension_registry: CliExtensionRegistry
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
def register(self, cli: typer.Typer) -> None:
"""
向根应用注册扩展入口。
:param cli: 根 Typer 应用
:return: None
"""
for registration in self.extension_registry.registrations:
extension_module = self.module_loader.load(registration.module_path)
registration.attach(cli, extension_module)
@dataclass(frozen=True)
class CliRootOptionInitializer:
"""
CLI 根级选项初始化器。
该对象负责将根级 `--color`、`--icon` 选项映射到共享输出渲染器,
避免根回调继续内联输出配置逻辑。
:param output_renderer: 根命令共享的输出渲染器
"""
output_renderer: OutputRenderer
def initialize(self, *, color: str, icon: str) -> None:
"""
初始化根级输出选项。
:param color: 文本输出颜色模式
:param icon: 文本输出图标模式
:return: None
"""
self.output_renderer.set_color_mode(color)
self.output_renderer.set_icon_mode(icon)
@dataclass(frozen=True)
class CliRootCallbackRegistrar:
"""
CLI 根回调注册器。
该对象负责把根级 `--color`、`--icon` 选项回调挂载到 Typer 根应用,
让 `CliApplicationBuilder` 更专注于整体装配流程。
:param root_option_initializer: 根级选项初始化器
"""
root_option_initializer: CliRootOptionInitializer
def register(self, cli: typer.Typer) -> None:
"""
向根应用注册回调。
:param cli: 根 Typer 应用
:return: None
"""
@cli.callback()
def root_callback(
color: Annotated[
Literal['auto', 'always', 'never'],
typer.Option('--color', help='文本输出颜色模式'),
] = 'always',
icon: Annotated[
Literal['emoji', 'ascii', 'none'],
typer.Option('--icon', help='文本输出图标模式'),
] = 'emoji',
) -> None:
"""
初始化 CLI 根级运行参数。
:param color: 文本输出颜色模式
:param icon: 文本输出图标模式
:return: None
"""
self.root_option_initializer.initialize(color=color, icon=icon)
@dataclass(frozen=True)
class CliModuleLoader:
"""
CLI 模块加载服务。
该对象负责集中执行命令组与扩展入口所需的延迟导入,
让注册器本身更专注于挂载动作。
"""
@staticmethod
def load(module_path: str) -> object:
"""
按模块路径加载模块对象。
:param module_path: 模块导入路径
:return: 已加载模块
"""
return importlib.import_module(module_path)
@dataclass
class CliApplicationBuilder:
"""
CLI 根应用构建器。
:param output_renderer: 根命令共享的输出渲染器
:param command_group_registrar: CLI 命令组注册器
:param extension_registrar: CLI 扩展入口注册器
"""
output_renderer: OutputRenderer
command_group_registrar: CliCommandGroupRegistrar = field(
default_factory=lambda: CliCommandGroupRegistrar(DEFAULT_COMMAND_GROUP_REGISTRY)
)
extension_registrar: CliExtensionRegistrar = field(
default_factory=lambda: CliExtensionRegistrar(DEFAULT_CLI_EXTENSION_REGISTRY)
)
root_option_initializer: CliRootOptionInitializer = field(init=False)
root_callback_registrar: CliRootCallbackRegistrar = field(init=False)
def __post_init__(self) -> None:
"""
初始化根应用构建器依赖。
:return: None
"""
self.root_option_initializer = CliRootOptionInitializer(self.output_renderer)
self.root_callback_registrar = CliRootCallbackRegistrar(self.root_option_initializer)
def build(self) -> typer.Typer:
"""
构建 Typer 根应用并注册全部命令组。
:return: Typer 根应用
"""
cli = typer.Typer(
name='shierfeng',
help='ShiErFeng FastAPI 后端统一命令入口',
no_args_is_help=True,
add_completion=False,
context_settings={'help_option_names': ['-h', '--help']},
)
self.root_callback_registrar.register(cli)
self.command_group_registrar.register(cli)
self.extension_registrar.register(cli)
return cli
DEFAULT_COMMAND_GROUP_REGISTRY = CliCommandGroupRegistry(
command_modules={
'app': 'cli.groups.app',
'db': 'cli.groups.db',
'ops': 'cli.groups.ops',
'cache': 'cli.groups.cache',
'job': 'cli.groups.job',
'config': 'cli.groups.config',
'crypto': 'cli.groups.crypto',
'gen': 'cli.groups.gen',
'dev': 'cli.groups.dev',
}
)
DEFAULT_CLI_EXTENSION_REGISTRY = CliExtensionRegistry(
registrations=(
CliExtensionRegistration('cli.completion.commands', CliExtensionMountSupport.attach_completion),
CliExtensionRegistration('cli.wizard.commands', CliExtensionMountSupport.attach_wizard),
CliExtensionRegistration('cli.tui', CliExtensionMountSupport.attach_tui),
)
)
DEFAULT_CLI_MODULE_LOADER = CliModuleLoader()
@@ -1,121 +0,0 @@
import os
from dataclasses import dataclass, field
import typer
from click.shell_completion import shell_complete as click_shell_complete
from typer.completion import shell_complete as typer_shell_complete
from cli.completion.shells import ensure_custom_completion_classes_registered
@dataclass(frozen=True)
class CompletionInstructionSupport:
"""
completion 指令解析支持对象。
该对象负责管理 completion 环境变量名、受支持 shell 列表,以及
Click / Typer 两种指令格式的判定逻辑。
:param complete_env_var: completion 环境变量名
:param supported_shells: 支持的 shell 名称列表
"""
complete_env_var: str = '_SHIERFENG_COMPLETE'
supported_shells: tuple[str, ...] = ('bash', 'zsh', 'fish', 'powershell')
def is_click_style_instruction(self, instruction: str) -> bool:
"""
判断 completion 指令是否为 Click 原生格式。
Click 原生格式为 `<shell>_<instruction>`,例如 `bash_complete`。
:param instruction: completion 指令文本
:return: 是否为 Click 风格指令
"""
return any(instruction.startswith(f'{shell}_') for shell in self.supported_shells)
def is_typer_style_instruction(self, instruction: str) -> bool:
"""
判断 completion 指令是否为 Typer 兼容格式。
Typer 兼容格式为 `<instruction>_<shell>`,例如 `complete_bash`。
:param instruction: completion 指令文本
:return: 是否为 Typer 风格指令
"""
return any(instruction.endswith(f'_{shell}') for shell in self.supported_shells)
@dataclass(frozen=True)
class CompletionInstructionReader:
"""
completion 指令读取器。
该对象负责从当前进程环境中读取并规范化 completion 指令。
:param support: completion 指令解析支持对象
"""
support: CompletionInstructionSupport
def read_instruction(self) -> str:
"""
读取当前进程中的 completion 指令文本。
:return: 规范化后的 completion 指令文本
"""
return os.environ.get(self.support.complete_env_var, '').strip()
@dataclass(frozen=True)
class CompletionDispatcher:
"""
shell completion 请求分发器。
该对象负责识别 Click 与 Typer 两种 completion 指令格式,
并在命中时直接执行补全流程后退出当前进程。
:param support: completion 指令解析支持对象
:param instruction_reader: completion 指令读取器
"""
support: CompletionInstructionSupport = field(default_factory=CompletionInstructionSupport)
instruction_reader: CompletionInstructionReader = field(init=False)
def __post_init__(self) -> None:
"""
初始化 completion 指令读取器。
:return: None
"""
object.__setattr__(self, 'instruction_reader', CompletionInstructionReader(self.support))
def dispatch(self, cli: typer.Typer) -> None:
"""
处理 shell completion 请求并在命中时直接退出。
这里同时兼容 Click 与 Typer 两种 completion 指令格式,避免
生成脚本格式和运行时解析格式不一致时出现 `Shell complete not supported.`。
:param cli: Typer 根应用
:return: None
"""
ensure_custom_completion_classes_registered()
instruction = self.instruction_reader.read_instruction()
if not instruction:
return
click_command = typer.main.get_command(cli)
if self.support.is_click_style_instruction(instruction):
raise SystemExit(
click_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction)
)
if self.support.is_typer_style_instruction(instruction):
raise SystemExit(
typer_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction)
)
raise SystemExit(1)
DEFAULT_COMPLETION_INSTRUCTION_SUPPORT = CompletionInstructionSupport()
@@ -1,239 +0,0 @@
import logging
from dataclasses import dataclass, field
from importlib import import_module
from typing import Any
from cli.context import CLI_CONTEXT_BUILDER, CliContext, CliContextBuilder
from cli.guards import (
DEFAULT_DANGEROUS_COMMAND_GUARD,
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY,
DangerousCommandGuardService,
DangerousCommandRuleRegistry,
)
from cli.output import OutputRenderer
@dataclass
class CliRuntimeState:
"""
CLI 运行期状态。
:param logs_suppressed: 当前进程日志是否已切换为静默模式
"""
logs_suppressed: bool = False
sqlalchemy_logs_suppressed: bool = False
def get_logger(self) -> Any:
"""
获取 CLI 使用的日志对象。
:return: 日志对象
"""
return import_module('utils.log_util').logger
def suppress_logs(self) -> None:
"""
关闭 CLI 进程中的终端日志输出。
:return: None
"""
if self.logs_suppressed:
return
self.get_logger().remove()
self.logs_suppressed = True
def suppress_sqlalchemy_logs(self) -> None:
"""
关闭 CLI 进程中的 SQLAlchemy 终端 SQL 日志输出。
该逻辑同时处理两类来源:
1. 将 `config.env.DataBaseConfig.db_echo` 强制关闭,避免后续新建 Engine 时
继续打开 SQLAlchemy echo。
2. 将已知 SQLAlchemy logger 级别提升到 WARNING,避免已有 logger 配置把
`INFO sqlalchemy.engine.Engine ...` 继续打到标准输出。
:return: None
"""
if self.sqlalchemy_logs_suppressed:
return
env_module = import_module('config.env')
database_config = getattr(env_module, 'DataBaseConfig', None)
if database_config is not None and hasattr(database_config, 'db_echo'):
database_config.db_echo = False
for logger_name in (
'sqlalchemy',
'sqlalchemy.engine',
'sqlalchemy.engine.Engine',
'sqlalchemy.pool',
):
logging.getLogger(logger_name).setLevel(logging.WARNING)
self.sqlalchemy_logs_suppressed = True
@dataclass
class CliLogPolicy:
"""
CLI 日志策略服务。
该对象负责在上下文构建前应用统一的日志输出策略,
将“是否静默日志”这一策略从上下文工厂主体中拆出。
:param runtime_state: CLI 运行期状态对象
"""
runtime_state: CliRuntimeState
def prepare_regular_command(self) -> None:
"""
为普通 CLI 命令应用默认日志策略。
:return: None
"""
self.runtime_state.suppress_logs()
self.runtime_state.suppress_sqlalchemy_logs()
@dataclass
class DangerousCommandContextSupport:
"""
危险命令上下文支持服务。
该对象负责危险命令规则查询、保护执行与拒绝结果收口,
让上下文工厂本体只保留装配职责。
:param dangerous_command_rule_registry: 危险命令规则注册表
:param dangerous_command_guard_service: 危险命令保护执行服务
:param output_renderer: 输出渲染器
"""
dangerous_command_rule_registry: DangerousCommandRuleRegistry
dangerous_command_guard_service: DangerousCommandGuardService
output_renderer: OutputRenderer
def guard_context(self, ctx: CliContext, *, command_name: str) -> CliContext:
"""
对危险命令上下文执行统一保护。
:param ctx: 已构建的 CLI 上下文
:param command_name: 命令唯一标识
:return: 通过保护后的 CLI 上下文
"""
rule = self.dangerous_command_rule_registry.require_rule(command_name)
guard_result = self.dangerous_command_guard_service.guard(ctx, rule=rule)
if guard_result is not None:
self.output_renderer.complete_command(guard_result, ctx)
return ctx
@dataclass
class CliContextFactory:
"""
统一构建 CLI 命令上下文。
:param runtime_state: CLI 运行期状态对象
:param output_renderer: 输出渲染器
:param cli_context_builder: CLI 上下文构建器
:param dangerous_command_rule_registry: 危险命令规则注册表
:param dangerous_command_guard_service: 危险命令保护执行服务
"""
runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState)
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
cli_context_builder: CliContextBuilder = field(default_factory=lambda: CLI_CONTEXT_BUILDER)
dangerous_command_rule_registry: DangerousCommandRuleRegistry = field(
default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY
)
dangerous_command_guard_service: DangerousCommandGuardService = field(
default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_GUARD
)
_log_policy: CliLogPolicy | None = field(default=None, init=False, repr=False)
_dangerous_command_support: DangerousCommandContextSupport | None = field(
default=None,
init=False,
repr=False,
)
def get_log_policy(self) -> CliLogPolicy:
"""
获取当前上下文工厂使用的日志策略服务。
:return: CLI 日志策略服务
"""
if self._log_policy is None:
self._log_policy = CliLogPolicy(runtime_state=self.runtime_state)
return self._log_policy
def get_dangerous_command_support(self) -> DangerousCommandContextSupport:
"""
获取当前上下文工厂使用的危险命令上下文支持服务。
:return: 危险命令上下文支持服务
"""
if self._dangerous_command_support is None:
self._dangerous_command_support = DangerousCommandContextSupport(
dangerous_command_rule_registry=self.dangerous_command_rule_registry,
dangerous_command_guard_service=self.dangerous_command_guard_service,
output_renderer=self.output_renderer,
)
return self._dangerous_command_support
def build_regular(
self,
env: str,
output: str,
allow_prod: bool,
yes: bool,
dry_run: bool,
) -> CliContext:
"""
构建普通命令上下文。
:param env: 运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:param dry_run: 是否演练执行
:return: CLI 上下文
"""
self.get_log_policy().prepare_regular_command()
return self.cli_context_builder.build(env, output, allow_prod, yes, dry_run)
def build_readonly(
self,
env: str,
output: str,
) -> CliContext:
"""
构建只读命令上下文。
:param env: 运行环境
:param output: 输出格式
:return: CLI 上下文
"""
return self.build_regular(env, output, False, False, False)
def build_dangerous(
self,
env: str,
output: str,
allow_prod: bool,
yes: bool,
dry_run: bool,
*,
command_name: str,
) -> CliContext:
"""
构建危险命令上下文并执行统一保护。
:param env: 运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:param dry_run: 是否演练执行
:param command_name: 命令唯一标识
:return: CLI 上下文
"""
ctx = self.build_regular(env, output, allow_prod, yes, dry_run)
return self.get_dangerous_command_support().guard_context(ctx, command_name=command_name)
@@ -1,137 +0,0 @@
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from cli.context import CliContext
from cli.exit_codes import RUNTIME_ERROR
from cli.output import CommandResult, OutputRenderer
@dataclass
class CliExecutionService:
"""
CLI 执行与结果收口服务。
:param output_renderer: 输出渲染器
"""
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
def run_async(self, coroutine: Any) -> Any:
"""
执行异步协程并返回结果。
:param coroutine: 待执行协程
:return: 协程执行结果
"""
return asyncio.run(coroutine)
def complete_payload(
self,
ctx: CliContext,
payload: dict[str, Any],
*,
default_exit_code: int = 0,
) -> None:
"""
输出标准字典负载并结束命令。
:param ctx: CLI 上下文
:param payload: 标准结果字典
:param default_exit_code: 默认退出码
:return: None
"""
self.output_renderer.complete_command(self.build_result(payload, default_exit_code=default_exit_code), ctx)
def complete_result(
self,
ctx: CliContext,
result: CommandResult,
) -> None:
"""
输出命令结果对象并结束命令。
:param ctx: CLI 上下文
:param result: 命令执行结果对象
:return: None
"""
self.output_renderer.complete_command(result, ctx)
def complete_payload_with_text(
self,
ctx: CliContext,
payload: dict[str, Any],
*,
text_builder: Callable[[dict[str, Any]], Any],
default_exit_code: int = 0,
text_condition: Callable[[dict[str, Any]], bool] | None = None,
) -> None:
"""
按输出格式收口标准负载,必要时先转换为文本结果。
:param ctx: CLI 上下文
:param payload: 标准结果字典
:param text_builder: 文本结果构建函数
:param default_exit_code: 默认退出码
:param text_condition: 文本模式下是否应用构建函数的判定函数
:return: None
"""
if ctx.output != 'text':
self.complete_payload(ctx, payload, default_exit_code=default_exit_code)
return
result = self.build_result(payload, default_exit_code=default_exit_code)
should_build_text = True
if callable(text_condition) and isinstance(result.data, dict):
should_build_text = bool(text_condition(result.data))
if should_build_text and isinstance(result.data, dict):
result.data = text_builder(result.data)
self.complete_result(ctx, result)
def complete_payload_result(
self,
ctx: CliContext,
payload: dict[str, Any],
*,
text_builder: Callable[[dict[str, Any]], Any] | None = None,
default_exit_code: int = 0,
text_condition: Callable[[dict[str, Any]], bool] | None = None,
) -> None:
"""
统一收口标准负载,按输出格式决定是否渲染为文本结果。
:param ctx: CLI 上下文
:param payload: 标准结果字典
:param text_builder: 文本结果构建函数
:param default_exit_code: 默认退出码
:param text_condition: 文本模式下是否应用构建函数的判定函数
:return: None
"""
if text_builder is None:
self.complete_payload(ctx, payload, default_exit_code=default_exit_code)
return
self.complete_payload_with_text(
ctx,
payload,
text_builder=text_builder,
default_exit_code=default_exit_code,
text_condition=text_condition,
)
@staticmethod
def build_result(payload: dict[str, Any], *, default_exit_code: int = 0) -> CommandResult:
"""
将标准字典负载转换为命令结果对象。
:param payload: 标准结果字典
:param default_exit_code: 默认退出码;当 payload 未显式提供 exit_code 且
该值仍为 0 时,失败结果会统一回退到 `RUNTIME_ERROR`
:return: 命令结果对象
"""
result_payload = dict(payload)
exit_code = result_payload.pop('exit_code', None)
if exit_code is None:
is_ok = bool(result_payload.get('ok', True))
exit_code = default_exit_code if is_ok or default_exit_code != 0 else RUNTIME_ERROR
return CommandResult(data=result_payload, exit_code=exit_code)
@@ -1,8 +0,0 @@
SUCCESS = 0
ARGUMENT_ERROR = 2
DEPENDENCY_ERROR = 10
DATABASE_ERROR = 20
REDIS_ERROR = 21
SCHEDULER_ERROR = 22
GUARD_REJECTED = 30
RUNTIME_ERROR = 50
@@ -1,3 +0,0 @@
from .command import app
__all__ = ['app']
@@ -1,102 +0,0 @@
from typing import Annotated, Literal
import typer
from cli.context import EnvOption, OutputOption
from .controller import AppCommandController
app = typer.Typer(
help='应用相关命令',
no_args_is_help=True,
context_settings={'help_option_names': ['-h', '--help']},
)
_APP_COMMAND_CONTROLLER = AppCommandController()
@app.command('run', help='启动当前 FastAPI 应用')
def run_app(
env: EnvOption = 'dev',
) -> None:
"""
启动当前 FastAPI 应用。
:param env: 当前命令运行环境
:return: None
"""
_APP_COMMAND_CONTROLLER.run_app(env)
@app.command('doctor', help='执行应用启动前检查')
def doctor(
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
执行应用启动前检查。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_APP_COMMAND_CONTROLLER.doctor(env, output)
@app.command('config', help='查看当前应用配置快照')
def app_config(
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
输出当前应用配置快照。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_APP_COMMAND_CONTROLLER.show_config(env, output)
@app.command('env', help='查看当前 CLI 解析到的应用环境信息')
def app_env(
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
输出当前 CLI 解析到的应用环境信息。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_APP_COMMAND_CONTROLLER.show_env(env, output)
@app.command('routes', help='查看当前应用注册路由清单')
def routes(
env: EnvOption = 'dev',
output: OutputOption = 'text',
path_prefix: Annotated[str, typer.Option('--path-prefix', help='按路由前缀过滤')] = '',
method: Annotated[str, typer.Option('--method', help='按请求方法过滤,如 GET、POST')] = '',
group_by: Annotated[Literal['none', 'tag'], typer.Option('--group-by', help='路由分组方式')] = 'none',
include_hidden: Annotated[bool, typer.Option('--include-hidden', help='包含未出现在 OpenAPI 中的路由')] = False,
) -> None:
"""
输出当前应用注册路由清单。
:param env: 当前命令运行环境
:param output: 输出格式
:param path_prefix: 路由前缀过滤条件
:param method: 请求方法过滤条件
:param group_by: 路由分组方式
:param include_hidden: 是否包含隐藏路由
:return: None
"""
_APP_COMMAND_CONTROLLER.show_routes(
env,
output,
path_prefix=path_prefix,
method=method,
group_by=group_by,
include_hidden=include_hidden,
)
@@ -1,236 +0,0 @@
from collections import defaultdict
from typing import Any
from fastapi.routing import APIRoute
from cli.bootstrap import APP_BOOTSTRAP, AppBootstrapService
from cli.core import (
DEFAULT_CORE_SERVICES,
CliContextFactory,
CliExecutionService,
)
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
from cli.runtime.app import APP_RUNTIME, AppRuntimeService
from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService
from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService
from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService
from .presenter import AppCommandPresenter
class AppCommandController:
"""
应用命令控制器。
该控制器负责组织 `app` 命令组的上下文准备、runtime 调用、
payload 构建,以及基于输出格式选择 presenter 或直接返回 JSON。
"""
def __init__(
self,
*,
context_factory: CliContextFactory | None = None,
execution_service: CliExecutionService | None = None,
presenter: AppCommandPresenter | None = None,
runtime_service: AppRuntimeService | None = None,
database_runtime: DatabaseRuntimeService | None = None,
operations_runtime: OperationsRuntimeService | None = None,
crypto_runtime: CryptoRuntimeService | None = None,
bootstrap_service: AppBootstrapService | None = None,
) -> None:
"""
初始化应用命令控制器。
:param context_factory: CLI 上下文工厂
:param execution_service: CLI 执行服务
:param presenter: 应用命令文本渲染器
:param runtime_service: 应用运行时服务
:param database_runtime: 数据库运行时服务
:param operations_runtime: 运维运行时服务
:param crypto_runtime: 传输加密运行时服务
:param bootstrap_service: 应用引导服务
:return: None
"""
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
self.presenter = presenter or AppCommandPresenter()
self.runtime_service = runtime_service or APP_RUNTIME
self.database_runtime = database_runtime or DATABASE_RUNTIME
self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME
self.crypto_runtime = crypto_runtime or CRYPTO_RUNTIME
self.bootstrap_service = bootstrap_service or APP_BOOTSTRAP
def run_app(self, env: str) -> None:
"""
启动当前 FastAPI 应用。
:param env: 当前命令运行环境
:return: None
"""
self.bootstrap_service.exec_app_run_command(env)
def doctor(self, env: str, output: str) -> None:
"""
执行应用启动前检查。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
db_status = self.execution_service.run_async(self.database_runtime.ping_database())
redis_status = self.execution_service.run_async(self.operations_runtime.ping_redis())
crypto_status = self.crypto_runtime.validate_crypto_config()
payload = {
'env': ctx.env,
'database': db_status,
'redis': redis_status,
'crypto': crypto_status,
}
payload['ok'] = all(item.get('ok', False) for item in (db_status, redis_status, crypto_status))
exit_code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR
self.execution_service.complete_payload_with_text(
ctx,
payload,
text_builder=self.presenter.build_doctor_text,
default_exit_code=exit_code,
)
def show_config(self, env: str, output: str) -> None:
"""
输出当前应用配置快照。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
self.execution_service.complete_payload_result(
ctx,
{'ok': True, 'env': ctx.env, 'config': self.runtime_service.get_app_config_snapshot()},
text_builder=self.presenter.build_app_config_text,
default_exit_code=SUCCESS,
)
def show_env(self, env: str, output: str) -> None:
"""
输出当前 CLI 解析到的应用环境信息。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
self.execution_service.complete_payload_result(
ctx,
{'ok': True, 'env': ctx.env, 'runtime': self.runtime_service.get_app_env_snapshot()},
text_builder=self.presenter.build_app_env_text,
default_exit_code=SUCCESS,
)
def show_routes(
self,
env: str,
output: str,
*,
path_prefix: str,
method: str,
group_by: str,
include_hidden: bool,
) -> None:
"""
输出当前应用注册路由清单。
:param env: 当前命令运行环境
:param output: 输出格式
:param path_prefix: 路由前缀过滤条件
:param method: 请求方法过滤条件
:param group_by: 路由分组方式
:param include_hidden: 是否包含隐藏路由
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
routes_payload = self._serialize_routes(
self.runtime_service.build_app_instance(),
path_prefix=path_prefix,
method=method,
include_hidden=include_hidden,
)
grouped_routes = self._group_routes_by_tag(routes_payload) if group_by == 'tag' else None
payload = {
'ok': True,
'env': ctx.env,
'count': len(routes_payload),
'filters': {
'pathPrefix': path_prefix,
'method': method.upper().strip(),
'groupBy': group_by,
'includeHidden': include_hidden,
},
'routes': routes_payload,
'groupedRoutes': grouped_routes,
}
self.execution_service.complete_payload_with_text(
ctx,
payload,
text_builder=self.presenter.build_routes_text,
default_exit_code=SUCCESS,
text_condition=lambda data: data.get('ok', False),
)
@staticmethod
def _serialize_routes(
app_instance: Any,
*,
path_prefix: str = '',
method: str = '',
include_hidden: bool = False,
) -> list[dict[str, Any]]:
"""
序列化 FastAPI 路由信息。
:param app_instance: FastAPI 应用实例
:param path_prefix: 路径前缀过滤条件
:param method: 请求方法过滤条件
:param include_hidden: 是否包含未出现在 OpenAPI 中的路由
:return: 序列化后的路由列表
"""
normalized_method = method.upper().strip()
routes = []
for route in app_instance.routes:
if not isinstance(route, APIRoute):
continue
if not include_hidden and not route.include_in_schema:
continue
if path_prefix and not route.path.startswith(path_prefix):
continue
route_methods = sorted(item for item in route.methods if item not in {'HEAD', 'OPTIONS'})
if normalized_method and normalized_method not in route_methods:
continue
routes.append(
{
'path': route.path,
'methods': route_methods,
'name': route.name,
'summary': route.summary or '',
'operationId': route.operation_id or '',
'tags': route.tags or [],
'includeInSchema': route.include_in_schema,
}
)
return sorted(routes, key=lambda item: (item['path'], ','.join(item['methods'])))
@staticmethod
def _group_routes_by_tag(routes_payload: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
"""
按标签对路由信息分组。
:param routes_payload: 原始路由列表
:return: 按标签分组后的路由映射
"""
grouped_routes: dict[str, list[dict[str, Any]]] = defaultdict(list)
for route in routes_payload:
tags = route.get('tags') or ['__untagged__']
for tag in tags:
grouped_routes[tag].append(route)
return dict(sorted(grouped_routes.items(), key=lambda item: item[0]))
@@ -1,210 +0,0 @@
from typing import Any
from cli.utils import SHELL_TEXT_FORMATTER
class AppCommandPresenter:
"""
应用命令文本渲染器。
该渲染器负责将 `app` 命令组产生的结构化 payload 转换为稳定的文本摘要,
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
"""
def build_routes_text(self, payload: dict[str, Any]) -> str:
"""
将路由列表结果渲染为文本摘要。
:param payload: 路由列表结果字典
:return: 文本摘要
"""
lines = [
f'ok: {str(payload.get("ok", False)).lower()}',
f'env: {payload.get("env", "")}',
f'count: {payload.get("count", 0)}',
]
filters = payload.get('filters')
if isinstance(filters, dict):
lines.extend(self._build_route_filter_lines(filters))
grouped_routes = payload.get('groupedRoutes')
if filters and filters.get('groupBy') == 'tag' and isinstance(grouped_routes, dict):
lines.extend(self._build_grouped_routes_text(grouped_routes))
return '\n'.join(lines)
routes = payload.get('routes')
if not isinstance(routes, list) or not routes:
lines.append('routes: none')
return '\n'.join(lines)
lines.append('routes:')
for route in routes:
if isinstance(route, dict):
lines.extend([f' {line}' for line in self._build_route_item_lines(route)])
return '\n'.join(lines)
def build_app_config_text(self, payload: dict[str, Any]) -> str:
"""
将应用配置快照渲染为文本摘要。
:param payload: 应用配置结果字典
:return: 文本摘要
"""
config = payload.get('config')
if not isinstance(config, dict):
return '\n'.join(
[
f'ok: {str(payload.get("ok", False)).lower()}',
f'env: {payload.get("env", "")}',
'config: none',
]
)
lines = [
f'ok: {str(payload.get("ok", False)).lower()}',
f'env: {payload.get("env", "")}',
'application:',
f' name: {config.get("name", "-")}',
f' host: {config.get("host", "-")}:{config.get("port", "-")}',
f' root_path: {config.get("rootPath", "-") or "/"}',
f' reload: {str(config.get("reload", False)).lower()}',
f' workers: {config.get("workers", "-")}',
f' disable_swagger: {str(config.get("disableSwagger", False)).lower()}',
f' disable_redoc: {str(config.get("disableRedoc", False)).lower()}',
'database:',
f' type: {config.get("dbType", "-")}',
f' host: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
f' name: {config.get("dbDatabase", "-")}',
'redis:',
f' host: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}',
'logging:',
f' level: {config.get("logLevel", "-")}',
'transport_crypto:',
f' enabled: {str(config.get("transportCryptoEnabled", False)).lower()}',
f' mode: {config.get("transportCryptoMode", "-")}',
]
return '\n'.join(lines)
def build_app_env_text(self, payload: dict[str, Any]) -> str:
"""
将应用环境解析结果渲染为文本摘要。
:param payload: 应用环境结果字典
:return: 文本摘要
"""
runtime = payload.get('runtime')
if not isinstance(runtime, dict):
return '\n'.join(
[
f'ok: {str(payload.get("ok", False)).lower()}',
f'env: {payload.get("env", "")}',
'runtime: none',
]
)
return '\n'.join(
[
f'ok: {str(payload.get("ok", False)).lower()}',
f'env: {payload.get("env", "")}',
'runtime:',
f' cli_env: {runtime.get("cliEnv", "-")}',
f' config_env: {runtime.get("configEnv", "-")}',
f' app_env: {runtime.get("appEnv", "-")}',
f' env_file: {runtime.get("envFile", "-")}',
f' env_file_exists: {str(runtime.get("envFileExists", False)).lower()}',
f' backend_dir: {runtime.get("backendDir", "-")}',
f' python_executable: {runtime.get("pythonExecutable", "-")}',
]
)
def build_doctor_text(self, payload: dict[str, Any]) -> str:
"""
将应用启动前检查结果渲染为文本摘要。
:param payload: 启动前检查结果字典
:return: 文本摘要
"""
return '\n'.join(
[
f'ok: {str(payload.get("ok", False)).lower()}',
f'env: {payload.get("env", "")}',
'checks:',
self._build_check_status_line('database', payload.get('database')),
self._build_check_status_line('redis', payload.get('redis')),
self._build_check_status_line('crypto', payload.get('crypto')),
]
)
@staticmethod
def _build_route_filter_lines(filters: dict[str, object]) -> list[str]:
"""
构建路由过滤条件文本行。
:param filters: 过滤条件字典
:return: 过滤条件文本行列表
"""
active_filters = []
for key, value in filters.items():
if value in (None, '', False, 'none'):
continue
active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}')
if not active_filters:
return ['filters: none']
return ['filters:', *[f' - {item}' for item in active_filters]]
@staticmethod
def _build_route_item_lines(route_item: dict[str, Any]) -> list[str]:
"""
构建单条路由记录的文本行。
:param route_item: 单条路由记录
:return: 文本行列表
"""
methods = ','.join(route_item.get('methods', [])) or '-'
path = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('path', ''), 72)
tags = ','.join(route_item.get('tags', [])) or '-'
summary = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('summary', ''), 60)
name = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('name', ''), 40)
return [
f'- [{methods}] {path}',
f' name: {name or "-"} | tags: {tags}',
f' summary: {summary or "-"}',
]
def _build_grouped_routes_text(self, grouped_routes: dict[str, Any]) -> list[str]:
"""
将按标签分组后的路由结果渲染为文本行。
:param grouped_routes: 按标签分组后的路由映射
:return: 文本行列表
"""
if not grouped_routes:
return ['groups: none']
lines = [f'groups: {len(grouped_routes)}']
for tag, routes in grouped_routes.items():
route_items = routes if isinstance(routes, list) else []
lines.append(f' {tag}: {len(route_items)}')
for route in route_items:
if isinstance(route, dict):
lines.extend([f' {line}' for line in self._build_route_item_lines(route)])
return lines
@staticmethod
def _build_check_status_line(name: str, status_payload: dict[str, Any] | None) -> str:
"""
构建单项检查结果摘要行。
:param name: 检查项名称
:param status_payload: 检查结果字典
:return: 摘要行文本
"""
if not isinstance(status_payload, dict):
return f' {name}: unknown'
ok = str(status_payload.get('ok', False)).lower()
message = status_payload.get('message', '-') or '-'
error = status_payload.get('error')
if error:
return f' {name}: {ok} | {message} | error: {SHELL_TEXT_FORMATTER.truncate_text(error, 120)}'
return f' {name}: {ok} | {message}'
@@ -1,3 +0,0 @@
from .command import app
__all__ = ['app']
@@ -1,163 +0,0 @@
import typer
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
from .controller import CacheCommandController
app = typer.Typer(
help='缓存相关命令',
no_args_is_help=True,
context_settings={'help_option_names': ['-h', '--help']},
)
_CACHE_COMMAND_CONTROLLER = CacheCommandController()
@app.command('stats', help='查看缓存统计信息')
def stats(
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查看缓存统计信息。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_CACHE_COMMAND_CONTROLLER.stats(env, output)
@app.command('keys', help='查看指定缓存名称下的键名列表')
def keys(
cache_name: str = typer.Argument(
...,
help='缓存名称',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
),
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查看指定缓存名称下的键名列表。
:param cache_name: 缓存名称
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_CACHE_COMMAND_CONTROLLER.keys(cache_name, env, output)
@app.command('get', help='查看指定缓存内容')
def get(
cache_name: str = typer.Argument(
...,
help='缓存名称',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
),
cache_key: str = typer.Argument(
...,
help='缓存键名',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys,
),
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查看指定缓存内容。
:param cache_name: 缓存名称
:param cache_key: 缓存键名
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_CACHE_COMMAND_CONTROLLER.get(cache_name, cache_key, env, output)
@app.command('clear', help='执行缓存清理')
def clear(
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
dry_run: DryRunOption = False,
cache_name: str = typer.Option(
'',
'--cache-name',
help='按缓存名称前缀清理',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
),
cache_key: str = typer.Option('', '--cache-key', help='按缓存键名模糊清理'),
clear_all: bool = typer.Option(False, '--all', help='清理全部缓存并重建系统基础缓存'),
) -> None:
"""
清理缓存。
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:param dry_run: 是否演练执行
:param cache_name: 缓存名称前缀
:param cache_key: 缓存键名
:param clear_all: 是否清理全部缓存
:return: None
"""
_CACHE_COMMAND_CONTROLLER.clear(
env,
output,
allow_prod,
yes,
dry_run,
cache_name=cache_name,
cache_key=cache_key,
clear_all=clear_all,
)
@app.command('ttl', help='查看指定缓存键的剩余过期时间')
def ttl(
cache_name: str = typer.Argument(
...,
help='缓存名称',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
),
cache_key: str = typer.Argument(
...,
help='缓存键名',
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys,
),
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查看指定缓存键的剩余过期时间。
:param cache_name: 缓存名称
:param cache_key: 缓存键名
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
_CACHE_COMMAND_CONTROLLER.ttl(cache_name, cache_key, env, output)
@app.command('warmup', help='执行系统缓存预热')
def warmup(
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
) -> None:
"""
执行系统缓存预热。
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:return: None
"""
_CACHE_COMMAND_CONTROLLER.warmup(env, output, allow_prod, yes)
@@ -1,183 +0,0 @@
from cli.core import (
DEFAULT_CORE_SERVICES,
CliContextFactory,
CliExecutionService,
)
from cli.runtime.cache import CACHE_RUNTIME, CacheRuntimeService
from .presenter import CacheCommandPresenter
class CacheCommandController:
"""
缓存命令控制器。
该控制器负责组织 `cache` 命令组的上下文准备、runtime 调用、
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
:param context_factory: CLI 上下文工厂
:param execution_service: CLI 执行服务
:param presenter: 缓存命令文本渲染器
"""
def __init__(
self,
*,
context_factory: CliContextFactory | None = None,
execution_service: CliExecutionService | None = None,
presenter: CacheCommandPresenter | None = None,
runtime_service: CacheRuntimeService | None = None,
) -> None:
"""
初始化缓存命令控制器。
:param context_factory: CLI 上下文工厂
:param execution_service: CLI 执行服务
:param presenter: 缓存命令文本渲染器
:param runtime_service: 缓存运行时服务
:return: None
"""
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
self.presenter = presenter or CacheCommandPresenter()
self.runtime_service = runtime_service or CACHE_RUNTIME
def stats(self, env: str, output: str) -> None:
"""
查看缓存统计信息。
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
payload = self.execution_service.run_async(self.runtime_service.get_cache_stats())
self.execution_service.complete_payload_with_text(
ctx,
payload,
text_builder=self.presenter.build_cache_stats_text,
text_condition=lambda result_data: bool(result_data.get('ok', False)),
)
def keys(self, cache_name: str, env: str, output: str) -> None:
"""
查看指定缓存名称下的键名列表。
:param cache_name: 缓存名称
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
payload = self.execution_service.run_async(self.runtime_service.list_cache_keys(cache_name))
self.execution_service.complete_payload_with_text(
ctx,
payload,
text_builder=self.presenter.build_cache_keys_text,
text_condition=lambda result_data: bool(result_data.get('ok', False)),
)
def get(self, cache_name: str, cache_key: str, env: str, output: str) -> None:
"""
查看指定缓存内容。
:param cache_name: 缓存名称
:param cache_key: 缓存键名
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
payload = self.execution_service.run_async(self.runtime_service.get_cache_value(cache_name, cache_key))
self.execution_service.complete_payload_with_text(
ctx,
payload,
text_builder=self.presenter.build_cache_value_text,
text_condition=lambda result_data: bool(result_data.get('ok', False)),
)
def clear(
self,
env: str,
output: str,
allow_prod: bool,
yes: bool,
dry_run: bool,
*,
cache_name: str,
cache_key: str,
clear_all: bool,
) -> None:
"""
清理缓存。
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:param dry_run: 是否演练执行
:param cache_name: 缓存名称前缀
:param cache_key: 缓存键名
:param clear_all: 是否清理全部缓存
:return: None
"""
ctx = self.context_factory.build_dangerous(
env,
output,
allow_prod,
yes,
dry_run,
command_name='cache clear',
)
self.execution_service.complete_payload(
ctx,
self.execution_service.run_async(
self.runtime_service.clear_cache(
cache_name=cache_name,
cache_key=cache_key,
clear_all=clear_all,
dry_run=dry_run,
)
),
)
def ttl(self, cache_name: str, cache_key: str, env: str, output: str) -> None:
"""
查看指定缓存键的剩余过期时间。
:param cache_name: 缓存名称
:param cache_key: 缓存键名
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
ctx = self.context_factory.build_readonly(env, output)
payload = self.execution_service.run_async(self.runtime_service.get_cache_ttl(cache_name, cache_key))
self.execution_service.complete_payload_with_text(
ctx,
payload,
text_builder=self.presenter.build_cache_ttl_text,
text_condition=lambda result_data: 'error' not in result_data,
)
def warmup(self, env: str, output: str, allow_prod: bool, yes: bool) -> None:
"""
执行系统缓存预热。
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:return: None
"""
ctx = self.context_factory.build_dangerous(
env,
output,
allow_prod,
yes,
False,
command_name='cache warmup',
)
self.execution_service.complete_payload(
ctx, self.execution_service.run_async(self.runtime_service.warmup_cache())
)
@@ -1,150 +0,0 @@
from cli.utils import SHELL_TEXT_FORMATTER
class CacheCommandPresenter:
"""
缓存命令文本渲染器。
该渲染器负责将 `cache` 命令组产生的结构化 payload 转换为稳定的文本摘要,
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
"""
def build_cache_stats_text(self, payload: dict[str, object]) -> str:
"""
将缓存统计结果渲染为文本摘要。
:param payload: 缓存统计结果
:return: 文本摘要
"""
info = payload.get('info')
command_stats = payload.get('commandStats')
cache_names = payload.get('cacheNames')
redis_version = '-'
connected_clients = '-'
used_memory = '-'
uptime_seconds = '-'
keyspace_hits = '-'
keyspace_misses = '-'
if isinstance(info, dict):
redis_version = info.get('redis_version', '-')
connected_clients = info.get('connected_clients', '-')
used_memory = info.get('used_memory_human', info.get('used_memory', '-'))
uptime_seconds = info.get('uptime_in_seconds', '-')
keyspace_hits = info.get('keyspace_hits', '-')
keyspace_misses = info.get('keyspace_misses', '-')
lines = [
f'ok: {str(payload.get("ok", False)).lower()}',
f'db_size: {payload.get("dbSize", 0)}',
'redis:',
f' version: {redis_version}',
f' connected_clients: {connected_clients}',
f' used_memory: {used_memory}',
f' uptime_seconds: {uptime_seconds}',
f' keyspace_hits: {keyspace_hits}',
f' keyspace_misses: {keyspace_misses}',
]
if isinstance(command_stats, list) and command_stats:
lines.append('command_stats_top10:')
lines.extend(
f' - {item.get("name", "-")}: {item.get("value", 0)}'
for item in command_stats[:10]
if isinstance(item, dict)
)
else:
lines.append('command_stats_top10: none')
if isinstance(cache_names, list) and cache_names:
lines.append(f'cache_names: {len(cache_names)}')
lines.append('cache_name_samples:')
lines.extend(
f' - {item.get("cacheName", "-")}: {self._build_cache_name_remark(item.get("remark", ""))}'
for item in cache_names[:10]
if isinstance(item, dict)
)
else:
lines.append('cache_names: 0')
return '\n'.join(lines)
def build_cache_keys_text(self, payload: dict[str, object]) -> str:
"""
将缓存键列表结果渲染为文本摘要。
:param payload: 缓存键列表结果
:return: 文本摘要
"""
keys = payload.get('keys')
lines = [
f'ok: {str(payload.get("ok", False)).lower()}',
f'cache_name: {payload.get("cacheName", "")}',
f'count: {payload.get("count", 0)}',
]
if not isinstance(keys, list) or not keys:
lines.append('keys: none')
return '\n'.join(lines)
lines.append('keys:')
lines.extend(f' - {key}' for key in keys)
return '\n'.join(lines)
def build_cache_value_text(self, payload: dict[str, object]) -> str:
"""
将缓存值读取结果渲染为文本摘要。
:param payload: 缓存值读取结果字典
:return: 文本摘要
"""
cache_value = '' if payload.get('cacheValue') is None else str(payload.get('cacheValue'))
cache_value_lines = cache_value.splitlines() if cache_value else []
if not cache_value_lines:
rendered_value_lines = [' -']
elif len(cache_value_lines) == 1:
rendered_value_lines = [f' {SHELL_TEXT_FORMATTER.truncate_text(cache_value_lines[0], 200)}']
else:
rendered_value_lines = [' |', *[f' {line}' for line in cache_value_lines]]
return '\n'.join(
[
f'ok: {str(payload.get("ok", False)).lower()}',
f'cache_name: {payload.get("cacheName", "")}',
f'cache_key: {payload.get("cacheKey", "")}',
f'full_cache_key: {payload.get("fullCacheKey", "")}',
'cache_value:',
*rendered_value_lines,
]
)
@staticmethod
def build_cache_ttl_text(payload: dict[str, object]) -> str:
"""
将缓存 TTL 结果渲染为文本摘要。
:param payload: 缓存 TTL 结果字典
:return: 文本摘要
"""
return '\n'.join(
[
f'ok: {str(payload.get("ok", False)).lower()}',
f'cache_name: {payload.get("cacheName", "")}',
f'cache_key: {payload.get("cacheKey", "")}',
f'full_cache_key: {payload.get("fullCacheKey", "")}',
f'message: {payload.get("message", "-")}',
f'ttl_seconds: {payload.get("ttlSeconds", "-")}',
f'persistent: {str(payload.get("persistent", False)).lower()}',
f'expires: {str(payload.get("expires", False)).lower()}',
]
)
@staticmethod
def _build_cache_name_remark(remark: object) -> str:
"""
规范化缓存名称备注文本。
:param remark: 原始备注
:return: 规范化后的备注
"""
text = '' if remark is None else str(remark).strip()
return text or '-'

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