Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4119c78c | ||
|
|
167df4adb8 | ||
|
|
3050ccdc4e | ||
|
|
80a71b48ce | ||
|
|
3330592fbe | ||
|
|
a8af1f542b | ||
|
|
a62803ccf0 | ||
|
|
ae662a4941 | ||
|
|
d00c725320 | ||
|
|
3dc2a308eb | ||
|
|
f3393d78fb | ||
|
|
56e646bcc1 | ||
|
|
9a14b692e3 | ||
|
|
ecb254cb54 | ||
|
|
a539b4eb71 |
@@ -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
@@ -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/
|
||||
Generated
-10
@@ -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/
|
||||
Generated
-10
@@ -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>
|
||||
Generated
-15
@@ -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
@@ -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
@@ -1,6 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
-7
@@ -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>
|
||||
Generated
-8
@@ -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
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
../../../shierfeng-fastapi-app
|
||||
@@ -1,152 +0,0 @@
|
||||
# Task 5 交接报告
|
||||
|
||||
## Status
|
||||
|
||||
INCOMPLETE。当前尝试未完成 Task 5,未提交 commit。
|
||||
|
||||
## 已修改文件
|
||||
|
||||
本次 Task 5 尝试涉及 brief 白名单中的 18 个文件:
|
||||
|
||||
- `shierfeng-fastapi-backend/module_business/controller/inbound_controller.py`
|
||||
- `shierfeng-fastapi-backend/module_business/controller/filling_controller.py`
|
||||
- `shierfeng-fastapi-backend/module_business/controller/shipment_controller.py`
|
||||
- `shierfeng-fastapi-backend/module_business/controller/usage_controller.py`
|
||||
- `shierfeng-fastapi-backend/module_business/controller/return_controller.py`
|
||||
- `shierfeng-fastapi-backend/module_business/dao/operation_dao.py`
|
||||
- `shierfeng-fastapi-backend/module_business/dao/inventory_dao.py`
|
||||
- `shierfeng-fastapi-backend/module_business/service/inbound_service.py`
|
||||
- `shierfeng-fastapi-backend/module_business/service/filling_service.py`
|
||||
- `shierfeng-fastapi-backend/module_business/service/shipment_service.py`
|
||||
- `shierfeng-fastapi-backend/module_business/service/usage_service.py`
|
||||
- `shierfeng-fastapi-backend/module_business/service/return_service.py`
|
||||
- `shierfeng-fastapi-backend/tests/module_business/test_inbound_service.py`
|
||||
- `shierfeng-fastapi-backend/tests/module_business/test_filling_service.py`
|
||||
- `shierfeng-fastapi-backend/tests/module_business/test_shipment_service.py`
|
||||
- `shierfeng-fastapi-backend/tests/module_business/test_usage_service.py`
|
||||
- `shierfeng-fastapi-backend/tests/module_business/test_return_service.py`
|
||||
- `shierfeng-fastapi-backend/tests/module_business/test_inventory_dao.py`
|
||||
|
||||
## RED 证据
|
||||
|
||||
初始聚焦命令运行五类业务 service 与 inventory DAO 测试,共 282 个测试,结果为 `38 failures`。该 RED 结果用于确认显式 scope 契约、批次锁首语句、派生记录继承和 DAO scope 隔离的缺口。
|
||||
|
||||
## 阶段性结果
|
||||
|
||||
随后按单文件继续迁移测试调用和断言,当前结果为:
|
||||
|
||||
- `test_inbound_service`:55 tests,`24 failures / 9 errors`
|
||||
- `test_return_service`:46 tests,`16 failures / 9 errors`
|
||||
- `test_inventory_dao`:42 tests,`16 failures / 17 errors`
|
||||
|
||||
失败根因主要是既有测试中的旧 service/DAO 调用、旧参数顺序和旧 `assert_awaited_*` 断言尚未完成显式 scope 迁移;同时仍存在真实的跨 scope 校验、测试写入批次锁顺序、既有库存 ownership 保持和相关回滚行为需要继续实现与验证。生产接口当前保持 scope 必填,没有增加默认 formal scope。
|
||||
|
||||
## 未完成项
|
||||
|
||||
- 尚未使六个目标测试文件逐组 GREEN。
|
||||
- 尚未完成五条业务链路的完整跨 scope、批次锁首语句、派生 `test_batch_id` 和既有库存 ownership 验证。
|
||||
- 尚未运行 brief 要求的完整 282+ 回归测试。
|
||||
- 尚未运行最终 Ruff format/check 和 diff check。
|
||||
- 尚未执行精确白名单 `git commit --only`,当前没有可提交 SHA。
|
||||
|
||||
## 交接结论
|
||||
|
||||
这是 INCOMPLETE 交接,不代表 Task 5 完成。后续应从测试调用/断言迁移继续,修复每个单文件的真实 scope 与锁顺序失败,完成全套验证后再生成正式提交和最终报告。
|
||||
|
||||
## Task 5 最终结果(2026-09-23)
|
||||
|
||||
### Status
|
||||
|
||||
DONE。18 个白名单文件的 Task 5 实现、测试 scope 迁移和格式修复已完成;未提交 commit。生产代码保留显式 scope 必填,没有添加 formal 默认值;测试/生产数据中 `test_batch_id=NULL` 仍表示正式数据。
|
||||
|
||||
### 本轮完成
|
||||
|
||||
- 完成 `test_return_service.py` 的旧 DAO 调用、内部 helper 调用和 mock 断言迁移,补齐显式 `TestBatchScope`。
|
||||
- 完成 `test_inventory_dao.py` 的 DAO 签名期望、列表参数顺序、幂等查询、详情/锁查询和写入调用迁移。
|
||||
- 对 5 个控制器和 6 个业务测试文件执行白名单范围内的 Ruff 导入/格式修复;未修改白名单之外的源码、测试、日志或 pycache。
|
||||
- 复核生产实现:DAO 根查询及明细查询应用 `batch_scope_condition`;测试写入口首条数据库语句锁定 active batch;派生订单、明细和事件显式继承 `test_batch_id`;已有库存未被改写 ownership;入库/发货旧 DELETE 在访问 DAO 前固定抛出批次清理提示;授权、状态机、幂等、锁顺序和回滚回归通过。
|
||||
|
||||
### 验证命令与真实结果
|
||||
|
||||
1. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inbound_service`:`Ran 55 tests ... OK`。
|
||||
2. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_filling_service`:`Ran 42 tests ... OK`。
|
||||
3. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_shipment_service`:`Ran 46 tests ... OK`。
|
||||
4. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_usage_service`:`Ran 51 tests ... OK`。
|
||||
5. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_return_service`:`Ran 46 tests ... OK`。
|
||||
6. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inventory_dao`:`Ran 42 tests ... OK`。
|
||||
7. 六文件合并命令:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inbound_service tests.module_business.test_filling_service tests.module_business.test_shipment_service tests.module_business.test_usage_service tests.module_business.test_return_service tests.module_business.test_inventory_dao -v`:`Ran 282 tests ... OK`。
|
||||
8. 白名单 `ruff format --check`:`18 files already formatted`,exit 0。
|
||||
9. 白名单 `ruff check`:`All checks passed!`,exit 0。
|
||||
10. 目录级 `ruff check module_business/controller module_business/dao module_business/service tests/module_business`:`All checks passed!`,exit 0。
|
||||
11. 白名单范围 `git diff --check -- <18 files>`:无输出,exit 0。
|
||||
12. 全仓 `git diff --check`:exit 2;首个实际根因为用户已有 `shierfeng-fastapi-backend/logs/2026/09/20/info.log` 的 trailing whitespace,未修改日志。
|
||||
|
||||
### Concerns
|
||||
|
||||
- 目录级 `ruff format --check module_business/controller module_business/dao module_business/service tests/module_business` 仍 exit 1,精确列出 4 个非白名单文件:`module_business/dao/test_batch_dao.py`、`module_business/service/test_batch_service.py`、`tests/module_business/test_sql_contract.py`、`tests/module_business/test_test_batch_service.py`。受本任务 18 文件白名单约束,未修改。
|
||||
- 全仓 `git diff --check` 仍被用户已有日志 trailing whitespace 阻断;Task 5 白名单范围检查已通过。
|
||||
|
||||
## 停止长任务交接(2026-09-23)
|
||||
|
||||
本次停止请求已立即停止后续工作;当前没有仍在运行的测试或命令,没有回滚工作区改动,也未提交 commit。
|
||||
|
||||
本次停止请求本身未启动新命令,因此没有新增命令输出。停止前最近一次真实验证结果保持不变:六个单文件测试分别为 `55/55`、`42/42`、`46/46`、`51/51`、`46/46`、`42/42`,合并回归为 `Ran 282 tests ... OK`;白名单 Ruff format/check 和白名单 `git diff --check` 均 exit 0。
|
||||
|
||||
剩余阻塞/concerns:
|
||||
|
||||
- 目录级 Ruff format 仍因 4 个非白名单文件未格式化而 exit 1:`module_business/dao/test_batch_dao.py`、`module_business/service/test_batch_service.py`、`tests/module_business/test_sql_contract.py`、`tests/module_business/test_test_batch_service.py`。
|
||||
- 全仓 `git diff --check` 仍因用户已有 `shierfeng-fastapi-backend/logs/2026/09/20/info.log` trailing whitespace 而 exit 2;未修改该日志。
|
||||
|
||||
## Task 5 Fix Round 1(2026-09-23)
|
||||
|
||||
### Status
|
||||
|
||||
DONE_WITH_CONCERNS。review 指出的生产缺陷已修复,并创建了退回申请 `IntegrityError` 入口回归和事件历史指针 scope SQL 回归;未执行真实数据库写入或迁移。
|
||||
|
||||
### 修复内容
|
||||
|
||||
- `ReturnService.request_return_services` 现在向 `_recover_request_integrity_error` 传入 `test_scope`;入口回归通过真实恢复分支验证同一 TEST batch 的幂等赢家稳定返回。
|
||||
- 六个 `IntegrityError` recovery helper 在任何授权或业务查询前调用 `lock_active_batch`;退回申请的 site 重授权 helper 不再在 site 行锁后锁 batch。退回申请、确认及使用在预读 rollback 后开始的新事务也先重锁 batch。
|
||||
- `InventoryDao.add_event` 现在强制接收 `scope`,历史 `last_event_id` 查询加入 `batch_scope_condition(BizPalletEvent.test_batch_id, scope)`;五个服务的全部事件写入调用均传递当前 scope。
|
||||
- 入库和发货 DELETE OpenAPI summary/description 更新为固定拒绝消息:`不支持单条物理删除,请通过测试数据批次清理`。
|
||||
- 业务测试中的事件 DAO 替身更新为四参 API。
|
||||
|
||||
### TDD 与验证命令(真实输出摘要)
|
||||
|
||||
1. RED:`cd shierfeng-fastapi-backend && PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_return_service.ReturnRequestTransactionRedTest.test_request_integrity_error_recovers_same_test_batch_idempotently -v`:exit 1,`TypeError: ReturnService._recover_request_integrity_error() missing 1 required positional argument: 'test_scope'`。
|
||||
2. RED:`cd shierfeng-fastapi-backend && PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inventory_dao.TestDaoPublicContract.test_inventory_dao_critical_parameter_names_are_stable tests.module_business.test_inventory_dao.TestInventoryDao.test_add_event_reads_exact_previous_pointer_and_updates_inventory -v`:exit 1,`add_event` 缺少 `scope` 参数,调用报 `takes 4 positional arguments but 5 were given`。
|
||||
3. GREEN:上述退回入口单测:`Ran 1 test in 0.023s`,`OK`。
|
||||
4. GREEN:`TestDaoPublicContract.test_inventory_dao_critical_parameter_names_are_stable` 及三个 `add_event` DAO 单测:`Ran 4 tests in 0.021s`,`OK`。
|
||||
5. 单文件:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inbound_service -v`:`Ran 55 tests ... OK`。
|
||||
6. 单文件:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_filling_service -v`:`Ran 42 tests ... OK`。
|
||||
7. 单文件:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_shipment_service -v`:`Ran 46 tests ... OK`。
|
||||
8. 单文件:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_usage_service -v`:`Ran 51 tests ... OK`。
|
||||
9. 单文件:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_return_service -v`:`Ran 47 tests ... OK`。
|
||||
10. 单文件:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inventory_dao -v`:`Ran 42 tests ... OK`。
|
||||
11. 合并:`PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest tests.module_business.test_inbound_service tests.module_business.test_filling_service tests.module_business.test_shipment_service tests.module_business.test_usage_service tests.module_business.test_return_service tests.module_business.test_inventory_dao -v`:`Ran 283 tests in 1.761s`,`OK`。计数为 283 是本轮新增退回恢复回归的结果。
|
||||
12. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m ruff format --check <18 个白名单文件>`:`18 files already formatted`,exit 0。
|
||||
13. `PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m ruff check <18 个白名单文件>`:`All checks passed!`,exit 0。
|
||||
14. `git diff --check -- <18 个白名单文件及本报告>`:无输出,exit 0(在追加本段前执行;追加后会在提交前重新执行)。
|
||||
|
||||
### Concerns
|
||||
|
||||
- 本轮新增的 TEST scope 入口成功回归目前集中于退回申请;入库、装填、发货和使用的既有业务成功回归仍以 `TestBatchScope.formal()` 为主。生产路径、DAO SQL scope 条件和六文件回归均已验证,但尚未为这四条链路分别补齐 TEST batch 成功、跨批次拒绝、CLOSED 拒绝、派生字段和 rollback 的专门用例。
|
||||
- 目录级 Ruff format 和全仓 `git diff --check` 的既有非白名单阻塞仍按上文保留,未修改用户文件。
|
||||
|
||||
## Task 5 接管后补充(2026-09-23)
|
||||
|
||||
### 本轮修复
|
||||
|
||||
- 为正式模式 `SimpleNamespace` 仓库替身补齐 `test_batch_id=None`,保留服务层对已加载对象的强制 scope 校验。
|
||||
- 入库、装填、发货扫码路径对仓库/现场、托盘、库存和活动装填记录增加显式 `assert_record_in_scope` 防御校验;不放宽 DAO 过滤。
|
||||
- 装填与发货各增加 TEST scope 跨批次写入回滚回归;装填与发货扫码各增加跨批次对象拒绝回归。
|
||||
|
||||
### 最新验证
|
||||
|
||||
1. 红测:装填与发货跨批次扫码用例在新增服务校验前分别以 `ServiceException not raised` 失败;两条写入用例同时确认回滚路径可用。
|
||||
2. 绿测:四条新增回归:`Ran 4 tests ... OK`。
|
||||
3. 六文件合并:`PYTHONDONTWRITEBYTECODE=1 OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 /Users/jiaolongyan/miniconda3/envs/ShiErFeng/bin/python -u -m unittest tests.module_business.test_inbound_service tests.module_business.test_filling_service tests.module_business.test_shipment_service tests.module_business.test_usage_service tests.module_business.test_return_service tests.module_business.test_inventory_dao -v`:`Ran 290 tests ... OK`。
|
||||
4. 18 文件 `ruff format --check`:`18 files already formatted`;`ruff check`:`All checks passed!`。
|
||||
5. 18 文件范围 `git diff --check`:无输出,exit 0。
|
||||
|
||||
当前 Task 5 代码已具备提交条件;本报告仍只保留为本地审查材料,不应纳入业务提交。
|
||||
@@ -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)。
|
||||
|
||||
@@ -45,9 +45,17 @@
|
||||
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
|
||||
* Android PDA 可通过基于 uni-app 的 H5 页面使用,并支持 HID 扫码枪扫码后回车提交。
|
||||
|
||||
本轮交付包含 PDA H5 页面,但暂不打包 APK 或其他原生安装包,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./docs/pallet-workflow-deployment.md)。
|
||||
本轮交付包含 PDA H5 页面,但暂不打包 APK 或其他原生安装包,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./shierfeng-fastapi-backend/sql/pallet-workflow-deployment.md)。
|
||||
|
||||
测试数据批次的创建、授权、历史数据纳入和清理流程请阅读[测试数据批次操作说明](./docs/test-data-batch-operations.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 可能删除此前被跟踪的副本。此次清理不重写历史,旧提交中的文件仍然存在。
|
||||
|
||||
## 在线体验
|
||||
|
||||
@@ -136,7 +144,7 @@ shierfeng app run --env=dev
|
||||
密码:请在目标环境安全配置中设置后手工输入,不在仓库中记录
|
||||
|
||||
# 浏览器访问
|
||||
地址:http://localhost:80
|
||||
地址:http://localhost:8080
|
||||
```
|
||||
|
||||
### 发布
|
||||
@@ -163,10 +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
|
||||
```
|
||||
|
||||
浏览器访问:http://localhost:8080;后端直连接口为 `http://localhost:9099`;宿主机连接 MySQL 时使用 `127.0.0.1:13307`。需要局域网访问时,将根目录 `.env` 中的 `COMPOSE_BIND_IP` 改为 `0.0.0.0`,再使用宿主机局域网地址访问前端和后端端口。
|
||||
|
||||
已有数据库不能通过全量初始化脚本升级。请先备份并核对目标库,再按照 `shierfeng-fastapi-backend/sql/patches/` 下对应的 MySQL 增量补丁和部署文档执行;不要通过删除 Compose 数据卷来代替迁移。
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 缓存清理。
|
||||
@@ -1,106 +0,0 @@
|
||||
# PDA 托盘业务验收与培训手册
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本手册用于服务器、数据库和 PDA 设备准备完成后的现场验收与客户培训。它不替代部署、数据库迁移或 APK 打包,也不把本地构建结果视为真机验收结果。
|
||||
|
||||
当前代码交付物是 uni-app PDA 页面、接口封装、HID 扫码组件和可验证 H5 构建;APK 按当前范围暂不生成。
|
||||
|
||||
## 2. 验收前置条件
|
||||
|
||||
- 后端服务已部署并可从 PDA 访问,已确定 `VITE_APP_BASE_API` 对应地址。
|
||||
- 数据库已执行与目标数据库类型匹配的初始化 SQL/业务补丁,并完成备份。
|
||||
- Redis、JWT、传输加密和验证码配置已按目标环境确认。
|
||||
- 已准备管理员、仓库操作员、客户现场操作员三类账号,并分别配置仓库/现场数据范围。
|
||||
- 已准备至少两个空托盘、一个已装填托盘、一个可部分使用托盘和一个可退回托盘;每个托盘二维码唯一且可扫描。
|
||||
- PDA 与扫码头处于 HID 键盘输入模式,扫码结束符配置为回车;网络稳定性和离线恢复策略已确认。
|
||||
- 验收记录表包含:用例编号、账号、托盘码、时间、预期结果、实际结果、截图/日志、问题编号和签字人。
|
||||
|
||||
## 3. 角色验收
|
||||
|
||||
| 角色 | 应能操作 | 不应能操作 |
|
||||
| --- | --- | --- |
|
||||
| 管理员 | 在授权范围内查看基础资料、业务单、报表和追溯 | 不应绕过后端权限与数据范围 |
|
||||
| 仓库操作员 | 入库、装填、发货、退回收货 | 不应操作未授权仓库或客户现场 |
|
||||
| 客户现场操作员 | 扫码查看托盘、绑定产线、录入使用、发起退回 | 不应确认仓库收货或访问未授权现场 |
|
||||
|
||||
登录页默认填充用户名 `admin`;密码必须由操作员现场手工输入,不记录在测试单、日志或源码中。
|
||||
|
||||
## 4. 核心业务验收用例
|
||||
|
||||
### 4.1 基础资料与二维码
|
||||
|
||||
1. Web 后台新增菌种、客户、现场、产线、仓库和托盘。
|
||||
2. 为托盘设置容量,生成并查看固定二维码。
|
||||
3. 重复托盘编号或二维码应被拒绝;停用托盘不能进入业务流程。
|
||||
4. 为仓库和现场分配用户,使用受限账号确认数据范围。
|
||||
|
||||
### 4.2 入库
|
||||
|
||||
1. 仓库用户选择授权仓库,扫描 `UNSTOCKED` 托盘。
|
||||
2. 重复扫描同一托盘应被前端提示并不会重复加入。
|
||||
3. 创建入库草稿,移除一条明细后保存并重新进入详情。
|
||||
4. 确认入库,检查库存状态变为仓库空托盘,并检查托盘事件。
|
||||
5. 在响应丢失后使用同一操作重试,确认不会重复入库。
|
||||
|
||||
### 4.3 装填
|
||||
|
||||
1. 扫描仓库空托盘,确认页面显示仓库、容量和当前余量。
|
||||
2. 选择菌种,录入批次、规格和正整数包数。
|
||||
3. 使用 0、负数、超容量和未授权仓库数据验证后端拒绝。
|
||||
4. 提交成功后检查状态、菌种、批次、初始包数和装填事件。
|
||||
|
||||
### 4.4 发货
|
||||
|
||||
1. 选择授权来源仓库和目标客户现场,连续扫描可发货托盘。
|
||||
2. 检查页面展示的菌种、批次和剩余包数为服务端数据。
|
||||
3. 创建草稿并确认,检查来源库存扣减、现场位置和发货事件。
|
||||
4. 用同一幂等键重试确认,确认不会重复扣减库存。
|
||||
|
||||
### 4.5 客户现场使用
|
||||
|
||||
1. 现场用户扫描已发货托盘,确认菌种、批次和剩余包数可读。
|
||||
2. 首次使用选择当前现场产线,录入正整数使用包数。
|
||||
3. 使用小于余量的包数,确认状态为部分使用且剩余包数正确。
|
||||
4. 使用剩余全部包数,确认状态为现场空托盘且剩余包数为 0。
|
||||
5. 尝试切换已绑定的其他产线、超出余量或使用 0 包,确认请求被拒绝。
|
||||
|
||||
### 4.6 退回
|
||||
|
||||
1. 现场用户选择目标仓库并扫描可退回托盘,提交退回申请。
|
||||
2. 检查托盘状态进入退回中,不能再次发货或重复申请。
|
||||
3. 仓库用户查看退回详情,扫描整集合托盘并确认收货。
|
||||
4. 漏扫、错扫、重复扫和余量快照变化均应阻止确认。
|
||||
5. 确认成功后检查仓库库存、托盘状态和退回事件。
|
||||
|
||||
## 5. 报表、权限与异常验收
|
||||
|
||||
- 查看托盘库存、装填、发货、产线消耗、退回和托盘追溯六类报表。
|
||||
- 用受限账号验证未授权菜单不显示,直接调用接口返回 403。
|
||||
- 检查关键写操作包含操作人、时间、业务单和托盘事件。
|
||||
- 在扫码请求期间连续回车,确认输入锁生效且不会并发提交。
|
||||
- 断网后重试,确认使用原幂等键;网络恢复后不会重复扣减库存。
|
||||
- 使用非法状态、库存不足、错误仓库/现场和旧详情快照,确认错误可见且状态不被部分写入。
|
||||
|
||||
## 6. 培训顺序
|
||||
|
||||
1. 讲解托盘二维码、状态和“服务端结果为准”原则。
|
||||
2. 仓库操作员练习入库、装填、发货和退回收货。
|
||||
3. 客户现场操作员练习扫码、产线选择、部分/全部使用和退回申请。
|
||||
4. 管理员练习基础资料、用户数据范围、报表和托盘追溯。
|
||||
5. 演练重复扫码、断网重试、无权限和库存不足,不使用真实业务数据做首次演示。
|
||||
6. 培训结束后由各角色完成一条完整闭环:入库 → 装填 → 发货 → 使用 → 退回。
|
||||
|
||||
## 7. 当前不包含事项
|
||||
|
||||
- 本阶段不打包、签名、安装或发布 APK。
|
||||
- 本阶段不调用摄像头扫码,不新增原生扫码 SDK;扫码方式为 HID 扫码头加手工输入兜底。
|
||||
- 本手册不执行生产部署、数据库迁移、密码重置或客户培训;这些动作需在目标环境中按前置条件执行并留存记录。
|
||||
|
||||
## 8. 代码证据
|
||||
|
||||
- 设计与需求覆盖矩阵:`docs/superpowers/specs/2026-09-20-pda-business-workflow-design.md`
|
||||
- PDA 页面与接口:`shierfeng-fastapi-app/src/pages/business`、`shierfeng-fastapi-app/src/api/business`
|
||||
- Web 基础资料、业务和报表:`shierfeng-fastapi-frontend/src/views`
|
||||
- 后端业务与状态机:`shierfeng-fastapi-backend/module_business`
|
||||
- 数据库结构:`shierfeng-fastapi-backend/sql`
|
||||
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,721 +0,0 @@
|
||||
# PDA 托盘业务操作功能 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:** 在不改变 FastAPI 业务契约的前提下,为 `shierfeng-fastapi-app` 增加可在 H5/开发环境验证的托盘入库、装填、发货、现场使用和退回操作。
|
||||
|
||||
**Architecture:** PDA 复用现有 `request`、JWT、传输加密和后端业务接口;API 封装按业务模块拆分,扫码输入和幂等键生成作为无业务状态的共享工具。五个页面各自维护表单、扫码会话、请求代数和服务端刷新状态,后端继续作为权限、库存、状态机、产线绑定和退回集合校验的唯一权威。
|
||||
|
||||
**Tech Stack:** uni-app Vue 3、Pinia、Tailwind CSS、现有 `src/utils/request.js`、Node 20 `node:test`、pnpm;不增加依赖。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-20-pda-business-workflow-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 不打包、签名、安装或发布 APK;只运行 PDA 的 H5/开发构建。
|
||||
- 不调用摄像头扫码,不新增原生扫码 SDK;扫码输入必须兼容 HID 键盘逐字符输入后回车提交,并保留手工输入。
|
||||
- 不修改现有 FastAPI 业务接口的请求/响应契约,不在 PDA 前端计算最终库存或托盘状态。
|
||||
- 基础资料维护和六类报表继续使用 Web 后台,不复制到 PDA。
|
||||
- 不把管理员密码、Token、Cookie 或其他凭据写入源码、测试固定文本或日志;登录表单不得预填密码。
|
||||
- 不修改 `.env.dev`、`.env.prod`、前端环境文件、用户已有 APP 改动、后端日志和无关缓存。
|
||||
- 不安装、升级或删除依赖;使用仓库已有 `node_modules`、Node 20 和 pnpm 配置。
|
||||
- 所有业务写请求必须使用后端要求的 camelCase 字段和幂等键;网络失败后直接重试复用原键,改变业务输入后才生成新键。
|
||||
- 页面入口只按当前用户权限显示;页面内按钮权限与后端 401/403 校验同时保留,前端隐藏不能替代后端权限。
|
||||
- 每个提交只包含本任务明确列出的文件;暂存和提交使用显式路径,禁止 `git add -A`、`git clean`、重置或覆盖用户已有改动。
|
||||
- 每个任务完成后使用中文提交说明,并用 `git commit --only` 保护工作区中既有暂存内容。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构与责任边界
|
||||
|
||||
### 新建文件
|
||||
|
||||
- `shierfeng-fastapi-app/src/api/business/inbound.js`:首次入库列表、选项、扫码、详情、草稿增改删和确认请求。
|
||||
- `shierfeng-fastapi-app/src/api/business/filling.js`:装填列表、菌种选项、扫码、详情和装填提交请求。
|
||||
- `shierfeng-fastapi-app/src/api/business/shipment.js`:发货列表、仓库/现场选项、扫码、详情、草稿增改删和确认请求。
|
||||
- `shierfeng-fastapi-app/src/api/business/usage.js`:使用列表、产线选项、扫码、详情和使用提交请求。
|
||||
- `shierfeng-fastapi-app/src/api/business/return.js`:退回列表、仓库选项、扫码、申请、详情和确认请求。
|
||||
- `shierfeng-fastapi-app/src/utils/palletScan.js`:扫码值规范化、短时重复判断和 Web Crypto UUID v4 幂等键生成;不保存业务状态。
|
||||
- `shierfeng-fastapi-app/src/components/business/PalletScanInput.vue`:HID/手工输入、回车提交、空值校验、短时重复提示和输入焦点管理。
|
||||
- `shierfeng-fastapi-app/src/pages/business/inbound/index.vue`:入库草稿编辑、扫码、删除和确认。
|
||||
- `shierfeng-fastapi-app/src/pages/business/filling/index.vue`:单托盘装填。
|
||||
- `shierfeng-fastapi-app/src/pages/business/shipment/index.vue`:发货草稿编辑、扫码、删除和确认。
|
||||
- `shierfeng-fastapi-app/src/pages/business/usage/index.vue`:现场使用、产线选择和服务端结果展示。
|
||||
- `shierfeng-fastapi-app/src/pages/business/return/index.vue`:退回申请和仓库确认两个独立操作区。
|
||||
- `shierfeng-fastapi-app/tests/business-contract.test.mjs`:Node 20 静态 API/页面/字段/安全契约与扫码工具测试。
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `shierfeng-fastapi-app/package.json`:增加不依赖新包的 `test:business` 脚本。
|
||||
- `shierfeng-fastapi-app/src/pages.json`:注册五个业务页面路由。
|
||||
- `shierfeng-fastapi-app/src/pages/login.vue`:移除旧默认密码预填值,保留现有认证流程。
|
||||
- `shierfeng-fastapi-app/src/pages/work/index.vue`:增加按权限过滤的五类 PDA 业务入口。
|
||||
|
||||
### 明确不修改
|
||||
|
||||
- `shierfeng-fastapi-backend/module_business/**`:后端接口已存在,本计划只消费其当前契约。
|
||||
- `shierfeng-fastapi-frontend/**`:Web 端作为接口和交互参考,不复制改动。
|
||||
- `shierfeng-fastapi-app/src/utils/request.js`、传输加密、Pinia 用户会话和环境配置:除非测试证明共享层存在直接阻塞,否则保持现状。
|
||||
|
||||
## 已核实的后端接口与字段
|
||||
|
||||
| 模块 | 查询/选项/扫码 | 写请求 | 必须保持的写入字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| 入库 | `GET /business/inbound/list`、`/warehouse-options`、`/scan/{qrToken}?warehouseId=`、`/{inboundId}` | `POST /business/inbound`、`PUT /business/inbound`、`DELETE /business/inbound/{ids}`、`PUT /business/inbound/{id}/confirm` | 草稿:`warehouseId,palletIds`;确认:`idempotencyKey` |
|
||||
| 装填 | `GET /business/filling/list`、`/strain-options`、`/scan/{qrToken}`、`/{fillingId}` | `POST /business/filling` | `qrToken,warehouseId,strainId,batchNo,packageSpec,initialPackages,idempotencyKey` |
|
||||
| 发货 | `GET /business/shipment/list`、`/warehouse-options`、`/site-options`、`/scan/{qrToken}?sourceWarehouseId=&targetSiteId=`、`/{shipmentId}` | `POST /business/shipment`、`PUT /business/shipment`、`DELETE /business/shipment/{ids}`、`PUT /business/shipment/{id}/confirm` | 草稿:`sourceWarehouseId,targetSiteId,palletIds`;确认:`idempotencyKey` |
|
||||
| 使用 | `GET /business/usage/list`、`/line-options?siteId=`、`/scan/{qrToken}`、`/{usageId}` | `POST /business/usage` | `qrToken,lineId,usedPackages,idempotencyKey` |
|
||||
| 退回 | `GET /business/return/list`、`/warehouse-options`、`/scan/{qrToken}?mode=&returnId=`、`/{returnId}` | `POST /business/return/request`、`PUT /business/return/{id}/confirm` | 申请:`sourceSiteId,targetWarehouseId,palletIds,idempotencyKey`;确认:`scannedPalletIds,idempotencyKey` |
|
||||
|
||||
后端扫码响应统一使用 `data.palletId/palletCode/flowState/warehouseId/siteId/currentLineId/strainId/strainCode/strainName/batchNo/packageSpec/capacityPackages/remainingPackages/allowedActions`;列表响应使用 `rows` 和 `total`;详情使用 `data`。装填和使用写请求没有单独确认接口,成功后以列表、扫码结果或使用响应为准。
|
||||
|
||||
## Task 1: 用测试锁定 API 封装、扫码工具和幂等键
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
- Create: `shierfeng-fastapi-app/src/api/business/inbound.js`
|
||||
- Create: `shierfeng-fastapi-app/src/api/business/filling.js`
|
||||
- Create: `shierfeng-fastapi-app/src/api/business/shipment.js`
|
||||
- Create: `shierfeng-fastapi-app/src/api/business/usage.js`
|
||||
- Create: `shierfeng-fastapi-app/src/api/business/return.js`
|
||||
- Create: `shierfeng-fastapi-app/src/utils/palletScan.js`
|
||||
- Modify: `shierfeng-fastapi-app/package.json`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces API exports `listInbound`, `getInboundWarehouseOptions`, `scanInboundPallet`, `getInbound`, `addInbound`, `updateInbound`, `delInbound`, `confirmInbound`;其他四个模块使用已核实接口表中的同义导出名。
|
||||
- Produces `normalizeScanValue(value)`, `isDuplicateScan(value,lastValue,scannedAt,lastScannedAt,duplicateWindowMs)` 和 `createIdempotencyKey(cryptoProvider)`。
|
||||
- All API wrappers import `request` from `@/utils/request` and return its promise without改写响应结构。
|
||||
|
||||
各模块的完整导出名固定如下,页面不得自行拼接 URL:
|
||||
|
||||
- `inbound.js`:`listInbound(query)`、`getInboundWarehouseOptions()`、`scanInboundPallet(qrToken,query)`、`getInbound(inboundId)`、`addInbound(data)`、`updateInbound(data)`、`delInbound(inboundIds)`、`confirmInbound(inboundId,data)`。
|
||||
- `filling.js`:`listFilling(query)`、`getFillingStrainOptions()`、`scanFillingPallet(qrToken)`、`getFilling(fillingId)`、`addFilling(data)`。
|
||||
- `shipment.js`:`listShipment(query)`、`getShipmentWarehouseOptions()`、`getShipmentSiteOptions()`、`scanShipmentPallet(qrToken,query)`、`getShipment(shipmentId)`、`addShipment(data)`、`updateShipment(data)`、`delShipment(shipmentIds)`、`confirmShipment(shipmentId,data)`。
|
||||
- `usage.js`:`listUsage(query)`、`getUsageLineOptions(siteId)`、`scanUsagePallet(qrToken)`、`getUsage(usageId)`、`addUsage(data)`。
|
||||
- `return.js`:`listReturn(query)`、`getReturnWarehouseOptions()`、`scanReturnPallet(qrToken,query)`、`requestReturn(data)`、`getReturn(returnId)`、`confirmReturn(returnId,data)`。
|
||||
|
||||
- [ ] **Step 1: 先写 API 与工具的失败测试**
|
||||
|
||||
在测试文件中用 `readFile` 读取五个 API 文件,用正则逐个断言 URL、HTTP 方法和关键参数名称;同时为工具写可执行单元测试:
|
||||
|
||||
```js
|
||||
test('扫码值只去除首尾空白并拒绝空值', async () => {
|
||||
const { normalizeScanValue } = await import('../src/utils/palletScan.js')
|
||||
assert.equal(normalizeScanValue(' QR-001\n'), 'QR-001')
|
||||
assert.throws(() => normalizeScanValue(' \n\t '), /二维码不能为空/)
|
||||
})
|
||||
|
||||
test('幂等键使用 randomUUID 或安全随机字节,不使用时间和伪随机数', async () => {
|
||||
const { createIdempotencyKey } = await import('../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('同一值只在重复时间窗口内被识别', async () => {
|
||||
const { isDuplicateScan } = await import('../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)
|
||||
})
|
||||
```
|
||||
|
||||
API 契约测试至少覆盖以下请求:入库和发货的列表、选项、扫码、详情、草稿增改删、确认;装填和使用的列表、选项、扫码、详情、提交;退回的列表、选项、扫码、申请、详情、确认。测试还要断言确认/写请求的函数名和 `data`/`params` 位置,防止把 query 字段误放入 body。
|
||||
|
||||
- [ ] **Step 2: 运行失败测试确认缺口**
|
||||
|
||||
工作目录:`shierfeng-fastapi-app`
|
||||
|
||||
```bash
|
||||
node --test tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
预期:因五个 API 模块、扫码工具和测试脚本尚未存在而失败;此时不修改后端或依赖。
|
||||
|
||||
- [ ] **Step 3: 实现最小 API 封装和测试脚本**
|
||||
|
||||
`package.json` 只增加以下脚本,不增加依赖:
|
||||
|
||||
```json
|
||||
"test:business": "node --test tests/business-contract.test.mjs"
|
||||
```
|
||||
|
||||
每个 API 文件只做路径和参数映射。例如入库模块必须保持如下接口形状:
|
||||
|
||||
```js
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listInbound(query) {
|
||||
return request({ url: '/business/inbound/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function scanInboundPallet(qrToken, query) {
|
||||
return request({ url: '/business/inbound/scan/' + qrToken, method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function addInbound(data) {
|
||||
return request({ url: '/business/inbound', method: 'post', data })
|
||||
}
|
||||
|
||||
export function confirmInbound(inboundId, data) {
|
||||
return request({
|
||||
url: '/business/inbound/' + inboundId + '/confirm',
|
||||
method: 'put',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
其他函数按接口表逐一实现,不增加前端字段别名。`createIdempotencyKey` 优先使用 `crypto.randomUUID()`,否则用 `crypto.getRandomValues(new Uint8Array(16))` 按 UUID v4 设置版本和变体位;缺少安全随机源时抛出明确错误,不使用 `Date.now()`、`Math.random()` 或账号信息。
|
||||
|
||||
- [ ] **Step 4: 运行测试确认 API 与工具契约通过**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
```
|
||||
|
||||
预期:本任务新增的 API 路径、HTTP 方法、参数位置、扫码规范化、重复窗口和 UUID 测试通过;页面相关测试在后续任务加入。
|
||||
|
||||
- [ ] **Step 5: 只提交本任务文件**
|
||||
|
||||
```bash
|
||||
git add -- \
|
||||
shierfeng-fastapi-app/package.json \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs \
|
||||
shierfeng-fastapi-app/src/api/business/inbound.js \
|
||||
shierfeng-fastapi-app/src/api/business/filling.js \
|
||||
shierfeng-fastapi-app/src/api/business/shipment.js \
|
||||
shierfeng-fastapi-app/src/api/business/usage.js \
|
||||
shierfeng-fastapi-app/src/api/business/return.js \
|
||||
shierfeng-fastapi-app/src/utils/palletScan.js
|
||||
git commit --only -m '新增:PDA 业务 API 与扫码幂等工具' -- \
|
||||
shierfeng-fastapi-app/package.json \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs \
|
||||
shierfeng-fastapi-app/src/api/business/inbound.js \
|
||||
shierfeng-fastapi-app/src/api/business/filling.js \
|
||||
shierfeng-fastapi-app/src/api/business/shipment.js \
|
||||
shierfeng-fastapi-app/src/api/business/usage.js \
|
||||
shierfeng-fastapi-app/src/api/business/return.js \
|
||||
shierfeng-fastapi-app/src/utils/palletScan.js
|
||||
```
|
||||
|
||||
## Task 2: 先测试再实现 HID 扫码输入组件
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/src/components/business/PalletScanInput.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes `normalizeScanValue` and `isDuplicateScan` from `@/utils/palletScan`.
|
||||
- Props: `disabled:Boolean=false`、`loading:Boolean=false`、`duplicateWindowMs:Number=500`。
|
||||
- Emits: `scan(normalizedValue)`、`invalid(message)`、`duplicate(normalizedValue)`。
|
||||
- Exposes no business state; parent controls request lock through `loading` and owns accepted pallet rows.
|
||||
|
||||
- [ ] **Step 1: 写组件静态契约测试并确认 RED**
|
||||
|
||||
在 `business-contract.test.mjs` 读取组件源码,断言存在 `@confirm="handleSubmit"`、`normalizeScanValue`、`isDuplicateScan`、三个 emit 名称、`:disabled="disabled || loading"` 或等价锁定逻辑,并断言没有 `uni.scanCode`、摄像头 API 或状态机字段判断。执行 `pnpm test:business`,预期组件测试失败。
|
||||
|
||||
- [ ] **Step 2: 实现组件输入状态机**
|
||||
|
||||
组件使用 uni-app 原生 `<input>`,同时绑定 `@confirm`(HID 回车和手工确认)与一个可点击的“提交扫描”按钮;输入框在 `disabled` 或 `loading` 时只读/禁用。提交逻辑必须保持以下顺序:
|
||||
|
||||
```js
|
||||
function handleSubmit() {
|
||||
if (props.disabled || props.loading) return
|
||||
let value
|
||||
try {
|
||||
value = normalizeScanValue(scanValue.value)
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
emit('invalid', error.message)
|
||||
focusInput(true)
|
||||
return
|
||||
}
|
||||
const scannedAt = Date.now()
|
||||
if (isDuplicateScan(value, lastScanValue, scannedAt, lastScanAt, props.duplicateWindowMs)) {
|
||||
scanValue.value = ''
|
||||
emit('duplicate', value)
|
||||
focusInput()
|
||||
return
|
||||
}
|
||||
lastScanValue = value
|
||||
lastScanAt = scannedAt
|
||||
scanValue.value = ''
|
||||
emit('scan', value)
|
||||
focusInput()
|
||||
}
|
||||
```
|
||||
|
||||
`Date.now()`只用于比较短时重复输入,不得用于生成业务幂等键。组件自动聚焦、扫描完成后清空输入并重新聚焦;页面切换、请求进行中和权限不足时由父页面传入 `disabled/loading`。
|
||||
|
||||
- [ ] **Step 3: 运行组件契约和工具测试确认 GREEN**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
```
|
||||
|
||||
预期:组件的 HID 回车、手工提交、空值、重复值和请求锁静态契约通过。
|
||||
|
||||
- [ ] **Step 4: 提交组件**
|
||||
|
||||
```bash
|
||||
git add -- shierfeng-fastapi-app/src/components/business/PalletScanInput.vue shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA HID 扫码输入组件' -- \
|
||||
shierfeng-fastapi-app/src/components/business/PalletScanInput.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
## Task 3: 注册路由、移除密码预填并增加权限入口
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `shierfeng-fastapi-app/src/pages.json`
|
||||
- Modify: `shierfeng-fastapi-app/src/pages/login.vue`
|
||||
- Modify: `shierfeng-fastapi-app/src/pages/work/index.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces routes `pages/business/inbound/index`、`pages/business/filling/index`、`pages/business/shipment/index`、`pages/business/usage/index`、`pages/business/return/index`。
|
||||
- Uses existing `$auth.hasPermiOr`/`$auth.hasPermiAnd` and `$tab.navigateTo`; does not change Pinia or permission storage.
|
||||
|
||||
- [ ] **Step 1: 写路由、权限和凭据安全测试并确认 RED**
|
||||
|
||||
测试必须断言五个页面路径和标题存在,工作台包含五类业务权限字符串,且 `src/pages/login.vue` 不再包含旧默认密码或其他密码字面量。测试也要断言入口跳转路径与页面路径一致。
|
||||
|
||||
- [ ] **Step 2: 注册五个页面并实现入口过滤**
|
||||
|
||||
在 `pages.json` 按工作台之后加入五个页面,使用中文标题“仓库入库”“托盘装填”“发货出库”“现场使用”“退回仓库”。工作台增加“现场业务”区块,入口权限使用以下 OR 集合:
|
||||
|
||||
```js
|
||||
const businessEntries = [
|
||||
{ path: '/pages/business/inbound/index', permissions: ['business:inbound:list', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', 'business:inbound:remove', 'business:inbound:confirm'] },
|
||||
{ path: '/pages/business/filling/index', permissions: ['business:filling:list', 'business:filling:query', 'business:filling:add'] },
|
||||
{ path: '/pages/business/shipment/index', permissions: ['business:shipment:list', 'business:shipment:query', 'business:shipment:add', 'business:shipment:edit', 'business:shipment:remove', 'business:shipment:confirm'] },
|
||||
{ path: '/pages/business/usage/index', permissions: ['business:usage:list', 'business:usage:query', 'business:usage:add'] },
|
||||
{ path: '/pages/business/return/index', permissions: ['business:return:list', 'business:return:query', 'business:return:request', 'business:return:confirm'] }
|
||||
]
|
||||
```
|
||||
|
||||
模板只渲染 `proxy.$auth.hasPermiOr(entry.permissions)` 为真的入口;点击前再次检查权限并调用 `$tab.navigateTo(entry.path)`。页面没有任何授权时不显示业务区块。保留现有非本任务系统入口,不扩展其他模块。
|
||||
|
||||
- [ ] **Step 3: 清除登录页密码默认值**
|
||||
|
||||
将 `loginForm` 的 `username` 和 `password` 初始值改为空字符串,验证码和登录 API 流程保持不变;开发联调账号只在人工输入时使用,不写入源码、测试或日志。
|
||||
|
||||
- [ ] **Step 4: 运行测试确认 GREEN**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
```
|
||||
|
||||
预期:五个路由、权限入口、登录安全静态契约通过。
|
||||
|
||||
- [ ] **Step 5: 提交页面壳层改动**
|
||||
|
||||
```bash
|
||||
git add -- \
|
||||
shierfeng-fastapi-app/src/pages.json \
|
||||
shierfeng-fastapi-app/src/pages/login.vue \
|
||||
shierfeng-fastapi-app/src/pages/work/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA 业务页面路由与权限入口' -- \
|
||||
shierfeng-fastapi-app/src/pages.json \
|
||||
shierfeng-fastapi-app/src/pages/login.vue \
|
||||
shierfeng-fastapi-app/src/pages/work/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
## Task 4: 实现首次入库 PDA 页面
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/src/pages/business/inbound/index.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes all exports from `src/api/business/inbound.js`, `PalletScanInput`, `createIdempotencyKey` and `$auth`.
|
||||
- Produces page methods `handleInboundScan`, `submitInboundDraft`, `saveInboundUpdate`, `confirmInboundDraft`, `removeScannedPallet`, `resetInboundOperation`。
|
||||
- Page state stores only scanned display rows, draft form values, request/loading/error state and confirm idempotency key; it never stores a client-calculated inventory state。
|
||||
|
||||
- [ ] **Step 1: 写入库页面契约测试并确认 RED**
|
||||
|
||||
测试读取页面源码并断言:
|
||||
|
||||
1. 使用 `PalletScanInput`,扫描调用 `scanInboundPallet(qrToken, { warehouseId })`。
|
||||
2. 只接受 `flowState === 'UNSTOCKED'`,按 `palletId` 去重。
|
||||
3. 草稿 payload 只含 `{ warehouseId, palletIds }`,编辑 payload 只含 `{ inboundId, warehouseId, palletIds }`。
|
||||
4. 确认调用 `confirmInbound(inboundId, { idempotencyKey })`,失败分支不清空当前键。
|
||||
5. 编辑/删除/确认按钮分别包含 `business:inbound:edit/remove/confirm`,入口操作需要同时具备 `query` 与对应写权限。
|
||||
|
||||
- [ ] **Step 2: 实现仓库选项、草稿扫码和编辑状态**
|
||||
|
||||
页面加载授权仓库选项和第一页列表;新增时选择仓库后允许连续扫描。每次扫码携带当前仓库 ID,接受服务端返回的 `palletId/palletCode/flowState`,重复托盘只提示不追加。切换仓库清空当前扫描集合并使旧扫码响应失效。
|
||||
|
||||
编辑时先调用 `getInbound(inboundId)`,只有详情仍为 `DRAFT` 才填充仓库和明细;详情未返回前禁止扫码、保存和删除。列表刷新/页面 `onShow` 以服务端 `rows` 为准,不从本地草稿推导状态。
|
||||
|
||||
- [ ] **Step 3: 实现草稿写入和确认重试**
|
||||
|
||||
```js
|
||||
function submitInboundDraft() {
|
||||
return addInbound({
|
||||
warehouseId: form.value.warehouseId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId)
|
||||
})
|
||||
}
|
||||
|
||||
function confirmInboundDraft(row) {
|
||||
const idempotencyKey = ensureConfirmIdempotencyKey(row.inboundId)
|
||||
confirmLoading.value = true
|
||||
confirmInbound(row.inboundId, { idempotencyKey })
|
||||
.then(() => {
|
||||
clearConfirmIdempotency()
|
||||
loadInboundList()
|
||||
})
|
||||
.catch(() => {
|
||||
confirmError.value = '入库确认失败;再次确认将复用本次幂等键'
|
||||
})
|
||||
.finally(() => {
|
||||
confirmLoading.value = false
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
后端新增草稿响应不保证返回单据 ID,因此保存后只关闭编辑区并重新读取列表,由用户从服务端返回的 `DRAFT` 行选择确认,不按时间或本地数组猜测刚创建的单据。确认成功后清除键;网络超时、401 以外的可重试失败保留键,401 交由现有 `request` 会话处理。
|
||||
|
||||
- [ ] **Step 4: 运行入库契约和构建前静态检查**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
```
|
||||
|
||||
预期:入库页面的字段、状态、权限、幂等键和服务端刷新契约通过。
|
||||
|
||||
- [ ] **Step 5: 提交入库页面**
|
||||
|
||||
```bash
|
||||
git add -- shierfeng-fastapi-app/src/pages/business/inbound/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA 首次入库操作页' -- \
|
||||
shierfeng-fastapi-app/src/pages/business/inbound/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
## Task 5: 实现整托发货 PDA 页面
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/src/pages/business/shipment/index.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes `src/api/business/shipment.js`、`PalletScanInput` 和 `createIdempotencyKey`。
|
||||
- Produces `handleShipmentScan`, `submitShipmentDraft`, `saveShipmentUpdate`, `confirmShipmentDraft`, `removeScannedPallet`。
|
||||
- Scanned rows expose `palletCode`, `strainName/strainCode`, `batchNo`, `remainingPackages` as read-only display values。
|
||||
|
||||
- [ ] **Step 1: 写发货页面失败契约**
|
||||
|
||||
断言页面加载仓库和现场选项,扫描调用 `scanShipmentPallet(qrToken, { sourceWarehouseId, targetSiteId })`,只接受 `flowState === 'FILLED_IN_WAREHOUSE' && remainingPackages > 0`,草稿/确认字段与接口表一致,并保留确认失败时的幂等键。
|
||||
|
||||
- [ ] **Step 2: 实现来源仓库、目标现场和集合扫码**
|
||||
|
||||
来源仓库或目标现场改变时清空托盘集合并使旧请求失效。扫描期间锁定输入和提交按钮;同一 `palletId` 不重复追加。列表、详情和选项失败显示页面内可重试错误,不吞掉后端业务消息。
|
||||
|
||||
- [ ] **Step 3: 实现草稿和确认**
|
||||
|
||||
```js
|
||||
function submitShipmentDraft() {
|
||||
return addShipment({
|
||||
sourceWarehouseId: form.value.sourceWarehouseId,
|
||||
targetSiteId: form.value.targetSiteId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId)
|
||||
})
|
||||
}
|
||||
|
||||
function confirmShipmentDraft(row) {
|
||||
const idempotencyKey = ensureConfirmIdempotencyKey(row.shipmentId)
|
||||
return confirmShipment(row.shipmentId, { idempotencyKey })
|
||||
}
|
||||
```
|
||||
|
||||
保存后重新读取列表;确认前要求 `DRAFT`,成功后重新加载列表和页面状态。错误重试只复用同一确认键,改变单据或托盘集合时清除并重新生成键;不在前端扣减 `remainingPackages`。
|
||||
|
||||
- [ ] **Step 4: 运行测试并提交**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
git add -- shierfeng-fastapi-app/src/pages/business/shipment/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA 整托发货操作页' -- \
|
||||
shierfeng-fastapi-app/src/pages/business/shipment/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
预期:测试通过后才执行提交;提交只包含发货页面和契约测试增量。
|
||||
|
||||
## Task 6: 实现单托盘装填 PDA 页面
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/src/pages/business/filling/index.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes `listFilling`、`getFillingStrainOptions`、`scanFillingPallet`、`addFilling` 和 `PalletScanInput`。
|
||||
- Produces form fields `qrToken`, `warehouseId`, `strainId`, `batchNo`, `packageSpec`, `initialPackages` and one operation-scoped `idempotencyKey`。
|
||||
|
||||
- [ ] **Step 1: 写装填页面失败契约**
|
||||
|
||||
断言页面只接受 `flowState === 'EMPTY_IN_WAREHOUSE'`,扫码成功后展示仓库和容量,提交 payload 包含且只包含后端 `FillingCreateModel` 字段,包数校验为严格正整数,成功/失败状态不伪造库存。
|
||||
|
||||
- [ ] **Step 2: 实现扫码和表单**
|
||||
|
||||
扫码后保存服务端返回的 `qrToken`、`warehouseId`、`palletCode`、`capacityPackages`,加载菌种选项。菌种、批次、规格和包数在请求期间锁定;包数的前端校验只做正整数和非空检查,容量和托盘状态由后端最终判定。
|
||||
|
||||
- [ ] **Step 3: 实现幂等提交和成功刷新**
|
||||
|
||||
```js
|
||||
function submitFilling() {
|
||||
const idempotencyKey = ensureFillingIdempotencyKey()
|
||||
return addFilling({
|
||||
qrToken: form.value.qrToken,
|
||||
warehouseId: form.value.warehouseId,
|
||||
strainId: form.value.strainId,
|
||||
batchNo: form.value.batchNo.trim(),
|
||||
packageSpec: form.value.packageSpec.trim(),
|
||||
initialPackages: form.value.initialPackages,
|
||||
idempotencyKey
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
提交成功后清空表单并刷新装填列表,再以扫码查询或列表结果显示服务端状态;刷新失败不能覆盖已经成功的写入提示。提交失败保留键;修改托盘、菌种、批次、规格或包数后清除键,下一次输入生成新键。
|
||||
|
||||
- [ ] **Step 4: 运行测试并提交**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
git add -- shierfeng-fastapi-app/src/pages/business/filling/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA 托盘装填操作页' -- \
|
||||
shierfeng-fastapi-app/src/pages/business/filling/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
## Task 7: 实现客户现场使用 PDA 页面
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/src/pages/business/usage/index.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes `listUsage`、`getUsageLineOptions(siteId)`、`scanUsagePallet`、`addUsage` 和 `PalletScanInput`。
|
||||
- Produces `handleUsageScan`, `loadLineOptions`, `submitUsage`, `resetUsageOperation`。
|
||||
- Uses `UsageSuccessResponseModel` fields `usageId`, `remainingPackages`, `flowState` for result display only。
|
||||
|
||||
- [ ] **Step 1: 写现场使用失败契约**
|
||||
|
||||
断言页面只接受 `AT_SITE_UNUSED` 或 `AT_SITE_PARTIAL` 且 `remainingPackages > 0`;没有 `currentLineId` 时按扫码响应的 `siteId` 获取产线,有绑定时锁定该产线;提交使用 `qrToken,lineId,usedPackages,idempotencyKey`,不在本地推算新状态。
|
||||
|
||||
- [ ] **Step 2: 实现扫码、产线锁定和正整数校验**
|
||||
|
||||
每次新扫码先使旧产线请求失效、清空旧结果和幂等键。扫码成功后保存托盘、菌种、批次、余量和现场信息;首次使用显示可选产线,已有 `currentLineId` 时只能使用该产线。使用包数必须是严格正整数,超过当前展示余量时给出提示,但即使通过前端检查也仍由服务端校验。
|
||||
|
||||
- [ ] **Step 3: 实现使用提交和结果刷新**
|
||||
|
||||
```js
|
||||
function submitUsage() {
|
||||
const idempotencyKey = ensureUsageIdempotencyKey()
|
||||
return addUsage({
|
||||
qrToken: form.value.qrToken,
|
||||
lineId: form.value.lineId,
|
||||
usedPackages: form.value.usedPackages,
|
||||
idempotencyKey
|
||||
}).then((response) => {
|
||||
usageResult.value = response.data
|
||||
clearUsageIdempotencyKey()
|
||||
return loadUsageList()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
网络失败时保留键并显示“再次提交将复用本次幂等键”;成功只采用 `response.data.remainingPackages` 和 `response.data.flowState`,然后清理表单或允许重新扫码。列表和扫码响应晚于新会话时必须被请求代数丢弃。
|
||||
|
||||
- [ ] **Step 4: 运行测试并提交**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
git add -- shierfeng-fastapi-app/src/pages/business/usage/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA 客户现场使用操作页' -- \
|
||||
shierfeng-fastapi-app/src/pages/business/usage/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
## Task 8: 实现托盘退回申请与仓库确认
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `shierfeng-fastapi-app/src/pages/business/return/index.vue`
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes all exports from `src/api/business/return.js` and two independent `PalletScanInput` instances。
|
||||
- Request side state: `sourceSiteId`, `targetWarehouseId`, `requestScannedPallets`, `requestIdempotencyKey`。
|
||||
- Confirm side state: `returnId`, `expectedPalletIds`, `confirmScannedPallets`, `confirmIdempotencyKey`。
|
||||
- Produces `handleRequestScan`, `submitReturnRequest`, `loadReturnDetail`, `handleConfirmScan`, `submitReturnConfirm`, `sameIdSet`。
|
||||
|
||||
- [ ] **Step 1: 写退回两阶段失败契约**
|
||||
|
||||
断言测试覆盖:
|
||||
|
||||
1. 申请扫码使用 `{ mode: 'request' }`,首个托盘确定 `sourceSiteId`,后续托盘必须属于同一现场。
|
||||
2. 申请 payload 为 `sourceSiteId,targetWarehouseId,palletIds,idempotencyKey`,申请键和确认键互不复用。
|
||||
3. 确认先读取详情,状态必须为 `REQUESTED`,扫描使用 `{ mode: 'confirm', returnId }`,扫码集合必须与详情托盘 ID 集合完全相同。
|
||||
4. 确认写请求前再次 `getReturn(returnId)`,比较最新状态和 ID 集合后才调用 `confirmReturn`。
|
||||
5. 请求或确认扫码/提交的旧响应不能覆盖新选择;失败不清除对应幂等键。
|
||||
|
||||
- [ ] **Step 2: 实现申请区**
|
||||
|
||||
加载可访问目标仓库和退回列表。申请扫码成功后保存 `palletId,palletCode,siteId,remainingPackages`,同一托盘不重复,目标仓库或会话变化时清空集合和申请键。`requestReturn` 成功后清除申请键、清空申请区并刷新列表;不根据响应消息猜测新 `returnId`,确认入口从服务端返回的 `REQUESTED` 列表选择单据。
|
||||
|
||||
- [ ] **Step 3: 实现确认区的详情快照与整集合扫码**
|
||||
|
||||
选择退回单后调用 `getReturn`,保存详情中的 `items[].palletId` 作为预期集合。确认扫码必须命中预期集合且不能重复;切换退回单使确认扫码代数、详情代数和确认键同时失效。实现集合比较时使用去重后的 `Set`,长度和成员都必须一致:
|
||||
|
||||
```js
|
||||
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))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现确认前复核和幂等提交**
|
||||
|
||||
```js
|
||||
async function submitReturnConfirm() {
|
||||
const returnId = confirmForm.value.returnId
|
||||
const scannedPalletIds = confirmScannedPallets.value.map((item) => item.palletId)
|
||||
if (!returnId || !sameIdSet(expectedPalletIds.value, scannedPalletIds)) {
|
||||
confirmError.value = '必须逐个扫描并完整匹配退回单全部托盘'
|
||||
return
|
||||
}
|
||||
const latest = (await getReturn(returnId)).data
|
||||
if (latest.status !== 'REQUESTED' ||
|
||||
!sameIdSet(latest.items.map((item) => item.palletId), scannedPalletIds)) {
|
||||
confirmError.value = '退回单状态或托盘明细已变化,请重新选择并扫码'
|
||||
return
|
||||
}
|
||||
const idempotencyKey = ensureConfirmIdempotencyKey(returnId)
|
||||
await confirmReturn(returnId, { scannedPalletIds, idempotencyKey })
|
||||
}
|
||||
```
|
||||
|
||||
确认成功后清除确认键、刷新退回列表和详情;网络失败保留键。申请确认两个阶段的输入锁、错误状态和重试键必须分开,不能用一个全局 `loading` 或一个幂等键混用。
|
||||
|
||||
- [ ] **Step 5: 运行测试并提交**
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
git add -- shierfeng-fastapi-app/src/pages/business/return/index.vue shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
git commit --only -m '新增:PDA 托盘退回两阶段操作页' -- \
|
||||
shierfeng-fastapi-app/src/pages/business/return/index.vue \
|
||||
shierfeng-fastapi-app/tests/business-contract.test.mjs
|
||||
```
|
||||
|
||||
## Task 9: 完成全量契约验证、H5 构建和交付检查
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `shierfeng-fastapi-app/tests/business-contract.test.mjs`(仅在发现测试覆盖缺口时补充)
|
||||
- Preserve: 所有后端、Web、环境、日志、缓存和用户已有 APP 文件
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes all five PDA pages, five API modules, shared scanner and existing backend contract。
|
||||
- Produces a verified H5/development build;does not produce an APK。
|
||||
|
||||
- [ ] **Step 1: 完成静态契约覆盖清单**
|
||||
|
||||
测试必须逐项覆盖:
|
||||
|
||||
- 五个 API 文件的路径、方法、参数位置和导出函数。
|
||||
- 五个页面的路由存在、页面调用对应 API、camelCase payload 字段正确。
|
||||
- 五类状态条件:`UNSTOCKED`、`EMPTY_IN_WAREHOUSE`、`FILLED_IN_WAREHOUSE`、`AT_SITE_UNUSED/AT_SITE_PARTIAL`、退回 `REQUESTED`。
|
||||
- HID 回车、空值、重复扫码、扫描请求锁和手工输入。
|
||||
- 入库/发货确认、装填、使用、退回申请/确认的幂等键生成、保留和成功清理时机。
|
||||
- 页面权限标识和登录页无密码预填。
|
||||
- 不存在摄像头扫码调用、APK 构建脚本调用、管理员密码或 Token 字面量。
|
||||
|
||||
- [ ] **Step 2: 运行 PDA 契约测试**
|
||||
|
||||
工作目录:`shierfeng-fastapi-app`
|
||||
|
||||
```bash
|
||||
pnpm test:business
|
||||
```
|
||||
|
||||
预期:所有 Node 20 测试通过且零失败。
|
||||
|
||||
- [ ] **Step 3: 运行 H5 构建,不运行 APK 构建**
|
||||
|
||||
```bash
|
||||
pnpm run build:h5
|
||||
```
|
||||
|
||||
预期:uni-app H5 构建成功。禁止运行 `pnpm run build:app`、`pnpm run dev:app`、APK 签名、安装或发布命令;构建产物不加入提交。
|
||||
|
||||
- [ ] **Step 4: 运行已有后端业务回归**
|
||||
|
||||
工作目录:`shierfeng-fastapi-backend`,使用当前已配置 Python 环境,不安装依赖:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 python -m pytest tests/module_business -q
|
||||
ruff format --check module_business tests/module_business
|
||||
ruff check module_business tests/module_business
|
||||
```
|
||||
|
||||
预期:已有后端业务测试、格式检查和静态检查通过;若本地 Python 环境无法执行,记录实际错误,不伪造通过结果。
|
||||
|
||||
- [ ] **Step 5: 做最终 diff 与敏感信息检查**
|
||||
|
||||
仓库根目录执行:
|
||||
|
||||
```bash
|
||||
git diff --check HEAD~9..HEAD
|
||||
rg -n --fixed-strings '旧默认密码' shierfeng-fastapi-app/src shierfeng-fastapi-app/tests
|
||||
rg -n 'Bearer[[:space:]]+[A-Za-z0-9._-]+' shierfeng-fastapi-app/src shierfeng-fastapi-app/tests
|
||||
git status --short
|
||||
```
|
||||
|
||||
敏感信息检查应无命中;`git status` 中只允许出现本计划任务的 PDA 文件和用户原有改动,不能出现由构建生成且准备提交的 APK、环境文件、日志或缓存。若提交数量因任务合并而不同,将 `HEAD~9..HEAD` 改为本次 PDA 首个提交到当前 `HEAD` 的实际范围,不扩大检查目标。
|
||||
|
||||
- [ ] **Step 6: 按人工验收清单交付**
|
||||
|
||||
在受控本地环境手工验证:管理员和受限普通用户登录;未授权入口不显示且直接接口返回 403;HID 扫码回车、重复扫码、空值和网络重试;库存余量、托盘状态、产线绑定、退回冻结与事件追溯均来自服务端;页面刷新和断网重试不重复扣减库存。管理员密码只在人工输入,不记录到终端输出、源码或日志。
|
||||
|
||||
## 计划自检
|
||||
|
||||
- 设计文档的范围、五类流程、认证安全、扫码输入、状态/幂等、验证、部署边界分别由 Tasks 1–9 覆盖。
|
||||
- 所有跨任务接口在“已核实的后端接口与字段”和各任务的 `Interfaces` 中定义,页面调用名称与 API 导出名称保持一致。
|
||||
- 没有新增依赖、后端契约改动、摄像头 SDK、APK 构建或凭据写入步骤。
|
||||
- 实现前先写失败契约,随后运行最小测试,再执行 H5 和后端回归;每个任务都以显式路径提交。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 全库清理。
|
||||
@@ -1,179 +0,0 @@
|
||||
# PDA 托盘业务操作功能补全设计
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前仓库已经具备 Web 后台、FastAPI 业务接口、托盘库存状态机、数据库结构、权限模型和六类报表。`shierfeng-fastapi-app` 目前只有通用登录、首页、工作台和个人中心,没有托盘业务操作页面与对应 API 封装。
|
||||
|
||||
本次在不改变既有后端业务契约的前提下,补齐 uni-app PDA 操作端的现场业务功能。PDA 暂不生成 APK,先完成页面、接口对接、扫码交互、权限控制和可验证的开发运行版本。
|
||||
|
||||
## 2. 范围确认
|
||||
|
||||
### 2.1 本次包含
|
||||
|
||||
- 复用现有 `/login`、`/getInfo`、`/logout` 认证流程。
|
||||
- 在 `shierfeng-fastapi-app` 增加五类现场操作:
|
||||
- 仓库入库
|
||||
- 工厂装填
|
||||
- 发货出库
|
||||
- 客户现场使用
|
||||
- 退回仓库
|
||||
- 增加 PDA 端 API 模块、扫码输入组件、业务页面、权限显示和错误/重试处理。
|
||||
- 复用后端已有状态机、幂等键、数据范围、库存事务和托盘追溯,不在 PDA 前端自行计算最终库存或状态。
|
||||
- 适配 PDA 扫码头的 HID 键盘输入和回车提交,并保留手工输入作为故障兜底。
|
||||
- 增加 PDA 端静态契约测试和 H5/开发构建验证;不生成 APK。
|
||||
- 继续使用 Web 后台维护菌种、客户、现场、产线、仓库、托盘和六类报表。
|
||||
|
||||
### 2.2 本次不包含
|
||||
|
||||
- 不打包、签名、安装或发布 APK。
|
||||
- 不调用摄像头扫码,不新增原生扫码 SDK;当前以 HID 扫码枪输入为主。
|
||||
- 不把基础资料维护和六类报表复制到 PDA。
|
||||
- 不修改现有 FastAPI 业务接口的请求/响应契约,除非实现阶段发现有明确且可验证的 PDA 阻塞缺口。
|
||||
- 不修改 `shierfeng-fastapi-frontend` 的既有 Web 业务页面,除非共享契约缺陷直接阻塞 PDA。
|
||||
- 不修改 `.env.dev`、`.env.prod`、前端环境文件、用户已有 APP 改动或无关缓存。
|
||||
- 不执行生产部署、服务器配置、Redis 清理、数据库迁移或培训;这些属于后续运行交付,需要单独的环境授权和验收。
|
||||
- 不把管理员密码写入源码、设计文档、日志或测试固定文本。
|
||||
|
||||
## 3. 用户角色与权限边界
|
||||
|
||||
PDA 页面只展示当前用户拥有的权限,后端接口权限和仓库/现场数据范围仍是最终权威:
|
||||
|
||||
| 角色能力 | PDA 操作 |
|
||||
|---|---|
|
||||
| 仓库操作员 | 查看可访问仓库、创建/确认入库、扫描装填、创建/确认发货、确认退回 |
|
||||
| 客户现场操作员 | 扫描托盘、首次选择产线、录入使用包数、发起退回 |
|
||||
| 业务管理员/超级管理员 | 依据既有权限可执行全部授权操作 |
|
||||
|
||||
不新增固定角色,不自动给普通用户授权。仓库与客户现场的数据范围继续由现有用户-仓库、用户-现场关系决定。
|
||||
|
||||
## 4. 页面与 API 设计
|
||||
|
||||
### 4.1 PDA 页面
|
||||
|
||||
新增页面建议采用以下路径,沿用当前 uni-app 页面路由和 Tailwind 样式:
|
||||
|
||||
```text
|
||||
src/pages/business/inbound/index.vue
|
||||
src/pages/business/filling/index.vue
|
||||
src/pages/business/shipment/index.vue
|
||||
src/pages/business/usage/index.vue
|
||||
src/pages/business/return/index.vue
|
||||
```
|
||||
|
||||
工作台只增加实际权限允许的业务入口,不保留“建设中”假入口。页面加载失败、权限不足和接口异常必须给出可操作提示。
|
||||
|
||||
### 4.2 PDA API 封装
|
||||
|
||||
复用后台已有接口路径,在 `src/api/business/` 增加:
|
||||
|
||||
```text
|
||||
inbound.js
|
||||
filling.js
|
||||
shipment.js
|
||||
usage.js
|
||||
return.js
|
||||
```
|
||||
|
||||
每个模块覆盖对应的列表、详情、选项、扫码、创建/编辑/删除、确认或提交接口;请求字段使用后端既有 camelCase 契约,不在前端另建同义字段。
|
||||
|
||||
### 4.3 共享扫码组件
|
||||
|
||||
新增 PDA 专用扫码输入组件,职责仅限于输入采集和规范化:
|
||||
|
||||
- 自动聚焦可用输入框。
|
||||
- 兼容 HID 扫码枪逐字符输入后回车提交。
|
||||
- 去除首尾空白,拒绝空值。
|
||||
- 防止同一托盘在短时间内重复追加。
|
||||
- 扫码请求期间锁定相关输入,避免并发提交。
|
||||
- 支持“重新扫描”和手工输入兜底。
|
||||
- 不在组件内决定托盘状态、库存数量或权限结果。
|
||||
|
||||
## 5. 五类业务流程
|
||||
|
||||
### 5.1 入库
|
||||
|
||||
选择后端返回的可用仓库,创建草稿并连续扫描 `UNSTOCKED` 托盘;允许移除草稿明细,确认时复用业务幂等键。提交成功后以服务端结果刷新页面,不由 PDA 推算库存。
|
||||
|
||||
### 5.2 装填
|
||||
|
||||
扫描托盘并展示后端返回的仓库、状态和当前余量;选择菌种,填写批次、规格和正整数包数;提交后清理表单并重新获取托盘状态。非法状态、超容量、无仓库权限由后端拒绝并原样转为用户可读提示。
|
||||
|
||||
### 5.3 发货
|
||||
|
||||
选择来源仓库和目标客户现场,连续扫描多个可发货托盘;展示菌种、批次和剩余包数只读信息;确认前复核托盘集合,服务端负责整单锁定、库存扣减和事件追加。
|
||||
|
||||
### 5.4 客户现场使用
|
||||
|
||||
扫描托盘后展示菌种、批次、当前余量和允许操作;首次使用要求选择当前现场产线,后续使用沿用后端锁定产线;使用包数必须为正整数且不超过服务端返回余量。成功后只采用服务端返回的新余量和状态。
|
||||
|
||||
### 5.5 退回仓库
|
||||
|
||||
客户现场扫描待退回托盘,选择目标仓库并发起退回;仓库用户扫描并确认收货。申请和确认分别持有独立幂等键,页面分别保存两阶段状态,确认后以服务端返回的库存状态刷新。
|
||||
|
||||
## 6. 认证、密码与安全
|
||||
|
||||
- PDA 继续使用当前 JWT、传输加密和 `/getInfo` 权限信息。
|
||||
- 前端不硬编码管理员密码;给定账号只用于经授权的本地联调验证。
|
||||
- 当前登录页中的旧默认密码预填值需要改为不暴露密码的空表单或开发环境专用行为,不能把真实密码写入仓库。
|
||||
- 管理员密码变更属于账号凭据写操作,须先只读核对当前认证存储,再单独确认、备份并执行,日志不得输出密码、Token 或 Cookie。
|
||||
- PDA 页面隐藏不能替代后端权限校验;所有业务请求继续经过后端接口权限和数据范围检查。
|
||||
|
||||
## 7. 状态、幂等与错误处理
|
||||
|
||||
- PDA 只提交业务输入和幂等键,不提交目标库存、目标状态或版本号。
|
||||
- 网络超时、响应丢失或用户重试时复用同一个业务幂等键;同键不同请求内容显示冲突,不自动生成新业务操作。
|
||||
- 扫码重复、按钮重复点击和页面切换期间的旧响应由前端请求序列号/锁控制。
|
||||
- 401 继续走现有会话过期处理;403、业务状态冲突、库存不足和数据范围错误必须显示后端消息并允许安全重试。
|
||||
- 页面恢复或重新进入时,以详情/列表接口作为唯一权威,不能相信本地缓存的库存和状态。
|
||||
|
||||
## 8. 验证与交付
|
||||
|
||||
### 8.1 代码验证
|
||||
|
||||
- 为 PDA API 路径、字段映射、权限入口、扫码规范化、幂等键复用和五类页面交互增加 Node 20 契约测试。
|
||||
- 使用现有后端业务测试和 Ruff 验证共享接口没有回归。
|
||||
- 运行 uni-app 的 H5/开发构建或等价编译检查;不执行 `build:app`,不生成 APK。
|
||||
- 对当前工作区做精确 diff 检查,保留用户已有 APP、环境、日志和缓存改动。
|
||||
|
||||
### 8.2 人工验收清单
|
||||
|
||||
1. 使用管理员和受限普通用户分别登录。
|
||||
2. 验证未授权菜单不显示、直接接口请求返回 403。
|
||||
3. 使用 HID 扫码枪验证五类业务的扫码、回车、重复扫码和错误重试。
|
||||
4. 验证库存、剩余包数、产线绑定、退回冻结和事件追溯均以后端结果为准。
|
||||
5. 验证页面刷新/断网重试不重复扣减库存。
|
||||
|
||||
真实服务器部署、APK 安装、客户培训和跨设备实测在本次代码补全后仍需单独安排,不能用本地构建结果替代。
|
||||
|
||||
## 9. 当前基线与风险
|
||||
|
||||
- Web/FastAPI 核心业务已存在并有业务测试;本次重点是 uni-app PDA 缺失能力。
|
||||
- 当前 PDA 工程已包含通用登录、首页、工作台、个人中心,以及入库、装填、发货、现场使用、退回五类业务页面和对应 API 模块。
|
||||
- 后端接口、Web 管理页面和数据库 SQL 已存在;PDA 的实际设备联调、服务器访问地址、数据库迁移和真实角色授权仍需运行环境配合。
|
||||
- APK 暂不打包,因此本次交付物是源码、接口封装、页面和可验证开发版本,不包含安装包。
|
||||
|
||||
## 10. 需求覆盖矩阵(代码审计结果)
|
||||
|
||||
下表区分“代码已实现”和“真实环境已交付”,避免把静态测试或本地构建误认为部署、安装或培训完成。
|
||||
|
||||
| 序号 | 需求 | 当前结论 | 主要证据 |
|
||||
| ---: | --- | --- | --- |
|
||||
| 1 | 业务流程、功能边界、角色、交付内容和不包含事项 | 已确认 | 本设计第 1~3、8~9 节 |
|
||||
| 2 | 托盘、菌种、产线、仓库、装填、发货、使用、退回字段 | 已梳理 | `module_business/entity`、SQL 脚本 |
|
||||
| 3 | 后台、接口、数据库结构和基础运行环境 | 代码已完成;实际环境未部署 | `module_business`、`sql/`、前后端工程 |
|
||||
| 4 | 菌种、产线、仓库、用户等基础资料维护 | 已实现 | Web `views/master`、系统用户/角色模块 |
|
||||
| 5 | 托盘唯一标识、固定二维码、状态和档案 | 已实现 | `biz_pallet` 唯一约束、状态机、托盘管理页 |
|
||||
| 6 | 入库登记、入库单、库存更新 | 已实现 | `inbound_controller.py`、`inbound_service.py` |
|
||||
| 7 | 扫码装填菌种、批次、规格、包数并更新状态 | 已实现 | `filling_service.py`、Web/PDA 装填页 |
|
||||
| 8 | 发货单、托盘扫码/选择、明细和库存扣减 | 已实现 | `shipment_service.py`、Web/PDA 发货页 |
|
||||
| 9 | PDA 登录、扫码识别和读取菌种/余量 | 已实现;未打包 APK | `shierfeng-fastapi-app/src/pages/business`、PDA API |
|
||||
| 10 | 客户扫码、产线绑定、使用包数、操作人和时间 | 已实现 | `usage_service.py`、使用记录模型和页面 |
|
||||
| 11 | 全部/部分使用、剩余包数、退回和状态流转 | 已实现 | `pallet_state_machine.py`、使用/退回服务 |
|
||||
| 12 | 六类库存、装填、发货、消耗、退回、追溯报表 | 已实现 | `report_controller.py`、Web 六类报表页 |
|
||||
| 13 | 权限、操作记录、流转追溯和异常留痕 | 已实现 | 权限依赖、`Log`、`biz_pallet_event`、追溯报表 |
|
||||
| 14 | Android 页面、接口、PDA 适配和 APK | 页面/API/HID 已实现;APK 按要求暂缓 | PDA H5 构建与扫码组件 |
|
||||
| 15 | 后台、接口、PDA、库存、退回整体联调 | 代码级验证完成;真实设备联调未完成 | 契约测试通过;缺少真实设备/服务地址 |
|
||||
| 16 | 服务器、数据库、后台/接口服务和访问地址部署 | 未完成 | 缺少目标服务器和部署授权 |
|
||||
| 17 | App 安装、扫码验收和客户培训 | 未完成 | 缺少 PDA 设备及培训安排 |
|
||||
|
||||
登录页默认只填充用户名 `admin`;密码保持空白并由操作员手工输入,不将真实密码写入源码、测试或日志。
|
||||
@@ -1,298 +0,0 @@
|
||||
# 测试数据批次隔离与级联清理设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前菌种、客户、客户现场、产线、仓库和托盘等主数据在被业务单据引用后不允许删除。该约束能保护正式业务的外键完整性、库存状态、报表口径和托盘追溯链,但也导致开发、联调和验收期间产生的测试数据无法安全清理。
|
||||
|
||||
本设计引入“测试数据批次”,将正式数据与测试数据从创建、查询、业务操作到报表统计全程隔离。超级管理员可在预览完整影响范围后整批清理测试数据;正式数据在任何情况下都不能通过该能力物理删除。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 以测试批次为边界隔离正式数据和测试数据。
|
||||
- 支持授权的普通用户在指定测试批次内完成入库、装填、发货、使用和退回流程。
|
||||
- 支持超级管理员将升级前已有的旧测试数据完整纳入测试批次。
|
||||
- 支持关闭批次后预览影响范围,并在单个数据库事务中整批清理。
|
||||
- 保留批次元数据和操作审计,但不保留已清理的测试业务明细。
|
||||
- 防止前端绕过、权限误配、并发请求或部分失败导致正式数据被删或测试链路残缺。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不允许按单条主数据或单据强制级联删除。
|
||||
- 不允许将正在进行的批次直接清理。
|
||||
- 不用测试清理代替正式业务的停用、作废、冲销或审计流程。
|
||||
- 不根据名称、编码、创建人或时间自动猜测旧数据是否为测试数据。
|
||||
- 不在本次实施中直接运行生产数据库迁移或清理。
|
||||
|
||||
## 3. 角色与权限边界
|
||||
|
||||
| 能力 | 超级管理员 | 已授权测试用户 | 其他用户 |
|
||||
| --- | --- | --- | --- |
|
||||
| 查看测试批次管理 | 允许 | 不允许 | 不允许 |
|
||||
| 创建、关闭、重新开启批次 | 允许 | 不允许 | 不允许 |
|
||||
| 授权批次测试用户 | 允许 | 不允许 | 不允许 |
|
||||
| 创建批次内测试主数据 | 允许 | 不允许 | 不允许 |
|
||||
| 进入已授权批次执行业务 | 允许 | 允许 | 不允许 |
|
||||
| 将旧数据纳入批次 | 允许 | 不允许 | 不允许 |
|
||||
| 预览并整批清理 | 允许 | 不允许 | 不允许 |
|
||||
| 物理删除正式数据 | 不允许 | 不允许 | 不允许 |
|
||||
|
||||
超级管理员以后端 `current_user.user.admin` 为最终判断,不仅依赖前端角色或菜单权限。测试批次接口可同时使用独立权限字符控制菜单展示,但权限字符被误分配给普通角色时,后端仍必须返回 403。
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 测试批次
|
||||
|
||||
新增 `biz_test_batch`,至少包含:
|
||||
|
||||
- `batch_id`:主键。
|
||||
- `batch_code`:系统生成的唯一编码。
|
||||
- `batch_name`:用于界面识别与二次确认。
|
||||
- `status`:`ACTIVE`、`CLOSED`、`PURGED`。
|
||||
- `create_by`、`create_time`、`update_by`、`update_time`。
|
||||
- `closed_by`、`closed_at`、`purged_by`、`purged_at`。
|
||||
- `purge_summary`:保留按数据类型汇总的清理数量,不保留敏感业务内容。
|
||||
- `remark`:批次用途说明。
|
||||
|
||||
新增 `biz_test_batch_user`,保存批次与参与测试用户的关联。该关联只是批次的外层访问权;现有用户-仓库、用户-现场数据范围仍然生效。
|
||||
|
||||
### 4.2 业务数据标记
|
||||
|
||||
以下表新增可空 `test_batch_id` 和索引,并外键引用 `biz_test_batch.batch_id`:
|
||||
|
||||
- 主数据:`biz_strain`、`biz_customer`、`biz_customer_site`、`biz_production_line`、`biz_warehouse`、`biz_pallet`。
|
||||
- 入库:`biz_inbound_order`、`biz_inbound_item`。
|
||||
- 装填:`biz_filling_record`。
|
||||
- 发货:`biz_shipment_order`、`biz_shipment_item`。
|
||||
- 使用:`biz_usage_record`。
|
||||
- 退回:`biz_return_order`、`biz_return_item`。
|
||||
- 库存与追溯:`biz_pallet_inventory`、`biz_pallet_event`。
|
||||
|
||||
`test_batch_id IS NULL` 表示正式数据;非空表示所属测试批次。该字段不允许普通业务请求直接写入,由后端从已验证的当前模式或关联记录中派生。
|
||||
|
||||
`biz_user_warehouse` 和 `biz_user_site` 不重复保存 `test_batch_id`,其数据归属由所关联的仓库或现场派生。批次清理时,必须先删除指向该批次仓库和现场的用户关联。
|
||||
|
||||
### 4.3 一致性约束
|
||||
|
||||
- 正式数据只能引用正式数据。
|
||||
- 测试数据只能引用同一批次的测试数据。
|
||||
- 不同测试批次之间不能互相引用。
|
||||
- 批次标记一旦创建不能通过通用编辑接口修改。
|
||||
- 只有“纳入测试批次”专用流程可将旧的无标记数据一次性改为测试数据。
|
||||
- 关联对象的批次一致性由服务层在写入前校验,数据库外键作为存在性和删除顺序的最后保护。
|
||||
- 现有编码、单号和二维码唯一约束继续全局生效;测试数据使用独立编码,清理后可重新使用。
|
||||
|
||||
## 5. 数据模式与查询隔离
|
||||
|
||||
### 5.1 模式选择
|
||||
|
||||
Web 管理端和 PDA 操作端都需支持:
|
||||
|
||||
- 正式模式:不传测试批次上下文,后端只允许 `test_batch_id IS NULL` 的数据。
|
||||
- 测试模式:携带当前 `batch_id`。已授权用户只能进入 `ACTIVE` 批次;超级管理员可在批次管理页以只读方式检查 `CLOSED` 批次。任何写入都要求批次为 `ACTIVE`。
|
||||
|
||||
前端可通过统一请求头传递当前批次,但该值只是请求上下文,不是信任根。后端必须每次验证用户授权、批次状态和数据归属。
|
||||
|
||||
### 5.2 隔离范围
|
||||
|
||||
批次条件必须应用于:
|
||||
|
||||
- 六类基础资料的列表、详情和选项。编码、单号和二维码的唯一性校验仍按全局数据执行,与现有数据库唯一约束保持一致。
|
||||
- 入库、装填、发货、使用和退回的列表、详情、扫码、创建、修改和确认。
|
||||
- 仓库、现场、产线、菌种和托盘等业务选项接口。
|
||||
- 托盘库存、装填、发货、消耗、退回和追溯六类报表。
|
||||
- 首页仓储运营指标、待处理数量、趋势图和状态分布。
|
||||
|
||||
任何缺失批次条件的查询都可能造成测试数据污染正式报表,实施时必须对所有业务 DAO 做完整盘点。
|
||||
|
||||
## 6. 批次生命周期
|
||||
|
||||
```text
|
||||
ACTIVE --关闭--> CLOSED --重新开启--> ACTIVE
|
||||
|
|
||||
+--预览并确认清理--> PURGED
|
||||
```
|
||||
|
||||
- `ACTIVE`:允许超级管理员创建测试主数据,允许已授权用户执行业务。
|
||||
- `CLOSED`:所有测试数据只读;可查看、预览清理范围、重新开启或清理。
|
||||
- `PURGED`:批次业务数据已删除;仅保留批次元数据和审计汇总,不能重新开启。
|
||||
|
||||
关闭、重新开启和清理都需锁定批次记录。每个测试写操作在业务数据加锁前必须重新校验批次仍为 `ACTIVE`,保证“关闭成功后无新写入”。
|
||||
|
||||
## 7. 新测试数据创建与业务继承
|
||||
|
||||
1. 超级管理员创建批次,分配参与测试的用户。
|
||||
2. 超级管理员切换到该批次,创建菌种、客户、现场、产线、仓库和托盘。
|
||||
3. 后端根据当前批次自动写入 `test_batch_id`,忽略或拒绝请求体中伪造的批次字段。
|
||||
4. 已授权用户进入该批次,使用批次内的仓库、现场、产线、菌种和托盘执行业务。
|
||||
5. 入库单、装填记录、发货单、使用记录、退回单、库存和事件由后端自动继承同一 `test_batch_id`。
|
||||
6. 任一关联数据的批次不一致时,整个业务操作失败并回滚。
|
||||
|
||||
## 8. 旧数据纳入测试批次
|
||||
|
||||
### 8.1 入口
|
||||
|
||||
超级管理员在正式模式的基础资料和业务管理页选择旧数据后,可执行“纳入测试批次”。报表页不提供该操作,因为报表行不是独立业务实体;需从其来源主数据或单据发起。
|
||||
|
||||
### 8.2 关联闭包
|
||||
|
||||
后端必须沿实际外键与业务关联双向计算连通闭包,直到没有新关联记录。闭包至少覆盖:
|
||||
|
||||
- 菌种与装填记录。
|
||||
- 客户、现场与产线层级。
|
||||
- 仓库、入库、装填、发货和退回。
|
||||
- 托盘、单据明细、库存、使用记录和追溯事件。
|
||||
- 与装填周期相关的发货、使用和退回记录。
|
||||
|
||||
由于旧数据没有可靠的测试标记,系统无法自动识别闭包中哪些数据被用户视为“正式”。预览必须按表展示数量和关键编号;如果闭包包含需保留的正式数据,管理员必须取消,系统不允许只标记半条关联链。
|
||||
|
||||
### 8.3 确认与写入
|
||||
|
||||
- 目标批次必须为 `ACTIVE`。
|
||||
- 闭包中所有数据必须尚未归属任何测试批次。
|
||||
- 管理员需输入目标批次名称二次确认。
|
||||
- 服务端使用不透明预览校验标识;确认时重新计算闭包,范围变化则拒绝旧确认。
|
||||
- 事务内锁定批次和闭包中的根记录,然后一次性写入所有 `test_batch_id`。
|
||||
- 任何一步失败都回滚,不得出现部分数据已纳入、部分数据仍为正式的状态。
|
||||
|
||||
## 9. 整批清理
|
||||
|
||||
### 9.1 前置条件
|
||||
|
||||
- 操作人必须是超级管理员。
|
||||
- 批次必须为 `CLOSED`。
|
||||
- 清理预览需展示每类主数据、单据、明细、库存和事件的数量。
|
||||
- 管理员必须输入完整批次名称并通过最终确认。
|
||||
- 确认请求必须携带服务端生成的一次性预览标识。
|
||||
|
||||
### 9.2 事务与删除顺序
|
||||
|
||||
清理服务在单个数据库事务内锁定批次,重新计算影响范围并校验预览标识。删除顺序必须由实际外键图生成并以回归测试固化,原则为:
|
||||
|
||||
1. 先删除 `biz_pallet_inventory`,解除其对托盘事件和活动装填记录的外键引用。
|
||||
2. 删除 `biz_usage_record`、`biz_return_item`、`biz_shipment_item` 和 `biz_inbound_item`。
|
||||
3. 删除 `biz_return_order`、`biz_shipment_order` 和 `biz_inbound_order`。
|
||||
4. 删除 `biz_pallet_event`,再删除 `biz_filling_record`。
|
||||
5. 删除指向测试仓库、现场的 `biz_user_warehouse` 和 `biz_user_site` 关联。
|
||||
6. 先删除产线,再删除现场和客户;随后删除仓库、菌种和托盘等其他主数据。
|
||||
7. 删除批次用户授权,更新批次为 `PURGED`,写入清理汇总。
|
||||
|
||||
实施时以 SQLAlchemy 模型和 MySQL/PostgreSQL 外键的实际编译结果为准,不依赖上述文字顺序代替自动化测试。
|
||||
|
||||
不对业务外键配置 `ON DELETE CASCADE`。如果任何外键、并发状态或数据库错误导致删除失败,整个事务回滚,批次保持 `CLOSED`。
|
||||
|
||||
### 9.3 幂等与审计
|
||||
|
||||
- 同一批次的并发清理只有一个可以执行,其他请求等待后读取最新状态。
|
||||
- 对 `PURGED` 批次重复请求返回稳定的“已清理”结果,不重复执行删除。
|
||||
- 批次记录保留清理人、清理时间和分类数量。
|
||||
- 系统操作日志保留,作为审计证据;日志不记录密码、Token、Cookie 或大量业务明细。
|
||||
|
||||
## 10. 界面与交互
|
||||
|
||||
### 10.1 测试数据批次管理
|
||||
|
||||
在“系统管理”下新增“测试数据批次”页面,仅超级管理员可见。页面支持:
|
||||
|
||||
- 查看批次编码、名称、状态、创建人、参与用户和时间。
|
||||
- 创建批次、编辑备注、授权或移除测试用户。
|
||||
- 关闭或重新开启未清理批次。
|
||||
- 查看数据汇总、清理预览和最终清理确认。
|
||||
- 查看已清理批次的审计汇总。
|
||||
|
||||
### 10.2 模式提示
|
||||
|
||||
- Web 顶部和 PDA 工作台提供“正式数据 / 已授权测试批次”切换。
|
||||
- 测试模式始终展示高可见的批次名称和“测试数据”标识,不得只在隐蔽的下拉框中表示。
|
||||
- 切换模式后刷新当前列表、选项、首页指标和报表,并清空未提交的跨模式表单状态。
|
||||
- 批次被关闭、授权被移除或会话失效时,普通测试用户退回正式模式并得到明确提示;超级管理员仍可从批次管理页查看关闭批次的只读汇总和清理预览。
|
||||
|
||||
### 10.3 现有删除入口
|
||||
|
||||
- 基础资料、业务管理和报表页不再提供物理删除入口。
|
||||
- 正式主数据通过停用保留历史引用;正式业务单据按现有业务状态处理,不使用测试批次能力物理删除。
|
||||
- 旧的删除 API 保留路由兼容,但统一拒绝物理删除并提示“请通过测试数据批次清理”。
|
||||
- “纳入测试批次”只在正式模式下向超级管理员展示。
|
||||
|
||||
## 11. 后端组件与接口边界
|
||||
|
||||
建议新增独立的批次控制器、服务、DAO 和 VO,不将级联清理逻辑分散到六个主数据服务中。职责划分如下:
|
||||
|
||||
- 批次管理:创建、列表、详情、用户授权、关闭和重新开启。
|
||||
- 批次上下文:解析请求模式,校验用户与批次,为查询和写入提供受信任的 `batch_id | None`。
|
||||
- 批次一致性校验:根据业务输入校验所有关联数据处于同一模式和批次。
|
||||
- 旧数据纳入:计算关联闭包、生成预览、校验确认并原子写入批次标记。
|
||||
- 整批清理:统计影响数据、生成预览、校验确认并原子删除。
|
||||
|
||||
预览与执行必须分为不同接口。执行接口不接受前端传入的“要删哪些表或 ID”,只接受批次 ID、批次名称确认和一次性预览标识;实际范围始终由后端重算。
|
||||
|
||||
## 12. 错误处理
|
||||
|
||||
需向用户明确区分以下失败:
|
||||
|
||||
- 非超级管理员访问管理或清理接口。
|
||||
- 用户未被授权访问所选测试批次。
|
||||
- 批次已关闭、已清理或在操作期间状态发生变化。
|
||||
- 请求将正式数据与测试数据或不同批次数据混用。
|
||||
- 旧数据关联闭包包含已属于其他批次的数据。
|
||||
- 预览后数据范围发生变化,一次性校验标识失效。
|
||||
- 批次名称确认不匹配。
|
||||
- 外键、并发或数据库错误触发整体回滚。
|
||||
|
||||
前端不将未知错误表示为成功,也不在失败后乐观移除列表记录。所有成功状态均以服务端提交后重新查询的结果为准。
|
||||
|
||||
## 13. 数据库迁移与发布
|
||||
|
||||
- 同步更新 MySQL 和 PostgreSQL 全量建库脚本。
|
||||
- 为已有环境提供 MySQL 和 PostgreSQL 增量补丁。
|
||||
- 先创建批次与授权表,再以可空字段方式增加 `test_batch_id`、索引和外键。
|
||||
- 既有记录的 `test_batch_id` 保持 `NULL`,迁移不自动更新业务数据。
|
||||
- 发布前必须完成数据库备份和恢复演练;应用迁移和首次旧数据纳入属于独立高风险操作,需单独确认后执行。
|
||||
- 先发布后端与前端代码,确认正式模式查询不回归,再创建新测试批次或纳入旧数据。
|
||||
|
||||
## 14. 测试与验证
|
||||
|
||||
### 14.1 后端
|
||||
|
||||
- 批次生命周期和不合法状态转换。
|
||||
- 超级管理员硬校验,包括普通用户持有误分配权限字符的情况。
|
||||
- 批次用户授权与现有仓库/现场数据范围的叠加效果。
|
||||
- 正式、同批次和跨批次关联校验。
|
||||
- 创建主数据和五类业务时的批次自动继承。
|
||||
- 主数据、业务列表、选项、扫码、首页和六类报表的模式隔离。
|
||||
- 旧数据闭包的完整性、循环去重、跨批次拒绝和事务回滚。
|
||||
- 关闭批次后所有写入被拒绝,重新开启后恢复。
|
||||
- 清理预览计数、预览过期、确认文本、外键删除顺序、失败回滚和重复清理幂等性。
|
||||
- MySQL 和 PostgreSQL 的 SQL 编译与迁移契约。
|
||||
|
||||
### 14.2 Web 与 PDA
|
||||
|
||||
- 超级管理员、已授权测试用户和未授权用户的菜单与按钮可见性。
|
||||
- 正式/测试模式切换、批次标识、请求上下文和授权失效回退。
|
||||
- 旧数据纳入预览、批次名称二次确认和预览过期提示。
|
||||
- 批次关闭后的只读界面和清理确认。
|
||||
- 现有物理删除按钮移除,直接调用旧删除 API 时的稳定拒绝提示。
|
||||
- Web 生产构建、PDA H5 构建、最相关契约测试和后端业务单元测试。
|
||||
|
||||
## 15. 验收标准
|
||||
|
||||
1. 正式模式的页面、选项、首页和报表不展示任何测试数据。
|
||||
2. 测试模式只展示当前批次数据,不显示正式数据或其他批次数据。
|
||||
3. 授权的普通用户可完成测试业务链,但无法管理、纳入、关闭或清理批次。
|
||||
4. 正式与测试数据、不同测试批次之间不能混用。
|
||||
5. 超级管理员可预览并完整纳入旧测试数据,范围变化时旧确认失效。
|
||||
6. `ACTIVE` 批次不能清理;`CLOSED` 批次不能新增或修改业务数据。
|
||||
7. 整批清理成功后,所有相关主数据、单据、明细、库存和追溯记录消失,报表不再返回该批次数据。
|
||||
8. 清理任一步失败时不删除任何数据,批次仍为 `CLOSED`。
|
||||
9. 超级管理员也无法通过批次清理或旧删除 API 物理删除正式数据。
|
||||
10. 已清理批次保留操作人、时间和分类数量,且日志不包含凭据或敏感信息。
|
||||
|
||||
## 16. 已知风险与控制
|
||||
|
||||
- **旧数据闭包可能过大**:旧测试数据如果复用了正式仓库、现场或托盘,关联闭包可能扩展到大量正式数据。系统必须展示完整预览并允许取消,不提供忽略关联的强制选项。
|
||||
- **大批量事务时间较长**:在目标数据库上通过压力测试确定在线清理上限,并将上限写入部署配置和运维文档。在线接口必须阻止超过上限的批次,且不得通过分段提交规避原子性;超大批次由 DBA 在备份后使用受审计脚本处理。数值上限依赖目标环境,本设计不在缺少实测数据时猜测固定值。
|
||||
- **请求上下文被伪造**:后端每次验证授权、批次状态和数据归属,不相信前端显示或请求头本身。
|
||||
- **漏加查询条件导致统计污染**:使用统一批次上下文和 DAO 查询辅助方法,并用契约测试盘点全部业务查询。
|
||||
- **仅前端隐藏不能提供安全性**:批次管理、纳入和清理均在后端强制超级管理员检查。
|
||||
- **迁移与实际数据存在差异**:应用前在目标数据库执行只读外键和表结构盘点,与代码模型和迁移脚本三方对照,不假设生产结构与仓库 SQL 完全一致。
|
||||
+275
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,7 @@ VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
|
||||
|
||||
不要把包含真实凭据的环境文件或地址配置提交到仓库。
|
||||
|
||||
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../docs/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
|
||||
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
|
||||
|
||||
### vscode
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.9.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"scripts": {
|
||||
"test:business": "node --test tests/business-contract.test.mjs",
|
||||
"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",
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function getFillingStrainOptions() {
|
||||
|
||||
export function scanFillingPallet(qrToken) {
|
||||
return request({
|
||||
url: "/business/filling/scan/" + qrToken,
|
||||
url: "/business/filling/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function getInboundWarehouseOptions() {
|
||||
|
||||
export function scanInboundPallet(qrToken, query) {
|
||||
return request({
|
||||
url: "/business/inbound/scan/" + qrToken,
|
||||
url: "/business/inbound/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export function getReturnWarehouseOptions() {
|
||||
|
||||
export function scanReturnPallet(qrToken, query) {
|
||||
return request({
|
||||
url: "/business/return/scan/" + qrToken,
|
||||
url: "/business/return/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export function getShipmentSiteOptions() {
|
||||
|
||||
export function scanShipmentPallet(qrToken, query) {
|
||||
return request({
|
||||
url: "/business/shipment/scan/" + qrToken,
|
||||
url: "/business/shipment/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -16,13 +16,37 @@ export function getUsageLineOptions(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/" + qrToken,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
maxlength="128"
|
||||
:focus="focusRequested"
|
||||
:disabled="disabled || loading"
|
||||
placeholder="请扫描或输入托盘二维码后按回车"
|
||||
placeholder="扫描二维码或输入托盘号后按回车"
|
||||
@confirm="handleSubmit"
|
||||
@input="clearError"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="testBatchStore.isTestMode"
|
||||
class="flex items-start gap-3 rounded-xl border border-red-300 bg-red-50 px-3 py-3"
|
||||
>
|
||||
<view class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-red-500 text-white">
|
||||
<view class="i-mdi-flask-outline text-sm"></view>
|
||||
<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="min-w-0">
|
||||
<text class="block text-sm font-bold text-red-700">测试数据</text>
|
||||
<text class="mt-0.5 block break-all text-xs leading-5 text-red-600">
|
||||
<view class="test-batch-copy">
|
||||
<text class="test-batch-title">测试数据</text>
|
||||
<text class="test-batch-value">
|
||||
当前批次:{{ testBatchStore.currentBatchName || "已选测试批次" }}
|
||||
</text>
|
||||
</view>
|
||||
@@ -20,3 +17,62 @@ 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>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<view class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="flex items-center justify-between gap-3">
|
||||
<view class="min-w-0">
|
||||
<text class="block text-sm font-bold text-gray-800">数据模式</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
<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="testBatchStore.isTestMode ? 'border-red-300 bg-red-50 text-red-700' : 'border-gray-200 bg-gray-50 text-gray-600'"
|
||||
class="shrink-0 rounded-full border px-2.5 py-1 text-xs font-semibold"
|
||||
class="mode-switch-status"
|
||||
:class="testBatchStore.isTestMode ? 'is-test' : 'is-formal'"
|
||||
>
|
||||
{{ testBatchStore.isTestMode ? "测试数据" : "正式数据" }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="flex rounded-xl bg-gray-100 p-1">
|
||||
<view class="mode-switch-controls">
|
||||
<button
|
||||
class="flex-1 rounded-lg py-2 text-xs transition-colors"
|
||||
:class="!testBatchStore.isTestMode ? 'bg-white font-semibold text-gray-800 shadow-sm' : 'text-gray-500'"
|
||||
class="mode-switch-button"
|
||||
:class="{ active: !testBatchStore.isTestMode }"
|
||||
:disabled="loading"
|
||||
size="mini"
|
||||
@click="handleModeClick('formal')"
|
||||
@@ -26,8 +26,8 @@
|
||||
正式
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-lg py-2 text-xs transition-colors"
|
||||
:class="testBatchStore.isTestMode ? 'bg-red-500 font-semibold text-white shadow-sm' : 'text-gray-500'"
|
||||
class="mode-switch-button"
|
||||
:class="{ 'active-test': testBatchStore.isTestMode }"
|
||||
:disabled="loading || !activeOptions.length"
|
||||
size="mini"
|
||||
@click="handleModeClick('test')"
|
||||
@@ -36,8 +36,11 @@
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="testBatchStore.isTestMode || activeOptions.length" class="space-y-1">
|
||||
<text class="block text-xs text-gray-500">测试批次</text>
|
||||
<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"
|
||||
@@ -45,15 +48,23 @@
|
||||
:disabled="loading || !activeOptions.length"
|
||||
@change="handleBatchChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedBatch?.batchName || (loading ? "加载中..." : "暂无可用测试批次") }}
|
||||
<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="flex items-center justify-between gap-2 rounded-lg bg-orange-50 px-3 py-2">
|
||||
<text class="min-w-0 text-xs leading-5 text-orange-700">{{ loadError }}</text>
|
||||
<button class="shrink-0 text-xs text-orange-700" size="mini" :disabled="loading" @click="loadOptions">
|
||||
<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>
|
||||
@@ -151,3 +162,191 @@ async function handleBatchChange(event) {
|
||||
|
||||
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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{
|
||||
"path": "pages/business/filling/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "托盘装填"
|
||||
"navigationBarTitleText": "工厂装填"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -158,5 +158,15 @@
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "智链云枢管理系统",
|
||||
"navigationBarBackgroundColor": "#FFFFFF"
|
||||
},
|
||||
"condition" : { //模式配置,仅开发期间生效
|
||||
"current": 0, //当前激活的模式(list 的索引项)
|
||||
"list": [
|
||||
{
|
||||
"name": "", //模式名称
|
||||
"path": "", //启动页面,必选
|
||||
"query": "" //启动参数,在页面的onLoad函数里面得到
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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="block text-xl font-bold text-gray-800">工厂装填</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描空托盘,录入菌种、批次、规格和初始包数
|
||||
</text>
|
||||
@@ -80,6 +80,18 @@
|
||||
@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
|
||||
@@ -97,9 +109,8 @@
|
||||
v-model="form.initialPackages"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
type="number"
|
||||
placeholder="请输入正整数"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
placeholder="固定 100 包"
|
||||
disabled
|
||||
/>
|
||||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
|
||||
@@ -177,8 +188,10 @@ const form = reactive({
|
||||
warehouseId: null,
|
||||
strainId: null,
|
||||
batchNo: "",
|
||||
productionDate: "",
|
||||
expiryDate: "",
|
||||
packageSpec: "",
|
||||
initialPackages: "",
|
||||
initialPackages: 100,
|
||||
});
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
@@ -251,8 +264,10 @@ function resetOperation() {
|
||||
form.warehouseId = null;
|
||||
form.strainId = null;
|
||||
form.batchNo = "";
|
||||
form.productionDate = "";
|
||||
form.expiryDate = "";
|
||||
form.packageSpec = "";
|
||||
form.initialPackages = "";
|
||||
form.initialPackages = 100;
|
||||
scanError.value = "";
|
||||
formError.value = "";
|
||||
scanLoading.value = false;
|
||||
@@ -280,7 +295,7 @@ function handleFillingScan(qrToken) {
|
||||
flowState: pallet.flowState,
|
||||
capacityPackages: pallet.capacityPackages,
|
||||
};
|
||||
form.qrToken = qrToken;
|
||||
form.qrToken = pallet.qrToken || qrToken;
|
||||
form.warehouseId = pallet.warehouseId;
|
||||
loadStrainOptions();
|
||||
})
|
||||
@@ -313,6 +328,12 @@ 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;
|
||||
@@ -329,8 +350,12 @@ function submitFilling() {
|
||||
formError.value = "请完整填写菌种、批次和包装规格";
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(initialPackages) || initialPackages <= 0) {
|
||||
formError.value = "初始包数必须为正整数";
|
||||
if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
|
||||
formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
|
||||
return;
|
||||
}
|
||||
if (initialPackages !== 100) {
|
||||
formError.value = "新装填固定为 100 包";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
@@ -340,6 +365,8 @@ function submitFilling() {
|
||||
warehouseId: form.warehouseId,
|
||||
strainId: form.strainId,
|
||||
batchNo: form.batchNo.trim(),
|
||||
productionDate: form.productionDate,
|
||||
expiryDate: form.expiryDate,
|
||||
packageSpec: form.packageSpec.trim(),
|
||||
initialPackages,
|
||||
idempotencyKey: ensureIdempotencyKey(),
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<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="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"
|
||||
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"
|
||||
@@ -20,12 +20,17 @@
|
||||
</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 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>
|
||||
|
||||
<template v-else>
|
||||
<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">
|
||||
@@ -155,8 +160,8 @@
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
|
||||
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
|
||||
<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">
|
||||
@@ -195,6 +200,7 @@ 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,
|
||||
@@ -229,7 +235,7 @@ const confirmState = reactive({ shipmentId: null, key: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
const canShipment = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:shipment:list",
|
||||
"business:shipment:query",
|
||||
@@ -238,6 +244,9 @@ const canPage = computed(() =>
|
||||
"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"]),
|
||||
@@ -274,6 +283,11 @@ 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 || "-"}`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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 />
|
||||
@@ -15,6 +15,25 @@
|
||||
</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>
|
||||
@@ -44,31 +63,81 @@
|
||||
<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>
|
||||
<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="lineOptions"
|
||||
:range="linePickerOptions"
|
||||
range-key="name"
|
||||
:value="lineIndex"
|
||||
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||
:disabled="operationPending || lineOptionLoading || !scannedPallet"
|
||||
@change="handleLineChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedLineName || "请选择现场产线" }}
|
||||
{{ 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>
|
||||
@@ -93,7 +162,7 @@
|
||||
</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 || !!usageResult"
|
||||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
|
||||
@click="submitUsage"
|
||||
>
|
||||
{{ submitLoading ? "提交中" : "确认使用" }}
|
||||
@@ -120,11 +189,15 @@
|
||||
>
|
||||
<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.lineId }} · 使用 {{ row.usedPackages }} 包
|
||||
托盘 #{{ 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>
|
||||
@@ -135,35 +208,54 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
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 idempotencyKey = ref("");
|
||||
const form = reactive({ qrToken: "", lineId: null, usedPackages: "" });
|
||||
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(() =>
|
||||
@@ -177,19 +269,225 @@ 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);
|
||||
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
|
||||
const linePickerOptions = computed(() => [
|
||||
{ id: null, name: "不选择产线" },
|
||||
...lineOptions.value,
|
||||
]);
|
||||
const lineIndex = computed(() => {
|
||||
const index = lineOptions.value.findIndex((item) => item.id === form.lineId);
|
||||
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;
|
||||
@@ -214,56 +512,104 @@ function resetUsageOperation() {
|
||||
operationSession += 1;
|
||||
scanGeneration += 1;
|
||||
optionGeneration += 1;
|
||||
fifoGeneration += 1;
|
||||
scannedPallet.value = null;
|
||||
usageResult.value = null;
|
||||
lineOptions.value = [];
|
||||
tunnelOptions.value = [];
|
||||
lineLocked.value = false;
|
||||
idempotencyKey.value = "";
|
||||
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 loadLineOptions(siteId, session, scanRequestGeneration) {
|
||||
function loadUsageOptions(pallet, session, scanRequestGeneration) {
|
||||
const generation = ++optionGeneration;
|
||||
const isCurrent = () => generation === optionGeneration &&
|
||||
session === operationSession && scanRequestGeneration === scanGeneration;
|
||||
optionLoading.value = true;
|
||||
getUsageLineOptions(siteId)
|
||||
.then((response) => {
|
||||
if (
|
||||
generation !== optionGeneration ||
|
||||
session !== operationSession ||
|
||||
scanRequestGeneration !== scanGeneration
|
||||
) {
|
||||
return;
|
||||
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 = "该现场暂无可用隧道";
|
||||
}
|
||||
lineOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
if (!lineOptions.value.length) operationError.value = "该现场暂无可用产线";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== optionGeneration || session !== operationSession) return;
|
||||
lineOptions.value = [];
|
||||
operationError.value = errorMessage(error, "产线选项加载失败,请稍后重新扫码");
|
||||
if (!isCurrent()) return;
|
||||
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === optionGeneration && session === operationSession) {
|
||||
optionLoading.value = false;
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function handleUsageScan(qrToken) {
|
||||
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 || session !== operationSession) return;
|
||||
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
|
||||
const pallet = response.data;
|
||||
if (
|
||||
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
|
||||
@@ -277,13 +623,26 @@ function handleUsageScan(qrToken) {
|
||||
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 = qrToken;
|
||||
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;
|
||||
@@ -292,17 +651,18 @@ function handleUsageScan(qrToken) {
|
||||
];
|
||||
} else {
|
||||
lineLocked.value = false;
|
||||
form.lineId = null;
|
||||
loadLineOptions(pallet.siteId, session, generation);
|
||||
form.lineId = rejectedOperation?.payload.lineId ?? null;
|
||||
}
|
||||
loadUsageOptions(pallet, session, generation);
|
||||
loadFifoRecommendation();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration && session === operationSession) {
|
||||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration && session === operationSession) {
|
||||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||
scanLoading.value = false;
|
||||
}
|
||||
});
|
||||
@@ -318,63 +678,143 @@ function handleScanDuplicate() {
|
||||
}
|
||||
|
||||
function handleLineChange(event) {
|
||||
if (lineLocked.value) return;
|
||||
const option = lineOptions.value[Number(event.detail.value)];
|
||||
form.lineId = option?.id || null;
|
||||
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 (!submitLoading.value) idempotencyKey.value = "";
|
||||
if (operationPending.value) return;
|
||||
submitError.value = "";
|
||||
}
|
||||
|
||||
function ensureIdempotencyKey() {
|
||||
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||
return idempotencyKey.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;
|
||||
}
|
||||
}
|
||||
|
||||
function submitUsage() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
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 (!form.lineId) {
|
||||
submitError.value = "请选择使用产线";
|
||||
if (!tunnelLocked.value || !form.tunnelId) {
|
||||
submitError.value = "请先选择隧道并确认入隧";
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||||
submitError.value = "使用包数必须为正整数";
|
||||
return;
|
||||
}
|
||||
if (usedPackages > scannedPallet.value.remainingPackages) {
|
||||
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 = "";
|
||||
addUsage({
|
||||
qrToken: form.qrToken,
|
||||
lineId: form.lineId,
|
||||
usedPackages,
|
||||
idempotencyKey: ensureIdempotencyKey(),
|
||||
})
|
||||
.then((response) => {
|
||||
usageResult.value = response.data;
|
||||
idempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
|
||||
loadUsageList();
|
||||
})
|
||||
.catch((error) => {
|
||||
submitError.value = `${errorMessage(error, "现场使用提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
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
@@ -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 h-20 w-60 items-center justify-center rounded-2xl bg-gray-800 px-4 shadow-lg"
|
||||
>
|
||||
<image
|
||||
class="h-16 w-full"
|
||||
:src="globalConfig.appInfo.logo"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</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,6 +105,7 @@ import { useConfigStore, useUserStore } from "@/store";
|
||||
const { proxy } = getCurrentInstance();
|
||||
const globalConfig = useConfigStore().config;
|
||||
const codeUrl = ref("");
|
||||
const loading = ref(false);
|
||||
// 验证码开关
|
||||
const captchaEnabled = ref(true);
|
||||
// 用户注册开关
|
||||
@@ -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>
|
||||
|
||||
@@ -1,231 +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">
|
||||
<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>
|
||||
|
||||
<TestBatchModeSwitch />
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
|
||||
<!-- PDA Business Section -->
|
||||
<view v-if="visibleBusinessEntries.length">
|
||||
<view class="mb-4 flex items-center space-x-2">
|
||||
<view class="h-4 w-1 rounded-full bg-emerald-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="grid grid-cols-3 gap-4">
|
||||
<view
|
||||
v-for="entry in visibleBusinessEntries"
|
||||
:key="entry.path"
|
||||
class="flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBusinessEntry(entry)"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600"
|
||||
>
|
||||
<view :class="entry.icon" class="text-xl"></view>
|
||||
</view>
|
||||
<text class="text-center text-[11px] font-medium text-gray-600">
|
||||
{{ entry.title }}
|
||||
</text>
|
||||
<text class="text-center text-[10px] text-gray-400">
|
||||
{{ entry.description }}
|
||||
</text>
|
||||
</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>
|
||||
@@ -234,113 +65,297 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, 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" },
|
||||
]);
|
||||
const businessEntries = [
|
||||
{
|
||||
path: "/pages/business/inbound/index",
|
||||
title: "仓库入库",
|
||||
description: "扫描托盘登记入库",
|
||||
icon: "i-mdi-package-down",
|
||||
color: "blue",
|
||||
permissions: [
|
||||
"business:inbound:list",
|
||||
"business:inbound:query",
|
||||
"business:inbound:add",
|
||||
"business:inbound:edit",
|
||||
"business:inbound:confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pages/business/filling/index",
|
||||
title: "托盘装填",
|
||||
description: "录入菌种和包数",
|
||||
icon: "i-mdi-package-variant-closed-plus",
|
||||
color: "green",
|
||||
permissions: [
|
||||
"business:filling:list",
|
||||
"business:filling:query",
|
||||
"business:filling:add",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pages/business/shipment/index",
|
||||
title: "发货出库",
|
||||
description: "扫码创建发货单",
|
||||
icon: "i-mdi-truck-delivery",
|
||||
color: "orange",
|
||||
permissions: [
|
||||
"business:shipment:list",
|
||||
"business:shipment:query",
|
||||
"business:shipment:add",
|
||||
"business:shipment:edit",
|
||||
"business:shipment:confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pages/business/usage/index",
|
||||
title: "现场使用",
|
||||
description: "绑定产线记录消耗",
|
||||
icon: "i-mdi-factory",
|
||||
color: "purple",
|
||||
permissions: [
|
||||
"business:usage:list",
|
||||
"business:usage:query",
|
||||
"business:usage:add",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pages/business/return/index",
|
||||
title: "退回仓库",
|
||||
description: "申请和确认退回",
|
||||
icon: "i-mdi-undo-variant",
|
||||
color: "red",
|
||||
permissions: [
|
||||
"business:return:list",
|
||||
"business:return:query",
|
||||
"business:return:request",
|
||||
"business:return:confirm",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const visibleBusinessEntries = computed(() =>
|
||||
businessEntries.filter((entry) => proxy.$auth.hasPermiOr(entry.permissions)),
|
||||
businessMenuEntries.filter(canOpenBusinessEntry),
|
||||
);
|
||||
|
||||
function clickBannerItem(item) {
|
||||
console.info(item);
|
||||
}
|
||||
|
||||
function changeSwiper(e) {
|
||||
current.value = e.detail.current;
|
||||
}
|
||||
|
||||
function handleToAiChat() {
|
||||
handleBuilding();
|
||||
}
|
||||
|
||||
function handleToUserList() {
|
||||
handleBuilding();
|
||||
function canOpenBusinessEntry(entry) {
|
||||
return entry.permissions
|
||||
? proxy.$auth.hasPermiOr(entry.permissions)
|
||||
: proxy.$auth.hasPermi(entry.permission);
|
||||
}
|
||||
|
||||
function handleBusinessEntry(entry) {
|
||||
if (!proxy.$auth.hasPermiOr(entry.permissions)) {
|
||||
proxy.$modal.msgError("当前账号没有该业务权限");
|
||||
if (!canOpenBusinessEntry(entry)) {
|
||||
proxy.$modal.msgError("当前账号没有该业务菜单权限");
|
||||
return;
|
||||
}
|
||||
proxy.$tab.navigateTo(entry.path);
|
||||
}
|
||||
|
||||
function handleBuilding() {
|
||||
proxy.$modal.msg("模块建设中~");
|
||||
}
|
||||
</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.
|
After Width: | Height: | Size: 68 KiB |
@@ -101,6 +101,7 @@ const request = async (config) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -126,6 +127,11 @@ 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(body);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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));
|
||||
|
||||
@@ -33,7 +34,7 @@ const apiContracts = {
|
||||
"src/api/business/inbound.js": [
|
||||
["listInbound", "'/business/inbound/list'", "get"],
|
||||
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
|
||||
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"],
|
||||
["scanInboundPallet", "'/business/inbound/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getInbound", "'/business/inbound/' + inboundId", "get"],
|
||||
["addInbound", "'/business/inbound'", "post"],
|
||||
["updateInbound", "'/business/inbound'", "put"],
|
||||
@@ -42,7 +43,7 @@ const apiContracts = {
|
||||
"src/api/business/filling.js": [
|
||||
["listFilling", "'/business/filling/list'", "get"],
|
||||
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
|
||||
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"],
|
||||
["scanFillingPallet", "'/business/filling/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getFilling", "'/business/filling/' + fillingId", "get"],
|
||||
["addFilling", "'/business/filling'", "post"],
|
||||
],
|
||||
@@ -50,7 +51,7 @@ const apiContracts = {
|
||||
["listShipment", "'/business/shipment/list'", "get"],
|
||||
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
|
||||
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
|
||||
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"],
|
||||
["scanShipmentPallet", "'/business/shipment/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getShipment", "'/business/shipment/' + shipmentId", "get"],
|
||||
["addShipment", "'/business/shipment'", "post"],
|
||||
["updateShipment", "'/business/shipment'", "put"],
|
||||
@@ -59,14 +60,14 @@ const apiContracts = {
|
||||
"src/api/business/usage.js": [
|
||||
["listUsage", "'/business/usage/list'", "get"],
|
||||
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
|
||||
["scanUsagePallet", "'/business/usage/scan/' + qrToken", "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/' + qrToken", "get"],
|
||||
["scanReturnPallet", "'/business/return/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["requestReturn", "'/business/return/request'", "post"],
|
||||
["getReturn", "'/business/return/' + returnId", "get"],
|
||||
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
|
||||
@@ -145,7 +146,8 @@ test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async
|
||||
});
|
||||
|
||||
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
|
||||
const pages = JSON.parse(
|
||||
// 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));
|
||||
@@ -159,21 +161,95 @@ test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
|
||||
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
|
||||
}
|
||||
|
||||
const workSource = await readFile(
|
||||
path.join(appRoot, "src/pages/work/index.vue"),
|
||||
"utf8",
|
||||
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%/,
|
||||
);
|
||||
assert.match(workSource, /hasPermiOr/);
|
||||
assert.match(workSource, /business:inbound/);
|
||||
assert.match(workSource, /business:filling/);
|
||||
assert.match(workSource, /business:shipment/);
|
||||
assert.match(workSource, /business:usage/);
|
||||
assert.match(workSource, /business:return/);
|
||||
assert.match(workSource, /pages\/business\/inbound\/index/);
|
||||
assert.match(workSource, /pages\/business\/filling\/index/);
|
||||
assert.match(workSource, /pages\/business\/shipment\/index/);
|
||||
assert.match(workSource, /pages\/business\/usage\/index/);
|
||||
assert.match(workSource, /pages\/business\/return\/index/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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'
|
||||
# 当前启用的密钥版本标识
|
||||
|
||||
@@ -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 '-'
|
||||
@@ -1,3 +0,0 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -1,173 +0,0 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import ConfigCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='参数配置相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_CONFIG_COMMAND_CONTROLLER = ConfigCommandController()
|
||||
|
||||
|
||||
@app.command('list', help='查看参数配置列表')
|
||||
def list_command(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
config_name: Annotated[str, typer.Option('--config-name', help='按参数名称过滤')] = '',
|
||||
config_key: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
'--config-key',
|
||||
help='按参数键名过滤',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys,
|
||||
),
|
||||
] = '',
|
||||
config_type: Annotated[Literal['Y', 'N'] | None, typer.Option('--config-type', help='按系统内置标记过滤')] = None,
|
||||
begin_date: Annotated[str, typer.Option('--begin-date', help='按创建时间开始日期过滤,格式 YYYY-MM-DD')] = '',
|
||||
end_date: Annotated[str, typer.Option('--end-date', help='按创建时间结束日期过滤,格式 YYYY-MM-DD')] = '',
|
||||
paged: Annotated[bool, typer.Option('--paged/--no-paged', help='是否启用分页结果')] = False,
|
||||
page_num: Annotated[int, typer.Option('--page-num', min=1, help='分页页码')] = 1,
|
||||
page_size: Annotated[int, typer.Option('--page-size', min=1, help='分页每页数量')] = 20,
|
||||
) -> None:
|
||||
"""
|
||||
查看参数配置列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param config_name: 参数名称过滤条件
|
||||
:param config_key: 参数键名过滤条件
|
||||
:param config_type: 参数类型过滤条件
|
||||
:param begin_date: 查询开始日期
|
||||
:param end_date: 查询结束日期
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.list_configs(
|
||||
env,
|
||||
output,
|
||||
config_name=config_name,
|
||||
config_key=config_key,
|
||||
config_type=config_type,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('get', help='查看单个参数配置详情')
|
||||
def get_command(
|
||||
config_key: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help='需要查询的参数键名',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
source: Annotated[Literal['db', 'cache', 'both'], typer.Option('--source', help='读取来源')] = 'both',
|
||||
) -> None:
|
||||
"""
|
||||
查看单个参数配置详情。
|
||||
|
||||
:param config_key: 需要查询的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param source: 配置读取来源
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.get_config(config_key, env, output, source=source)
|
||||
|
||||
|
||||
@app.command('set', help='新增或更新单个参数配置')
|
||||
def set_command(
|
||||
config_key: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help='需要写入的参数键名',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
value: Annotated[str, typer.Option('--value', help='参数键值')] = ...,
|
||||
name: Annotated[str | None, typer.Option('--name', help='参数名称,新增时必填')] = None,
|
||||
config_type: Annotated[Literal['Y', 'N'] | None, typer.Option('--config-type', help='系统内置标记')] = None,
|
||||
remark: Annotated[str | None, typer.Option('--remark', help='参数备注')] = None,
|
||||
) -> None:
|
||||
"""
|
||||
新增或更新单个参数配置。
|
||||
|
||||
:param config_key: 需要写入的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param value: 参数键值
|
||||
:param name: 参数名称
|
||||
:param config_type: 参数类型
|
||||
:param remark: 参数备注
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.set_config(
|
||||
config_key,
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
value=value,
|
||||
name=name,
|
||||
config_type=config_type,
|
||||
remark=remark,
|
||||
)
|
||||
|
||||
|
||||
@app.command('sync-cache', help='刷新参数配置缓存')
|
||||
def sync_cache(
|
||||
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
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.sync_cache(env, output, allow_prod, yes)
|
||||
|
||||
|
||||
@app.command('doctor', help='诊断参数配置数据库与缓存是否一致')
|
||||
def doctor(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
sample_limit: Annotated[int, typer.Option('--sample-limit', min=1, help='问题示例键名输出上限')] = 10,
|
||||
) -> None:
|
||||
"""
|
||||
诊断参数配置数据库与缓存是否一致。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param sample_limit: 问题示例键名输出上限
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.doctor(env, output, sample_limit=sample_limit)
|
||||
@@ -1,225 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.config import CONFIG_RUNTIME, ConfigRuntimeService
|
||||
|
||||
from .presenter import ConfigCommandPresenter
|
||||
|
||||
|
||||
class ConfigCommandController:
|
||||
"""
|
||||
参数配置命令控制器。
|
||||
|
||||
该控制器负责组织 `config` 命令组的上下文准备、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: ConfigCommandPresenter | None = None,
|
||||
runtime_service: ConfigRuntimeService | 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 ConfigCommandPresenter()
|
||||
self.runtime_service = runtime_service or CONFIG_RUNTIME
|
||||
|
||||
def list_configs(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
config_name: str,
|
||||
config_key: str,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
begin_date: str,
|
||||
end_date: str,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
查看参数配置列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param config_name: 参数名称过滤条件
|
||||
:param config_key: 参数键名过滤条件
|
||||
:param config_type: 参数类型过滤条件
|
||||
:param begin_date: 查询开始日期
|
||||
:param end_date: 查询结束日期
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.list_configs(
|
||||
config_name=config_name,
|
||||
config_key=config_key,
|
||||
config_type=config_type,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_config_list_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def get_config(
|
||||
self,
|
||||
config_key: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
source: Literal['db', 'cache', 'both'],
|
||||
) -> None:
|
||||
"""
|
||||
查看单个参数配置详情。
|
||||
|
||||
:param config_key: 需要查询的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param source: 配置读取来源
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.get_config(config_key, source=source))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_config_get_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def set_config(
|
||||
self,
|
||||
config_key: str,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
value: str,
|
||||
name: str | None,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
remark: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
新增或更新单个参数配置。
|
||||
|
||||
:param config_key: 需要写入的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param value: 参数键值
|
||||
:param name: 参数名称
|
||||
:param config_type: 参数类型
|
||||
:param remark: 参数备注
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='config set',
|
||||
)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.set_config(
|
||||
config_key,
|
||||
value,
|
||||
config_name=name,
|
||||
config_type=config_type,
|
||||
remark=remark,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def sync_cache(
|
||||
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='config sync-cache',
|
||||
)
|
||||
payload = self.execution_service.run_async(self.runtime_service.sync_config_cache())
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def doctor(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
sample_limit: int,
|
||||
) -> None:
|
||||
"""
|
||||
诊断参数配置数据库与缓存是否一致。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param sample_limit: 问题示例键名输出上限
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.diagnose_config(sample_limit=sample_limit))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_config_doctor_text,
|
||||
text_condition=lambda data: 'error' not in data,
|
||||
)
|
||||
@@ -1,180 +0,0 @@
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class ConfigCommandPresenter:
|
||||
"""
|
||||
参数配置命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `config` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_config_list_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将参数配置列表结果渲染为文本摘要。
|
||||
|
||||
:param payload: 参数配置列表结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
]
|
||||
|
||||
filters = payload.get('filters')
|
||||
if isinstance(filters, dict):
|
||||
lines.extend(self._build_config_filter_lines(filters))
|
||||
|
||||
page_payload = payload.get('page')
|
||||
if isinstance(page_payload, dict):
|
||||
rows = page_payload.get('rows')
|
||||
lines.append(
|
||||
'page: '
|
||||
f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} '
|
||||
f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})'
|
||||
)
|
||||
lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
lines.append('configs: none')
|
||||
return '\n'.join(lines)
|
||||
lines.append('configs:')
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
lines.extend([f' {item}' for item in self._build_config_item_lines(row)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
items = payload.get('items')
|
||||
lines.append(f'count: {payload.get("count", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('configs: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('configs:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
lines.extend([f' {line}' for line in self._build_config_item_lines(item)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_config_get_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将单个参数配置详情结果渲染为文本摘要。
|
||||
|
||||
:param payload: 参数配置详情结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'key: {payload.get("key", "")}',
|
||||
f'source: {payload.get("source", "")}',
|
||||
]
|
||||
|
||||
if payload.get('source') == 'both':
|
||||
lines.append(f'in_sync: {str(payload.get("inSync", False)).lower()}')
|
||||
|
||||
if payload.get('source') in {'db', 'both'}:
|
||||
lines.extend(self._build_config_detail_section('database', payload.get('database')))
|
||||
if payload.get('source') in {'cache', 'both'}:
|
||||
lines.extend(self._build_config_detail_section('cache', payload.get('cache')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_config_doctor_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将参数配置诊断结果渲染为文本摘要。
|
||||
|
||||
:param payload: 参数配置诊断结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'database_count: {payload.get("databaseCount", 0)}',
|
||||
f'cache_count: {payload.get("cacheCount", 0)}',
|
||||
f'missing_in_cache_count: {payload.get("missingInCacheCount", 0)}',
|
||||
f'orphan_in_cache_count: {payload.get("orphanInCacheCount", 0)}',
|
||||
f'mismatch_count: {payload.get("mismatchCount", 0)}',
|
||||
f'sample_limit: {payload.get("sampleLimit", 0)}',
|
||||
]
|
||||
lines.extend(self._build_config_doctor_items('missing_in_cache', payload.get('missingInCache')))
|
||||
lines.extend(self._build_config_doctor_items('orphan_in_cache', payload.get('orphanInCache')))
|
||||
lines.extend(self._build_config_doctor_items('mismatch_keys', payload.get('mismatchKeys')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_config_filter_lines(filters: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建参数配置过滤条件文本行。
|
||||
|
||||
:param filters: 过滤条件字典
|
||||
:return: 过滤条件文本行列表
|
||||
"""
|
||||
active_filters = []
|
||||
for key, value in filters.items():
|
||||
if value in (None, '', False):
|
||||
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_config_item_lines(config_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单条参数配置记录的文本行。
|
||||
|
||||
:param config_item: 单条参数配置记录
|
||||
:return: 文本行列表
|
||||
"""
|
||||
config_id = config_item.get('configId', '-')
|
||||
config_key = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configKey', ''), 50)
|
||||
config_name = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configName', ''), 24)
|
||||
config_type = config_item.get('configType', '-')
|
||||
config_value = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configValue', ''), 80)
|
||||
remark = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', ''), 60)
|
||||
return [
|
||||
f'- [{config_id}] {config_key} | 名称: {config_name} | 内置: {config_type}',
|
||||
f' value: {config_value or "-"}',
|
||||
f' remark: {remark or "-"}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_config_detail_section(title: str, config_item: dict[str, object] | None) -> list[str]:
|
||||
"""
|
||||
构建单个配置来源的详情文本段落。
|
||||
|
||||
:param title: 段落标题
|
||||
:param config_item: 配置详情字典
|
||||
:return: 文本行列表
|
||||
"""
|
||||
if not isinstance(config_item, dict):
|
||||
return [f'{title}: none']
|
||||
|
||||
config_key = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configKey', ''), 60)
|
||||
config_name = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configName', ''), 30)
|
||||
config_value = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configValue', ''), 120)
|
||||
config_type = config_item.get('configType', '-')
|
||||
remark = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', ''), 80)
|
||||
config_id = config_item.get('configId', '-')
|
||||
return [
|
||||
f'{title}:',
|
||||
f' id: {config_id}',
|
||||
f' key: {config_key or "-"}',
|
||||
f' name: {config_name or "-"}',
|
||||
f' value: {config_value or "-"}',
|
||||
f' type: {config_type}',
|
||||
f' remark: {remark or "-"}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_config_doctor_items(title: str, items: list[object] | object) -> list[str]:
|
||||
"""
|
||||
构建配置诊断问题示例段落。
|
||||
|
||||
:param title: 段落标题
|
||||
:param items: 问题键名列表
|
||||
:return: 文本行列表
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
return [f'{title}: none']
|
||||
return [f'{title}:', *[f' - {item}' for item in items]]
|
||||
@@ -1,3 +0,0 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -1,94 +0,0 @@
|
||||
import typer
|
||||
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import CryptoCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='传输加密相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_CRYPTO_COMMAND_CONTROLLER = CryptoCommandController()
|
||||
|
||||
|
||||
@app.command('validate', help='校验传输加密配置')
|
||||
def validate(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
校验传输加密配置。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.validate(env, output)
|
||||
|
||||
|
||||
@app.command('keygen', help='生成新的传输加密密钥对')
|
||||
def keygen(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
kid: str = typer.Option('default', '--kid', help='生成的密钥版本标识'),
|
||||
key_size: int = typer.Option(2048, '--key-size', help='RSA 密钥长度'),
|
||||
) -> None:
|
||||
"""
|
||||
生成新的传输加密密钥对。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param kid: 生成的密钥版本标识
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.keygen(env, output, kid=kid, key_size=key_size)
|
||||
|
||||
|
||||
@app.command('rotate', help='生成传输加密密钥轮换辅助结果')
|
||||
def rotate(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
next_kid: str = typer.Option('rotated', '--next-kid', help='轮换后的新密钥版本标识'),
|
||||
key_size: int = typer.Option(2048, '--key-size', help='新密钥的 RSA 长度'),
|
||||
) -> None:
|
||||
"""
|
||||
生成传输加密密钥轮换辅助结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param next_kid: 轮换后的新密钥版本标识
|
||||
:param key_size: 新密钥的 RSA 长度
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.rotate(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
next_kid=next_kid,
|
||||
key_size=key_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('export-public', help='查看当前运行环境的公钥信息')
|
||||
def export_public(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
导出当前运行环境的公钥信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.export_public(env, output)
|
||||
@@ -1,125 +0,0 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService
|
||||
|
||||
from .presenter import CryptoCommandPresenter
|
||||
|
||||
|
||||
class CryptoCommandController:
|
||||
"""
|
||||
传输加密命令控制器。
|
||||
|
||||
该控制器负责组织 `crypto` 命令组的上下文准备、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: CryptoCommandPresenter | None = None,
|
||||
runtime_service: CryptoRuntimeService | 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 CryptoCommandPresenter()
|
||||
self.runtime_service = runtime_service or CRYPTO_RUNTIME
|
||||
|
||||
def validate(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(ctx, self.runtime_service.validate_crypto_config())
|
||||
|
||||
def keygen(self, env: str, output: str, *, kid: str, key_size: int) -> None:
|
||||
"""
|
||||
生成新的传输加密密钥对。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param kid: 生成的密钥版本标识
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.generate_crypto_key_pair(kid, key_size)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_crypto_keygen_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def rotate(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
next_kid: str,
|
||||
key_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
生成传输加密密钥轮换辅助结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param next_kid: 轮换后的新密钥版本标识
|
||||
:param key_size: 新密钥的 RSA 长度
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='crypto rotate',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.build_rotation_payload(next_kid, key_size))
|
||||
|
||||
def export_public(self, env: str, output: str) -> None:
|
||||
"""
|
||||
导出当前运行环境的公钥信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.export_public_key()
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_export_public_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
class CryptoCommandPresenter:
|
||||
"""
|
||||
传输加密命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `crypto` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_crypto_keygen_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将密钥生成结果渲染为文本摘要。
|
||||
|
||||
:param payload: 密钥生成结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
env_patch = payload.get('envPatch')
|
||||
env_patch_keys = sorted(env_patch.keys()) if isinstance(env_patch, dict) else []
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'kid: {payload.get("kid", "-")}',
|
||||
f'key_size: {payload.get("keySize", "-")}',
|
||||
]
|
||||
lines.extend(self._build_multiline_block('public_key', payload.get('publicKey')))
|
||||
lines.extend(self._build_multiline_block('private_key', payload.get('privateKey')))
|
||||
if env_patch_keys:
|
||||
lines.append('env_patch_keys:')
|
||||
lines.extend(f' - {key}' for key in env_patch_keys)
|
||||
else:
|
||||
lines.append('env_patch_keys: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_export_public_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将公钥导出结果渲染为文本摘要。
|
||||
|
||||
:param payload: 公钥导出结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
public_key_payload = payload.get('publicKey')
|
||||
if not isinstance(public_key_payload, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'public_key: none',
|
||||
]
|
||||
)
|
||||
|
||||
supported_kids = public_key_payload.get('supportedKids')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'kid: {public_key_payload.get("kid", "-")}',
|
||||
f'alg: {public_key_payload.get("alg", "-")}',
|
||||
f'envelope_version: {public_key_payload.get("envelopeVersion", "-")}',
|
||||
f'expire_at: {public_key_payload.get("expireAt", "-")}',
|
||||
]
|
||||
if isinstance(supported_kids, list) and supported_kids:
|
||||
lines.append('supported_kids:')
|
||||
lines.extend(f' - {kid}' for kid in supported_kids)
|
||||
else:
|
||||
lines.append('supported_kids: none')
|
||||
lines.extend(self._build_multiline_block('public_key_pem', public_key_payload.get('publicKey')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_multiline_block(title: str, value: object) -> list[str]:
|
||||
"""
|
||||
将多行文本构建为带标题的块级文本。
|
||||
|
||||
:param title: 块标题
|
||||
:param value: 原始文本值
|
||||
:return: 文本行列表
|
||||
"""
|
||||
text = '' if value is None else str(value).strip()
|
||||
if not text:
|
||||
return [f'{title}: -']
|
||||
return [f'{title}:', ' |', *[f' {line}' for line in text.splitlines()]]
|
||||
@@ -1,3 +0,0 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -1,191 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import DbCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='数据库相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_DB_COMMAND_CONTROLLER = DbCommandController()
|
||||
|
||||
|
||||
@app.command('check', help='检查数据库连接状态')
|
||||
def check(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
检查数据库连接状态。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.check(env, output)
|
||||
|
||||
|
||||
@app.command('current', help='查看当前数据库迁移版本')
|
||||
def current(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看数据库当前迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.current(env, output)
|
||||
|
||||
|
||||
@app.command('upgrade', help='执行数据库升级')
|
||||
def upgrade(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
revision: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
'--revision',
|
||||
help='目标迁移版本',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_alembic_revisions,
|
||||
),
|
||||
] = 'head',
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库升级。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标迁移版本
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.upgrade(env, output, allow_prod, yes, dry_run, revision=revision)
|
||||
|
||||
|
||||
@app.command('init', help='初始化数据库到最新迁移版本')
|
||||
def init(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
初始化数据库到最新迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.init(env, output, allow_prod, yes, dry_run)
|
||||
|
||||
|
||||
@app.command('downgrade', help='执行数据库回退')
|
||||
def downgrade(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
revision: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
'--revision',
|
||||
help='目标回退版本',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_alembic_revisions,
|
||||
),
|
||||
] = '-1',
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库回退。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标回退版本
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.downgrade(env, output, allow_prod, yes, dry_run, revision=revision)
|
||||
|
||||
|
||||
@app.command('revision', help='创建新的数据库迁移版本文件')
|
||||
def revision(
|
||||
message: Annotated[str, typer.Option('--message', '-m', help='迁移说明')],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
autogenerate: bool = typer.Option(False, '--autogenerate/--no-autogenerate', help='是否自动生成迁移内容'),
|
||||
) -> None:
|
||||
"""
|
||||
创建新的数据库迁移版本文件。
|
||||
|
||||
:param message: 迁移说明
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param autogenerate: 是否自动生成迁移内容
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.revision(
|
||||
message,
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
autogenerate=autogenerate,
|
||||
)
|
||||
|
||||
|
||||
@app.command('heads', help='查看当前代码仓库中的 Alembic heads')
|
||||
def heads(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic heads。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.heads(env, output)
|
||||
|
||||
|
||||
@app.command('history', help='查看当前代码仓库中的 Alembic 历史版本')
|
||||
def history(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
limit: Annotated[int, typer.Option('--limit', min=1, help='输出的最大历史记录数量')] = 20,
|
||||
) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic 历史版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param limit: 输出的最大历史记录数量
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.history(env, output, limit=limit)
|
||||
@@ -1,237 +0,0 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService
|
||||
|
||||
from .presenter import DbCommandPresenter
|
||||
|
||||
|
||||
class DbCommandController:
|
||||
"""
|
||||
数据库命令控制器。
|
||||
|
||||
该控制器负责组织 `db` 命令组的上下文准备、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: DbCommandPresenter | None = None,
|
||||
runtime_service: DatabaseRuntimeService | 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 DbCommandPresenter()
|
||||
self.runtime_service = runtime_service or DATABASE_RUNTIME
|
||||
|
||||
def check(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(
|
||||
ctx, self.execution_service.run_async(self.runtime_service.ping_database())
|
||||
)
|
||||
|
||||
def current(self, env: str, output: str) -> None:
|
||||
"""
|
||||
查看数据库当前迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.get_current_revision()
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_current_revision_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def upgrade(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
revision: str,
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库升级。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标迁移版本
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db upgrade',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.upgrade_database(revision, dry_run=dry_run))
|
||||
|
||||
def init(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""
|
||||
初始化数据库到最新迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db init',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.init_database(dry_run=dry_run))
|
||||
|
||||
def downgrade(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
revision: str,
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库回退。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标回退版本
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db downgrade',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.downgrade_database(revision, dry_run=dry_run))
|
||||
|
||||
def revision(
|
||||
self,
|
||||
message: str,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
autogenerate: bool,
|
||||
) -> None:
|
||||
"""
|
||||
创建新的数据库迁移版本文件。
|
||||
|
||||
:param message: 迁移说明
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param autogenerate: 是否自动生成迁移内容
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db revision',
|
||||
)
|
||||
self.execution_service.complete_payload(
|
||||
ctx,
|
||||
self.runtime_service.create_revision(message, autogenerate=autogenerate, dry_run=dry_run),
|
||||
)
|
||||
|
||||
def heads(self, env: str, output: str) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic heads。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.get_alembic_heads()
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_alembic_revisions_text,
|
||||
text_condition=lambda result_data: 'error' not in result_data,
|
||||
)
|
||||
|
||||
def history(self, env: str, output: str, *, limit: int) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic 历史版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param limit: 输出的最大历史记录数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.get_alembic_history(limit=limit)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_alembic_revisions_text,
|
||||
text_condition=lambda result_data: 'error' not in result_data,
|
||||
)
|
||||
@@ -1,71 +0,0 @@
|
||||
class DbCommandPresenter:
|
||||
"""
|
||||
数据库命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `db` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_current_revision_text(payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将数据库当前迁移版本结果渲染为文本摘要。
|
||||
|
||||
:param payload: 当前迁移版本结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'current_revision: {payload.get("currentRevision", "-")}',
|
||||
]
|
||||
)
|
||||
|
||||
def build_alembic_revisions_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将 Alembic 修订版本结果渲染为文本摘要。
|
||||
|
||||
:param payload: 修订版本结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
items = payload.get('items')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'count: {payload.get("count", 0)}',
|
||||
]
|
||||
if 'totalCount' in payload:
|
||||
lines.append(f'total_count: {payload.get("totalCount", 0)}')
|
||||
if 'limit' in payload:
|
||||
lines.append(f'limit: {payload.get("limit", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('items: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('items:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
lines.extend([f' {line}' for line in self._build_alembic_revision_item_lines(item)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_alembic_revision_item_lines(revision_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单个 Alembic 修订版本的文本行。
|
||||
|
||||
:param revision_item: 修订版本结果字典
|
||||
:return: 文本行列表
|
||||
"""
|
||||
down_revisions = revision_item.get('downRevisions')
|
||||
branch_labels = revision_item.get('branchLabels')
|
||||
depends_on = revision_item.get('dependsOn')
|
||||
return [
|
||||
f'- revision: {revision_item.get("revision", "-")}',
|
||||
f' down_revisions: {",".join(down_revisions) if isinstance(down_revisions, list) and down_revisions else "-"}',
|
||||
f' branch_labels: {",".join(branch_labels) if isinstance(branch_labels, list) and branch_labels else "-"}',
|
||||
f' depends_on: {",".join(depends_on) if isinstance(depends_on, list) and depends_on else "-"}',
|
||||
f' doc: {revision_item.get("doc", "") or "-"}',
|
||||
f' path: {revision_item.get("path", "-")}',
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user