Compare commits
44
Commits
3edba99d2d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a71b48ce | ||
|
|
3330592fbe | ||
|
|
a8af1f542b | ||
|
|
a62803ccf0 | ||
|
|
ae662a4941 | ||
|
|
d00c725320 | ||
|
|
3dc2a308eb | ||
|
|
f3393d78fb | ||
|
|
56e646bcc1 | ||
|
|
9a14b692e3 | ||
|
|
ecb254cb54 | ||
|
|
a539b4eb71 | ||
|
|
17d991a74a | ||
|
|
977e7f996e | ||
|
|
d15b8e2f6c | ||
|
|
886a4479c4 | ||
|
|
02b87c9399 | ||
|
|
ae8c4c8c38 | ||
|
|
4706c4afeb | ||
|
|
a5cc91a92a | ||
|
|
8221a5162d | ||
|
|
a7dc1a2a1e | ||
|
|
9ca95125b8 | ||
|
|
169d9889f3 | ||
|
|
1aaed57825 | ||
|
|
b11c457e61 | ||
|
|
91cf862248 | ||
|
|
c97f6ed3bd | ||
|
|
5a76fe7521 | ||
|
|
97b4dc8508 | ||
|
|
f5df2150d8 | ||
|
|
43fe17cc70 | ||
|
|
46eff756de | ||
|
|
b6f606d9f2 | ||
|
|
af5428d0e3 | ||
|
|
c82f5e42dd | ||
|
|
e396fff790 | ||
|
|
763ea1efcc | ||
|
|
17bfa2c5fe | ||
|
|
40c56c6b33 | ||
|
|
9da9cd0a59 | ||
|
|
e690b52007 | ||
|
|
69c13284d8 | ||
|
|
f2629ccf14 |
@@ -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>
|
|
||||||
@@ -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
|
除 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md) 或具体文件另有说明的
|
||||||
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
|
本文件不改变第三方代码所适用的原始许可证。RuoYi 来源代码的版权声明和
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
MIT License 请参阅 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md)。
|
||||||
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.
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
* 前端采用Vue3、Element Plus。
|
* 前端采用Vue3、Element Plus。
|
||||||
* 移动端采用uni-app、Vue3、Vite,内置tailwindcss。
|
* 移动端采用uni-app、Vue3、Vite,内置tailwindcss。
|
||||||
* 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。
|
* 后端采用FastAPI、sqlalchemy、MySQL、Redis、OAuth2 & Jwt。
|
||||||
* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。
|
* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。
|
||||||
* 支持加载动态权限菜单,多方式轻松权限控制。
|
* 支持加载动态权限菜单,多方式轻松权限控制。
|
||||||
* 纯Python版本:
|
* 纯Python版本:
|
||||||
@@ -43,14 +43,24 @@
|
|||||||
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
|
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
|
||||||
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
|
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
|
||||||
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
|
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
|
||||||
* Android PDA 可直接通过浏览器使用响应式 Web 页面,并支持 HID 扫码枪扫码后回车提交。
|
* Android PDA 可通过基于 uni-app 的 H5 页面使用,并支持 HID 扫码枪扫码后回车提交。
|
||||||
|
|
||||||
本轮交付不是 APP/uni-app 原生端,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./docs/pallet-workflow-deployment.md)。
|
本轮交付包含 PDA H5 页面,但暂不打包 APK 或其他原生安装包,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./shierfeng-fastapi-backend/sql/pallet-workflow-deployment.md)。
|
||||||
|
|
||||||
|
测试数据批次的创建、授权、历史数据纳入和清理流程请阅读[测试数据批次操作说明](./shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。
|
||||||
|
|
||||||
|
数据库初始化与升级入口请阅读 [SQL 使用说明](./shierfeng-fastapi-backend/sql/README.md)。新空库直接使用已包含隧道、物料日期、收货和 FIFO 结构的初始化 SQL;已有生产库不得运行全量初始化,请按[生产 Compose 增量迁移说明](./shierfeng-fastapi-backend/sql/production-compose-material-flow-migration-20261008.md)核对适用条件后执行。
|
||||||
|
|
||||||
|
### Git 跟踪范围
|
||||||
|
|
||||||
|
根目录 `.idea/`、`.pnpm-store/`、`.superpowers/`、`docs/`、`logs/` 及后端 `logs/` 仅保留本地,不再提交;Python 缓存及本地依赖同样忽略。必要的业务部署与数据库迁移说明随后端 `sql/` 目录维护。代码、迁移 SQL、测试和必要部署配置仍正常跟踪。
|
||||||
|
|
||||||
|
其他已有克隆首次拉取本次清理提交前,应先备份这些目录中需要保留的文件:Git 可能删除此前被跟踪的副本。此次清理不重写历史,旧提交中的文件仍然存在。
|
||||||
|
|
||||||
## 在线体验
|
## 在线体验
|
||||||
|
|
||||||
* *账号:admin*
|
* *账号:admin*
|
||||||
* *密码:admin123*
|
* *密码:请通过授权渠道获取,不在仓库中记录*
|
||||||
* 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
|
* 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
|
||||||
|
|
||||||
## 项目开发及发布相关
|
## 项目开发及发布相关
|
||||||
@@ -110,17 +120,15 @@ pnpm dev:mp-weixin
|
|||||||
# 进入后端目录
|
# 进入后端目录
|
||||||
cd shierfeng-fastapi-backend
|
cd shierfeng-fastapi-backend
|
||||||
|
|
||||||
# 如果使用的是MySQL数据库,请执行以下命令安装项目依赖环境
|
# 安装项目依赖环境(统一使用 MySQL)
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
# 如果使用的是PostgreSQL数据库,请执行以下命令安装项目依赖环境
|
|
||||||
pip3 install -r requirements-pg.txt
|
|
||||||
|
|
||||||
# 配置环境
|
# 配置环境
|
||||||
在.env.dev文件中配置开发环境的数据库和redis
|
在.env.dev文件中配置开发环境的数据库和redis
|
||||||
|
|
||||||
# 运行sql文件
|
# 运行 SQL 文件
|
||||||
1.新建数据库shierfeng-fastapi(默认,可修改)
|
1. 新建数据库 shierfeng-fastapi(默认,可修改)
|
||||||
2.如果使用的是MySQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi.sql;如果使用的是PostgreSQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi-pg.sql
|
2. 使用命令或数据库连接工具运行 sql 文件夹下的 shierfeng-fastapi.sql
|
||||||
|
|
||||||
# 运行后端
|
# 运行后端
|
||||||
shierfeng app run --env=dev
|
shierfeng app run --env=dev
|
||||||
@@ -131,12 +139,12 @@ shierfeng app run --env=dev
|
|||||||
#### 访问
|
#### 访问
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 默认账号密码
|
# 本地账号
|
||||||
账号:admin
|
账号:admin
|
||||||
密码:admin123
|
密码:请在目标环境安全配置中设置后手工输入,不在仓库中记录
|
||||||
|
|
||||||
# 浏览器访问
|
# 浏览器访问
|
||||||
地址:http://localhost:80
|
地址:http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 发布
|
### 发布
|
||||||
@@ -163,16 +171,25 @@ shierfeng app run --env=prod
|
|||||||
|
|
||||||
### Docker Compose部署方式
|
### Docker Compose部署方式
|
||||||
|
|
||||||
> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化
|
> ⚠️ **说明:** 根目录 Compose 使用 MySQL 和 Redis 命名卷持久化数据。初始化 SQL 只会在 MySQL 数据目录首次为空时执行。
|
||||||
|
|
||||||
#### MySQL版本
|
#### MySQL 全栈版本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f shierfeng-fastapi-test/docker-compose.test.my.yml up -d --build
|
# 首次使用时创建本地配置文件
|
||||||
|
cp .env.docker.example .env
|
||||||
|
# 编辑 .env,至少修改 MYSQL_ROOT_PASSWORD、DB_PASSWORD 和 JWT_SECRET_KEY
|
||||||
|
|
||||||
|
# 启动前只做静态配置校验
|
||||||
|
docker compose config --quiet
|
||||||
|
|
||||||
|
# 构建并启动前端、后端、MySQL、Redis
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
#### PostgreSQL版本
|
浏览器访问:http://localhost:8080;后端直连接口为 `http://localhost:9099`;宿主机连接 MySQL 时使用 `127.0.0.1:13307`。需要局域网访问时,将根目录 `.env` 中的 `COMPOSE_BIND_IP` 改为 `0.0.0.0`,再使用宿主机局域网地址访问前端和后端端口。
|
||||||
|
|
||||||
```bash
|
已有数据库不能通过全量初始化脚本升级。请先备份并核对目标库,再按照 `shierfeng-fastapi-backend/sql/patches/` 下对应的 MySQL 增量补丁和部署文档执行;不要通过删除 Compose 数据卷来代替迁移。
|
||||||
docker compose -f shierfeng-fastapi-test/docker-compose.test.pg.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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 缓存清理。
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,774 +0,0 @@
|
|||||||
# 业务菜单一级展示 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 将“基础资料维护”“业务管理”“报表查询”提升为独立一级菜单,并让六个目标一级目录按 121、122、123、1、2、3 的顺序展示。
|
|
||||||
|
|
||||||
**Architecture:** Web 菜单继续由后端 /getRouters 根据 sys_menu 动态生成,不增加前端拍平或重排代码。新建库通过 MySQL/PostgreSQL 初始化 SQL 得到目标层级,已有 MySQL 库通过独立、幂等、fail-closed 的 20260727 增量补丁迁移;已执行的 20260724 历史补丁保持不变。
|
|
||||||
|
|
||||||
**Tech Stack:** MySQL 8、PostgreSQL、Python 3.10 unittest、Ruff、Vue 3、Vite 6、Node 20。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文件结构与保护边界
|
|
||||||
|
|
||||||
- 修改 shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py:区分初始化器目标状态与 20260724 补丁历史状态,并新增 20260727 补丁契约。
|
|
||||||
- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:新建 MySQL 数据库的六个一级菜单层级与排序。
|
|
||||||
- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql:新建 PostgreSQL 数据库的六个一级菜单层级与排序。
|
|
||||||
- 创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql:迁移现有 MySQL 数据库。
|
|
||||||
- 不修改 shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql。
|
|
||||||
- 不修改 Web 前端源码、APP、API、权限标识、角色菜单关联、业务子菜单或数据库表结构。
|
|
||||||
- 当前工作区已有 APP、后端/前端环境文件和已暂存 .idea/git_toolbox_prj.xml;每次暂存和提交都必须按路径执行,禁止 git add -A。
|
|
||||||
|
|
||||||
## Task 1:用契约固定初始化器的目标层级,并保留历史补丁语义
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
|
|
||||||
- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql
|
|
||||||
- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
|
|
||||||
- Preserve: shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql
|
|
||||||
|
|
||||||
- [ ] **Step 1:先修改契约测试,表达目标状态和历史状态**
|
|
||||||
|
|
||||||
在测试文件顶部保留旧补丁路径,并新增新补丁路径:
|
|
||||||
|
|
||||||
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
|
||||||
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
|
|
||||||
|
|
||||||
把 BUSINESS_MENU_SPECS 的前三行改为初始化器目标值,其余 61 行原样保留:
|
|
||||||
|
|
||||||
BUSINESS_MENU_SPECS = """
|
|
||||||
121|0|基础资料维护|businessMaster||1||<NULL>
|
|
||||||
122|0|业务管理|businessOperation||2||<NULL>
|
|
||||||
123|0|报表查询|businessReport||3||<NULL>
|
|
||||||
502|121|菌种资料|strain||1|master:strain:list|master/strain/index
|
|
||||||
|
|
||||||
在 EXPECTED_BUSINESS_MENU_FIELDS 和 BUSINESS_MENU_IDS 定义之后加入:
|
|
||||||
|
|
||||||
LEGACY_PATCH_BUSINESS_MENU_FIELDS = {
|
|
||||||
**EXPECTED_BUSINESS_MENU_FIELDS,
|
|
||||||
121: (1, '基础资料维护', 'businessMaster', '', 10, '', None),
|
|
||||||
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
|
|
||||||
123: (1, '报表查询', 'businessReport', '', 12, '', None),
|
|
||||||
}
|
|
||||||
EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
|
|
||||||
121: ('基础资料维护', 0, 1),
|
|
||||||
122: ('业务管理', 0, 2),
|
|
||||||
123: ('报表查询', 0, 3),
|
|
||||||
1: ('系统管理', 0, 4),
|
|
||||||
2: ('系统监控', 0, 5),
|
|
||||||
3: ('系统工具', 0, 6),
|
|
||||||
}
|
|
||||||
MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
|
|
||||||
|
|
||||||
不要把 1、2、3 加入 BUSINESS_MENU_IDS;该集合还被“新业务菜单不默认授权角色”的现有测试使用。
|
|
||||||
|
|
||||||
把菜单提取和字段断言改为以下实现:
|
|
||||||
|
|
||||||
def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]:
|
|
||||||
rows = _insert_rows(sql, 'sys_menu')
|
|
||||||
selected = [
|
|
||||||
row
|
|
||||||
for row in rows
|
|
||||||
if row and str(row[0]).isdigit() and int(row[0]) in menu_ids
|
|
||||||
]
|
|
||||||
counts = {
|
|
||||||
menu_id: sum(int(row[0]) == menu_id for row in selected)
|
|
||||||
for menu_id in menu_ids
|
|
||||||
}
|
|
||||||
if any(count != 1 for count in counts.values()):
|
|
||||||
raise AssertionError(f'menu row counts differ: {counts}')
|
|
||||||
return {int(row[0]): row for row in selected}
|
|
||||||
|
|
||||||
|
|
||||||
def _business_menu_rows(sql: str) -> dict[int, list]:
|
|
||||||
return _menu_rows(sql, BUSINESS_MENU_IDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_business_menu_fields(
|
|
||||||
test_case: unittest.TestCase,
|
|
||||||
sql: str,
|
|
||||||
expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS,
|
|
||||||
) -> None:
|
|
||||||
rows = _business_menu_rows(sql)
|
|
||||||
invalid_widths = {
|
|
||||||
menu_id: len(row)
|
|
||||||
for menu_id, row in rows.items()
|
|
||||||
if len(row) != SYS_MENU_VALUE_COUNT
|
|
||||||
}
|
|
||||||
test_case.assertEqual({}, invalid_widths)
|
|
||||||
actual = {
|
|
||||||
menu_id: (
|
|
||||||
int(row[2]),
|
|
||||||
row[1],
|
|
||||||
row[4],
|
|
||||||
row[7],
|
|
||||||
int(row[3]),
|
|
||||||
row[13],
|
|
||||||
row[5],
|
|
||||||
)
|
|
||||||
for menu_id, row in rows.items()
|
|
||||||
}
|
|
||||||
test_case.assertEqual(expected_fields, actual)
|
|
||||||
|
|
||||||
在 test_initializers_define_fixed_business_menus_and_permissions 中把三个根目录断言改成:
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
('基础资料维护', '0', '1', 'businessMaster', 'M', ''),
|
|
||||||
tuple(rows[121][index] for index in (1, 2, 3, 4, 10, 13)),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
('业务管理', '0', '2', 'businessOperation', 'M', ''),
|
|
||||||
tuple(rows[122][index] for index in (1, 2, 3, 4, 10, 13)),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
('报表查询', '0', '3', 'businessReport', 'M', ''),
|
|
||||||
tuple(rows[123][index] for index in (1, 2, 3, 4, 10, 13)),
|
|
||||||
)
|
|
||||||
|
|
||||||
紧接该测试新增六个一级目录的顺序契约:
|
|
||||||
|
|
||||||
def test_initializers_define_expected_top_level_menu_order(self) -> None:
|
|
||||||
for filename in INITIALIZERS:
|
|
||||||
with self.subTest(filename=filename):
|
|
||||||
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
|
||||||
rows = _menu_rows(sql, MENU_PROMOTION_IDS)
|
|
||||||
actual = {
|
|
||||||
menu_id: (row[1], int(row[2]), int(row[3]))
|
|
||||||
for menu_id, row in rows.items()
|
|
||||||
}
|
|
||||||
self.assertEqual(EXPECTED_TOP_LEVEL_MENU_POSITIONS, actual)
|
|
||||||
|
|
||||||
在 test_patch_is_documented_idempotent_and_contains_full_contract 中,将旧补丁字段断言改成:
|
|
||||||
|
|
||||||
_assert_business_menu_fields(
|
|
||||||
self,
|
|
||||||
raw,
|
|
||||||
LEGACY_PATCH_BUSINESS_MENU_FIELDS,
|
|
||||||
)
|
|
||||||
|
|
||||||
同一测试中,旧补丁与当前 MySQL 初始化器的比较只比较不含 parent_id、order_num 的稳定字段:
|
|
||||||
|
|
||||||
compared_indexes = (0, 1, *range(4, 15))
|
|
||||||
self.assertEqual(
|
|
||||||
{
|
|
||||||
menu_id: tuple(row[index] for index in compared_indexes)
|
|
||||||
for menu_id, row in mysql_rows.items()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
menu_id: tuple(row[index] for index in compared_indexes)
|
|
||||||
for menu_id, row in patch_rows.items()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
- [ ] **Step 2:运行初始化器契约,确认 RED**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend
|
|
||||||
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \
|
|
||||||
-v
|
|
||||||
|
|
||||||
预期:FAIL。MySQL/PostgreSQL 初始化器仍将 121/122/123 放在 parent_id=1、order_num=10/11/12,1/2/3 仍使用 order_num=1/2/3。
|
|
||||||
|
|
||||||
- [ ] **Step 3:对两份初始化 SQL 做六行最小修改**
|
|
||||||
|
|
||||||
MySQL 的六行目标内容:
|
|
||||||
|
|
||||||
insert into sys_menu values('1', '系统管理', '0', '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', sysdate(), '', null, '系统管理目录');
|
|
||||||
insert into sys_menu values('2', '系统监控', '0', '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', null, '系统监控目录');
|
|
||||||
insert into sys_menu values('3', '系统工具', '0', '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', null, '系统工具目录');
|
|
||||||
|
|
||||||
insert into sys_menu values('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '基础资料维护目录');
|
|
||||||
insert into sys_menu values('122', '业务管理', '0', '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '业务管理目录');
|
|
||||||
insert into sys_menu values('123', '报表查询', '0', '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '报表查询目录');
|
|
||||||
|
|
||||||
把 MySQL 注释“托盘业务目录”同步改为“托盘业务一级目录”。
|
|
||||||
|
|
||||||
PostgreSQL 的六行目标内容:
|
|
||||||
|
|
||||||
insert into sys_menu values(1, '系统管理', 0, '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', current_timestamp, '', null, '系统管理目录');
|
|
||||||
insert into sys_menu values(2, '系统监控', 0, '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', current_timestamp, '', null, '系统监控目录');
|
|
||||||
insert into sys_menu values(3, '系统工具', 0, '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', current_timestamp, '', null, '系统工具目录');
|
|
||||||
|
|
||||||
(121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
|
||||||
(122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
|
||||||
(123, '报表查询', 0, '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
|
||||||
|
|
||||||
把 PostgreSQL 注释“初始化-托盘业务菜单和权限”同步改为“初始化-托盘业务一级菜单和权限”。
|
|
||||||
|
|
||||||
- [ ] **Step 4:运行 GREEN 和历史补丁兼容回归**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend
|
|
||||||
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_mysql_and_postgresql_business_menu_fields_are_identical \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_patch_is_documented_idempotent_and_contains_full_contract \
|
|
||||||
-v
|
|
||||||
|
|
||||||
预期:4 项均通过;旧 20260724 补丁仍按历史层级通过契约,不发生文件改动。
|
|
||||||
|
|
||||||
- [ ] **Step 5:提交初始化器和对应契约**
|
|
||||||
|
|
||||||
从仓库根目录执行:
|
|
||||||
|
|
||||||
git add -- \
|
|
||||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
|
||||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \
|
|
||||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
|
|
||||||
git commit --only -m '调整:提升业务菜单为一级目录' -- \
|
|
||||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
|
||||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \
|
|
||||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
|
|
||||||
|
|
||||||
提交后确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区。
|
|
||||||
|
|
||||||
## Task 2:新增幂等、fail-closed 的 MySQL 前向补丁
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
|
|
||||||
- Create: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
|
||||||
|
|
||||||
- [ ] **Step 1:先写新补丁的失败契约**
|
|
||||||
|
|
||||||
在 SqlContractTest 中新增:
|
|
||||||
|
|
||||||
def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None:
|
|
||||||
self.assertTrue(
|
|
||||||
MENU_PROMOTION_PATCH_PATH.is_file(),
|
|
||||||
f'missing patch: {MENU_PROMOTION_PATCH_PATH}',
|
|
||||||
)
|
|
||||||
raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8')
|
|
||||||
self.assertIn('备份', raw)
|
|
||||||
self.assertIn('未自动执行', raw)
|
|
||||||
self.assertIn('可幂等重跑', raw)
|
|
||||||
self.assertIn('禁止使用 mysql --force', raw)
|
|
||||||
|
|
||||||
statements = _sql_statements(raw)
|
|
||||||
normalized = [_normalized_sql_statement(statement) for statement in statements]
|
|
||||||
expected_table = 'promote_business_menus_expected'
|
|
||||||
guard_table = 'promote_business_menus_guard'
|
|
||||||
|
|
||||||
expected_insert_indexes = [
|
|
||||||
index
|
|
||||||
for index, statement in enumerate(normalized)
|
|
||||||
if statement.startswith(f'insert into {expected_table} ')
|
|
||||||
]
|
|
||||||
self.assertEqual(1, len(expected_insert_indexes))
|
|
||||||
expected_rows = _insert_rows(
|
|
||||||
statements[expected_insert_indexes[0]],
|
|
||||||
expected_table,
|
|
||||||
)
|
|
||||||
self.assertEqual(6, len(expected_rows))
|
|
||||||
actual_expected_rows = {
|
|
||||||
int(row[0]): tuple(row[1:])
|
|
||||||
for row in expected_rows
|
|
||||||
}
|
|
||||||
self.assertEqual(
|
|
||||||
{
|
|
||||||
1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system', '0', '1', '0', '4'),
|
|
||||||
2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor', '0', '2', '0', '5'),
|
|
||||||
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool', '0', '3', '0', '6'),
|
|
||||||
121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '10', '0', '1'),
|
|
||||||
122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '11', '0', '2'),
|
|
||||||
123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '12', '0', '3'),
|
|
||||||
},
|
|
||||||
actual_expected_rows,
|
|
||||||
)
|
|
||||||
|
|
||||||
starts = [
|
|
||||||
index
|
|
||||||
for index, statement in enumerate(normalized)
|
|
||||||
if statement == 'start transaction'
|
|
||||||
]
|
|
||||||
commits = [
|
|
||||||
index
|
|
||||||
for index, statement in enumerate(normalized)
|
|
||||||
if statement == 'commit'
|
|
||||||
]
|
|
||||||
self.assertEqual(1, len(starts))
|
|
||||||
self.assertEqual(1, len(commits))
|
|
||||||
start_transaction = starts[0]
|
|
||||||
commit = commits[0]
|
|
||||||
|
|
||||||
row_locks = [
|
|
||||||
index
|
|
||||||
for index, statement in enumerate(normalized)
|
|
||||||
if re.fullmatch(
|
|
||||||
r'select menu_id from sys_menu '
|
|
||||||
r'where menu_id in \(1, 2, 3, 121, 122, 123\) '
|
|
||||||
r'order by menu_id for update',
|
|
||||||
statement,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
self.assertEqual(1, len(row_locks))
|
|
||||||
|
|
||||||
guard_indexes = [
|
|
||||||
index
|
|
||||||
for index, statement in enumerate(normalized)
|
|
||||||
if statement.startswith(f'insert into {guard_table} ')
|
|
||||||
]
|
|
||||||
self.assertEqual(2, len(guard_indexes))
|
|
||||||
precondition_guard = normalized[guard_indexes[0]]
|
|
||||||
postcondition_guard = normalized[guard_indexes[1]]
|
|
||||||
self.assertIn('as precondition_guard_rows', precondition_guard)
|
|
||||||
self.assertIn('as postcondition_guard_rows', postcondition_guard)
|
|
||||||
self.assertEqual(
|
|
||||||
2,
|
|
||||||
precondition_guard.count(
|
|
||||||
"'promote-business-menus-precondition-conflict'"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
2,
|
|
||||||
postcondition_guard.count(
|
|
||||||
"'promote-business-menus-postcondition-conflict'"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
identity_columns = (
|
|
||||||
('menu_name', 'menu_name'),
|
|
||||||
('path', 'path'),
|
|
||||||
('component', 'component'),
|
|
||||||
('query', 'query_value'),
|
|
||||||
('route_name', 'route_name'),
|
|
||||||
('is_frame', 'is_frame'),
|
|
||||||
('is_cache', 'is_cache'),
|
|
||||||
('menu_type', 'menu_type'),
|
|
||||||
('visible', 'visible'),
|
|
||||||
('status', 'status'),
|
|
||||||
('perms', 'perms'),
|
|
||||||
('icon', 'icon'),
|
|
||||||
)
|
|
||||||
for actual_column, expected_column in identity_columns:
|
|
||||||
comparison = (
|
|
||||||
f'actual.{actual_column} <=> expected.{expected_column}'
|
|
||||||
)
|
|
||||||
self.assertIn(comparison, precondition_guard)
|
|
||||||
self.assertIn(comparison, postcondition_guard)
|
|
||||||
|
|
||||||
compact_precondition = re.sub(r'\s+', '', precondition_guard)
|
|
||||||
self.assertIn('count(actual.menu_id)=6', compact_precondition)
|
|
||||||
self.assertIn(
|
|
||||||
'sum((actual.parent_id<=>expected.old_parent_id)'
|
|
||||||
'and(actual.order_num<=>expected.old_order_num))=6',
|
|
||||||
compact_precondition,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
'sum((actual.parent_id<=>expected.new_parent_id)'
|
|
||||||
'and(actual.order_num<=>expected.new_order_num))=6',
|
|
||||||
compact_precondition,
|
|
||||||
)
|
|
||||||
compact_postcondition = re.sub(r'\s+', '', postcondition_guard)
|
|
||||||
self.assertIn('count(actual.menu_id)=6', compact_postcondition)
|
|
||||||
self.assertIn(
|
|
||||||
'sum((actual.parent_id<=>expected.new_parent_id)'
|
|
||||||
'and(actual.order_num<=>expected.new_order_num))=6',
|
|
||||||
compact_postcondition,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_update = (
|
|
||||||
'update sys_menu set parent_id = case '
|
|
||||||
'when menu_id in (121, 122, 123) then 0 '
|
|
||||||
'else parent_id end, order_num = case menu_id '
|
|
||||||
'when 121 then 1 when 122 then 2 when 123 then 3 '
|
|
||||||
'when 1 then 4 when 2 then 5 when 3 then 6 '
|
|
||||||
'else order_num end '
|
|
||||||
'where menu_id in (1, 2, 3, 121, 122, 123)'
|
|
||||||
)
|
|
||||||
update_indexes = [
|
|
||||||
index
|
|
||||||
for index, statement in enumerate(normalized)
|
|
||||||
if statement == expected_update
|
|
||||||
]
|
|
||||||
self.assertEqual(1, len(update_indexes))
|
|
||||||
update_index = update_indexes[0]
|
|
||||||
|
|
||||||
permanent_mutations = [
|
|
||||||
statement
|
|
||||||
for statement in normalized
|
|
||||||
if re.match(
|
|
||||||
r'(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_',
|
|
||||||
statement,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
self.assertEqual([expected_update], permanent_mutations)
|
|
||||||
self.assertEqual(
|
|
||||||
(
|
|
||||||
start_transaction,
|
|
||||||
row_locks[0],
|
|
||||||
guard_indexes[0],
|
|
||||||
update_index,
|
|
||||||
guard_indexes[1],
|
|
||||||
commit,
|
|
||||||
),
|
|
||||||
tuple(
|
|
||||||
sorted(
|
|
||||||
(
|
|
||||||
start_transaction,
|
|
||||||
row_locks[0],
|
|
||||||
guard_indexes[0],
|
|
||||||
update_index,
|
|
||||||
guard_indexes[1],
|
|
||||||
commit,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_verification = (
|
|
||||||
'select menu_id, menu_name, parent_id, order_num, path, component, '
|
|
||||||
'menu_type, visible, status, perms from sys_menu '
|
|
||||||
'where menu_id in (1, 2, 3, 121, 122, 123) '
|
|
||||||
'order by order_num, menu_id'
|
|
||||||
)
|
|
||||||
self.assertEqual(expected_verification, normalized[-1])
|
|
||||||
|
|
||||||
- [ ] **Step 2:运行新补丁契约,确认 RED**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend
|
|
||||||
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \
|
|
||||||
-v
|
|
||||||
|
|
||||||
预期:FAIL,错误指出缺少 20260727_promote_business_menus_mysql.sql。
|
|
||||||
|
|
||||||
- [ ] **Step 3:创建最小 MySQL 前向补丁**
|
|
||||||
|
|
||||||
创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql,内容如下:
|
|
||||||
|
|
||||||
-- 将托盘业务目录 121/122/123 提升为一级菜单,并将系统菜单 1/2/3 后移。
|
|
||||||
-- 本补丁只更新 sys_menu.parent_id 和 sys_menu.order_num。
|
|
||||||
-- 本文件未自动执行;执行前必须备份目标数据库。
|
|
||||||
--
|
|
||||||
-- 必须在独立连接中使用标准 mysql batch 执行,并确保客户端遇错立即退出。
|
|
||||||
-- 禁止使用 mysql --force;guard 的 Duplicate entry 是故意设计的 fail-closed 错误,
|
|
||||||
-- 不能忽略错误继续执行到 commit。
|
|
||||||
--
|
|
||||||
-- 允许的写前状态只有两种:
|
|
||||||
-- 1. 20260724 旧状态;
|
|
||||||
-- 2. 本补丁目标状态,因此可幂等重跑。
|
|
||||||
-- 缺行、固定 ID 身份冲突或新旧状态混杂都会在永久更新前中止。
|
|
||||||
|
|
||||||
drop temporary table if exists promote_business_menus_expected;
|
|
||||||
create temporary table promote_business_menus_expected (
|
|
||||||
menu_id bigint not null,
|
|
||||||
menu_name varchar(50) not null,
|
|
||||||
path varchar(200),
|
|
||||||
component varchar(255),
|
|
||||||
query_value varchar(255),
|
|
||||||
route_name varchar(50),
|
|
||||||
is_frame int,
|
|
||||||
is_cache int,
|
|
||||||
menu_type char(1),
|
|
||||||
visible char(1),
|
|
||||||
status char(1),
|
|
||||||
perms varchar(100),
|
|
||||||
icon varchar(100),
|
|
||||||
old_parent_id bigint not null,
|
|
||||||
old_order_num int not null,
|
|
||||||
new_parent_id bigint not null,
|
|
||||||
new_order_num int not null,
|
|
||||||
primary key (menu_id)
|
|
||||||
) engine=innodb;
|
|
||||||
|
|
||||||
insert into promote_business_menus_expected (
|
|
||||||
menu_id, menu_name, path, component, query_value, route_name,
|
|
||||||
is_frame, is_cache, menu_type, visible, status, perms, icon,
|
|
||||||
old_parent_id, old_order_num, new_parent_id, new_order_num
|
|
||||||
) values
|
|
||||||
(1, '系统管理', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 0, 1, 0, 4),
|
|
||||||
(2, '系统监控', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 0, 2, 0, 5),
|
|
||||||
(3, '系统工具', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 0, 3, 0, 6),
|
|
||||||
(121, '基础资料维护', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 10, 0, 1),
|
|
||||||
(122, '业务管理', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 11, 0, 2),
|
|
||||||
(123, '报表查询', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 12, 0, 3);
|
|
||||||
|
|
||||||
drop temporary table if exists promote_business_menus_guard;
|
|
||||||
create temporary table promote_business_menus_guard (
|
|
||||||
guard_token varchar(64) not null,
|
|
||||||
primary key (guard_token)
|
|
||||||
) engine=innodb;
|
|
||||||
|
|
||||||
start transaction;
|
|
||||||
|
|
||||||
-- 锁定目标行,防止身份校验与更新之间被并发修改。
|
|
||||||
select menu_id
|
|
||||||
from sys_menu
|
|
||||||
where menu_id in (1, 2, 3, 121, 122, 123)
|
|
||||||
order by menu_id
|
|
||||||
for update;
|
|
||||||
|
|
||||||
-- 写前身份和整体状态校验。失败时重复 token 触发主键冲突并终止 batch。
|
|
||||||
insert into promote_business_menus_guard (guard_token)
|
|
||||||
select guard_token
|
|
||||||
from (
|
|
||||||
select 'promote-business-menus-precondition-conflict' as guard_token
|
|
||||||
union all
|
|
||||||
select if(
|
|
||||||
count(actual.menu_id) = 6
|
|
||||||
and sum(
|
|
||||||
(actual.menu_name <=> expected.menu_name)
|
|
||||||
and (actual.path <=> expected.path)
|
|
||||||
and (actual.component <=> expected.component)
|
|
||||||
and (actual.query <=> expected.query_value)
|
|
||||||
and (actual.route_name <=> expected.route_name)
|
|
||||||
and (actual.is_frame <=> expected.is_frame)
|
|
||||||
and (actual.is_cache <=> expected.is_cache)
|
|
||||||
and (actual.menu_type <=> expected.menu_type)
|
|
||||||
and (actual.visible <=> expected.visible)
|
|
||||||
and (actual.status <=> expected.status)
|
|
||||||
and (actual.perms <=> expected.perms)
|
|
||||||
and (actual.icon <=> expected.icon)
|
|
||||||
) = 6
|
|
||||||
and (
|
|
||||||
sum(
|
|
||||||
(actual.parent_id <=> expected.old_parent_id)
|
|
||||||
and (actual.order_num <=> expected.old_order_num)
|
|
||||||
) = 6
|
|
||||||
or sum(
|
|
||||||
(actual.parent_id <=> expected.new_parent_id)
|
|
||||||
and (actual.order_num <=> expected.new_order_num)
|
|
||||||
) = 6
|
|
||||||
),
|
|
||||||
'promote-business-menus-precondition-verified',
|
|
||||||
'promote-business-menus-precondition-conflict'
|
|
||||||
) as guard_token
|
|
||||||
from promote_business_menus_expected as expected
|
|
||||||
left join sys_menu as actual
|
|
||||||
on actual.menu_id = expected.menu_id
|
|
||||||
) as precondition_guard_rows;
|
|
||||||
|
|
||||||
-- 永久变更只涉及 parent_id 和 order_num。
|
|
||||||
update sys_menu
|
|
||||||
set parent_id = case
|
|
||||||
when menu_id in (121, 122, 123) then 0
|
|
||||||
else parent_id
|
|
||||||
end,
|
|
||||||
order_num = case menu_id
|
|
||||||
when 121 then 1
|
|
||||||
when 122 then 2
|
|
||||||
when 123 then 3
|
|
||||||
when 1 then 4
|
|
||||||
when 2 then 5
|
|
||||||
when 3 then 6
|
|
||||||
else order_num
|
|
||||||
end
|
|
||||||
where menu_id in (1, 2, 3, 121, 122, 123);
|
|
||||||
|
|
||||||
-- 提交前再次校验目标状态,捕获未覆盖行或触发器造成的意外变化。
|
|
||||||
insert into promote_business_menus_guard (guard_token)
|
|
||||||
select guard_token
|
|
||||||
from (
|
|
||||||
select 'promote-business-menus-postcondition-conflict' as guard_token
|
|
||||||
union all
|
|
||||||
select if(
|
|
||||||
count(actual.menu_id) = 6
|
|
||||||
and sum(
|
|
||||||
(actual.menu_name <=> expected.menu_name)
|
|
||||||
and (actual.path <=> expected.path)
|
|
||||||
and (actual.component <=> expected.component)
|
|
||||||
and (actual.query <=> expected.query_value)
|
|
||||||
and (actual.route_name <=> expected.route_name)
|
|
||||||
and (actual.is_frame <=> expected.is_frame)
|
|
||||||
and (actual.is_cache <=> expected.is_cache)
|
|
||||||
and (actual.menu_type <=> expected.menu_type)
|
|
||||||
and (actual.visible <=> expected.visible)
|
|
||||||
and (actual.status <=> expected.status)
|
|
||||||
and (actual.perms <=> expected.perms)
|
|
||||||
and (actual.icon <=> expected.icon)
|
|
||||||
) = 6
|
|
||||||
and sum(
|
|
||||||
(actual.parent_id <=> expected.new_parent_id)
|
|
||||||
and (actual.order_num <=> expected.new_order_num)
|
|
||||||
) = 6,
|
|
||||||
'promote-business-menus-postcondition-verified',
|
|
||||||
'promote-business-menus-postcondition-conflict'
|
|
||||||
) as guard_token
|
|
||||||
from promote_business_menus_expected as expected
|
|
||||||
left join sys_menu as actual
|
|
||||||
on actual.menu_id = expected.menu_id
|
|
||||||
) as postcondition_guard_rows;
|
|
||||||
|
|
||||||
commit;
|
|
||||||
|
|
||||||
drop temporary table promote_business_menus_guard;
|
|
||||||
drop temporary table promote_business_menus_expected;
|
|
||||||
|
|
||||||
-- 固定 ID 事后核对:顺序应为 121、122、123、1、2、3。
|
|
||||||
select menu_id, menu_name, parent_id, order_num, path, component,
|
|
||||||
menu_type, visible, status, perms
|
|
||||||
from sys_menu
|
|
||||||
where menu_id in (1, 2, 3, 121, 122, 123)
|
|
||||||
order by order_num, menu_id;
|
|
||||||
|
|
||||||
- [ ] **Step 4:运行新补丁契约与完整 SQL 契约**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend
|
|
||||||
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
|
||||||
tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \
|
|
||||||
-v
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
|
||||||
tests.module_business.test_sql_contract \
|
|
||||||
-v
|
|
||||||
conda run -n ShiErFeng python -m ruff format --check \
|
|
||||||
tests/module_business/test_sql_contract.py
|
|
||||||
conda run -n ShiErFeng python -m ruff check \
|
|
||||||
tests/module_business/test_sql_contract.py
|
|
||||||
|
|
||||||
预期:新补丁定向契约通过,完整 SQL 契约全部通过,Ruff format/check 均为成功退出。
|
|
||||||
|
|
||||||
- [ ] **Step 5:提交新补丁和对应契约**
|
|
||||||
|
|
||||||
从仓库根目录执行:
|
|
||||||
|
|
||||||
git add -- \
|
|
||||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
|
||||||
shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
|
||||||
git commit --only -m '迁移:新增业务菜单层级补丁' -- \
|
|
||||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
|
||||||
shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
|
||||||
|
|
||||||
提交后再次确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区,APP 和环境文件指纹未变化。
|
|
||||||
|
|
||||||
## Task 3:完成代码级回归验证
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Verify only: shierfeng-fastapi-backend
|
|
||||||
- Verify only: shierfeng-fastapi-frontend
|
|
||||||
|
|
||||||
- [ ] **Step 1:运行后端业务测试和 SQL 契约**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend
|
|
||||||
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest discover \
|
|
||||||
-s tests/module_business -p 'test_*.py' -v
|
|
||||||
|
|
||||||
预期:全部业务测试通过,不生成新的 Python 字节码。
|
|
||||||
|
|
||||||
- [ ] **Step 2:运行后端格式和静态检查**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend
|
|
||||||
|
|
||||||
conda run -n ShiErFeng python -m ruff format --check \
|
|
||||||
module_business tests/module_business
|
|
||||||
conda run -n ShiErFeng python -m ruff check \
|
|
||||||
module_business tests/module_business
|
|
||||||
|
|
||||||
预期:两条命令均成功退出,无格式或 lint 错误。
|
|
||||||
|
|
||||||
- [ ] **Step 3:运行 Web 契约与生产构建**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-frontend
|
|
||||||
|
|
||||||
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \
|
|
||||||
--test scripts/verify-business-contract.mjs
|
|
||||||
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \
|
|
||||||
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/lib/node_modules/npm/bin/npm-cli.js \
|
|
||||||
run build:prod
|
|
||||||
|
|
||||||
预期:Node 业务契约全部通过,Vite 生产构建成功。构建会读取用户已有的前端环境文件,但本任务不得修改或提交这些文件。
|
|
||||||
|
|
||||||
- [ ] **Step 4:检查 diff、提交边界和受保护改动**
|
|
||||||
|
|
||||||
从仓库根目录执行:
|
|
||||||
|
|
||||||
git diff --check
|
|
||||||
git status --short --branch
|
|
||||||
git diff --cached --name-status
|
|
||||||
|
|
||||||
预期:git diff --check 无输出;本任务代码已按路径提交;.idea/git_toolbox_prj.xml 仍保持用户原有暂存状态;APP、环境文件、缓存、日志和构建产物未被纳入提交。
|
|
||||||
|
|
||||||
## Task 4:经再次确认后迁移本地 MySQL 并验收菜单
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Read configuration: shierfeng-fastapi-backend/.env.dev
|
|
||||||
- Execute: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
|
||||||
- Backup output pattern: /private/tmp/shierfeng-fastapi-before-promote-business-menus-YYYYMMDD-HHMMSS.sql
|
|
||||||
|
|
||||||
- [ ] **Step 1:停止并取得数据库写入确认**
|
|
||||||
|
|
||||||
向用户列出:目标数据库来自 .env.dev、将创建新的全库备份、将执行的补丁文件、只更新 sys_menu 六行的 parent_id/order_num、禁止 mysql --force。没有本次明确确认时不得执行后续步骤。
|
|
||||||
|
|
||||||
- [ ] **Step 2:确认后创建新的数据库备份并执行补丁**
|
|
||||||
|
|
||||||
工作目录:shierfeng-fastapi-backend。以下命令不打印密码,且 set -e 保证备份或补丁遇错立即退出:
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
task_db_type=$(sed -nE "s/^[[:space:]]*DB_TYPE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
|
||||||
task_db_host=$(sed -nE "s/^[[:space:]]*DB_HOST[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
|
||||||
task_db_port=$(sed -nE "s/^[[:space:]]*DB_PORT[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p" .env.dev)
|
|
||||||
task_db_user=$(sed -nE "s/^[[:space:]]*DB_USERNAME[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
|
||||||
task_db_password=$(sed -nE "s/^[[:space:]]*DB_PASSWORD[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
|
||||||
task_db_name=$(sed -nE "s/^[[:space:]]*DB_DATABASE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
|
||||||
test "$task_db_type" = 'mysql'
|
|
||||||
test -n "$task_db_host"
|
|
||||||
test -n "$task_db_port"
|
|
||||||
test -n "$task_db_user"
|
|
||||||
test -n "$task_db_password"
|
|
||||||
test -n "$task_db_name"
|
|
||||||
task_backup_path="/private/tmp/shierfeng-fastapi-before-promote-business-menus-$(date +%Y%m%d-%H%M%S).sql"
|
|
||||||
MYSQL_PWD="$task_db_password" mysqldump \
|
|
||||||
--host="$task_db_host" \
|
|
||||||
--port="$task_db_port" \
|
|
||||||
--user="$task_db_user" \
|
|
||||||
--single-transaction \
|
|
||||||
--routines \
|
|
||||||
--triggers \
|
|
||||||
--events \
|
|
||||||
--no-tablespaces \
|
|
||||||
"$task_db_name" > "$task_backup_path"
|
|
||||||
test -s "$task_backup_path"
|
|
||||||
shasum -a 256 "$task_backup_path"
|
|
||||||
MYSQL_PWD="$task_db_password" mysql \
|
|
||||||
--host="$task_db_host" \
|
|
||||||
--port="$task_db_port" \
|
|
||||||
--user="$task_db_user" \
|
|
||||||
--database="$task_db_name" \
|
|
||||||
< sql/patches/20260727_promote_business_menus_mysql.sql
|
|
||||||
unset task_db_password
|
|
||||||
|
|
||||||
预期:生成新的非空备份并输出其 SHA-256;mysql 使用独立连接、未带 --force;补丁最后输出六个目标菜单,parent_id 均为 0,order_num 依次为 1、2、3、4、5、6。
|
|
||||||
|
|
||||||
- [ ] **Step 3:只读核对数据库菜单和不变量**
|
|
||||||
|
|
||||||
继续使用 .env.dev 解析出的连接参数,在新的 mysql 连接执行:
|
|
||||||
|
|
||||||
select menu_id, menu_name, parent_id, order_num, path, component,
|
|
||||||
menu_type, visible, status, perms
|
|
||||||
from sys_menu
|
|
||||||
where menu_id in (1, 2, 3, 121, 122, 123)
|
|
||||||
order by order_num, menu_id;
|
|
||||||
|
|
||||||
select parent_id, count(*) as child_count
|
|
||||||
from sys_menu
|
|
||||||
where parent_id in (121, 122, 123)
|
|
||||||
group by parent_id
|
|
||||||
order by parent_id;
|
|
||||||
|
|
||||||
select count(*) as business_permission_count
|
|
||||||
from sys_menu
|
|
||||||
where perms regexp '^(master|business|report):';
|
|
||||||
|
|
||||||
预期:六个根目录顺序为 121、122、123、1、2、3;121/122/123 的直属子菜单数为 6、5、6;业务权限菜单总数为 61。
|
|
||||||
|
|
||||||
- [ ] **Step 4:重新登录并核对 /getRouters 与 Web 侧边栏**
|
|
||||||
|
|
||||||
保持现有前后端启动方式不变,退出 Web 登录后重新登录,使现有登录缓存刷新。确认 /getRouters 返回的根节点依次包含“基础资料维护”“业务管理”“报表查询”“系统管理”“系统监控”“系统工具”,三个业务目录不再出现在“系统管理”的 children 中;侧边栏顺序与返回结果一致。不得执行 Redis FLUSHALL,也不修改 APP。
|
|
||||||
|
|
||||||
## 最终验收
|
|
||||||
|
|
||||||
- 新建 MySQL/PostgreSQL 数据库直接得到目标一级菜单层级。
|
|
||||||
- 已有 MySQL 数据库只能从完整旧状态或完整目标状态执行 20260727 补丁;缺行、ID 复用、身份漂移和新旧状态混杂均在永久更新前失败。
|
|
||||||
- 永久 DML 只更新六个 sys_menu 行的 parent_id/order_num,并在提交前复核目标状态。
|
|
||||||
- 20260724 历史补丁、子菜单、API、权限标识、角色关联、Web 动态路由机制与 APP 均保持不变。
|
|
||||||
- 后端业务测试、SQL 契约、Ruff、Web 业务契约和生产构建均通过。
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
# 智链云枢托盘业务系统设计
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
|
|
||||||
当前项目是基于 Vue 3、Element Plus、Pinia、Vite、FastAPI 和异步 SQLAlchemy 的通用管理后台。Web 菜单由后端 `/getRouters` 根据 `sys_menu`、`sys_role_menu` 动态生成,按钮权限由前端 `v-hasPermi` 控制,真实接口权限由后端 `UserInterfaceAuthDependency` 校验。
|
|
||||||
|
|
||||||
本次以用户提供的流程图为最终业务依据,完成以下目标:
|
|
||||||
|
|
||||||
- 完整移除“AI管理”专属功能。
|
|
||||||
- 删除“施尔丰仓库”外链菜单。
|
|
||||||
- 在“系统管理”下新增托盘基础资料、五类业务操作和六类报表。
|
|
||||||
- 保证菜单、页面、API、按钮权限、后端权限与数据范围一致。
|
|
||||||
- 建立可审计、可并发控制的托盘库存和追溯模型。
|
|
||||||
|
|
||||||
## 2. 已确认的范围
|
|
||||||
|
|
||||||
### 2.1 本次包含
|
|
||||||
|
|
||||||
- Web 前端页面、动态菜单、按钮权限、API 请求和类型约定。
|
|
||||||
- FastAPI 后端 Controller、Service、DAO、DO、VO、状态机和权限校验。
|
|
||||||
- MySQL、PostgreSQL 初始化脚本同步。
|
|
||||||
- 一份仅供审查、不自动执行的 MySQL 增量补丁。
|
|
||||||
- 直接相关的测试、README、CHANGELOG 和依赖声明。
|
|
||||||
- 适配 Android PDA 浏览器的 Web 操作页面。
|
|
||||||
|
|
||||||
### 2.2 本次明确排除
|
|
||||||
|
|
||||||
- 不修改 `shierfeng-fastapi-app` 下任何文件,也不兼容其现有接口逻辑。
|
|
||||||
- 不连接、不写入、不迁移本地 MySQL。
|
|
||||||
- 不生成或执行 PostgreSQL 增量补丁。
|
|
||||||
- 不删除现有数据库中的 AI 业务表、模型配置、聊天配置或 Agno 运行数据。
|
|
||||||
- 不新增在途、签收、拒收、冲销、库存调整、导入、导出或摄像头扫码功能。
|
|
||||||
- 不预置新的业务角色,不给现有普通角色自动分配新增权限。
|
|
||||||
- 不删除用户、角色、菜单、部门、岗位、字典、参数、通知、日志、监控或系统工具等通用功能。
|
|
||||||
|
|
||||||
## 3. 总体架构
|
|
||||||
|
|
||||||
### 3.1 后端
|
|
||||||
|
|
||||||
新增独立的 `module_business`,继续采用现有分层:
|
|
||||||
|
|
||||||
```text
|
|
||||||
module_business/
|
|
||||||
├── controller/
|
|
||||||
├── service/
|
|
||||||
├── dao/
|
|
||||||
├── entity/
|
|
||||||
│ ├── do/
|
|
||||||
│ └── vo/
|
|
||||||
└── domain/
|
|
||||||
```
|
|
||||||
|
|
||||||
模块内部职责划分:
|
|
||||||
|
|
||||||
- `master`:菌种、客户、客户现场、产线、仓库、托盘及用户授权。
|
|
||||||
- `operation`:首次入库、工厂装填、发货、现场使用、退回。
|
|
||||||
- `report`:六类只读报表和托盘追溯。
|
|
||||||
- `domain`:托盘状态枚举、状态迁移规则和跨业务公共校验。
|
|
||||||
|
|
||||||
Controller 只负责协议、参数、认证和权限依赖;Service 负责事务、状态机、数据范围和幂等;DAO 负责持久化查询;前端不得直接指定最终库存或最终状态。
|
|
||||||
|
|
||||||
### 3.2 Web
|
|
||||||
|
|
||||||
新增以下目录,沿用现有页面与请求风格:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/api/master/
|
|
||||||
src/api/business/
|
|
||||||
src/api/report/
|
|
||||||
src/views/master/
|
|
||||||
src/views/business/
|
|
||||||
src/views/report/
|
|
||||||
src/components/PalletScanInput/
|
|
||||||
```
|
|
||||||
|
|
||||||
PDA 页面属于 Web 页面,使用响应式布局。扫码设备按 HID 键盘输入处理:输入框自动聚焦,扫码头输入二维码后回车提交;不调用摄像头,不引入前端扫码依赖。
|
|
||||||
|
|
||||||
### 3.3 库存一致性
|
|
||||||
|
|
||||||
托盘是业务聚合根。每次状态变更必须在一个数据库事务内同时完成:
|
|
||||||
|
|
||||||
1. 校验接口权限和仓库/客户现场数据范围。
|
|
||||||
2. 锁定托盘当前库存行。
|
|
||||||
3. 校验当前状态、位置、数量和幂等键。
|
|
||||||
4. 写入或确认业务单据。
|
|
||||||
5. 更新托盘当前库存快照。
|
|
||||||
6. 追加不可变托盘事件。
|
|
||||||
|
|
||||||
任何一步失败都整体回滚,多托盘单据不允许部分成功。
|
|
||||||
|
|
||||||
## 4. 主数据模型
|
|
||||||
|
|
||||||
### 4.1 `biz_strain`
|
|
||||||
|
|
||||||
- 菌种是基础品种/SKU,不代表生产批次。
|
|
||||||
- `strain_code` 非空且唯一。
|
|
||||||
- 生产批次、包装规格和本次装填包数保存在装填记录中。
|
|
||||||
- 已被业务引用后只能停用,不能删除。
|
|
||||||
|
|
||||||
### 4.2 `biz_warehouse`
|
|
||||||
|
|
||||||
- 保存仓库编码、名称、地址和启停状态。
|
|
||||||
- `warehouse_code` 非空且唯一。
|
|
||||||
- 通过 `biz_user_warehouse` 与现有 `sys_user` 多对多关联。
|
|
||||||
|
|
||||||
### 4.3 客户、现场与产线
|
|
||||||
|
|
||||||
- `biz_customer`:客户主数据,客户编码唯一。
|
|
||||||
- `biz_customer_site`:归属于客户,现场编码在同一客户下唯一。
|
|
||||||
- `biz_production_line`:仅表示客户现场使用/消耗产线,产线编码在同一现场下唯一。
|
|
||||||
- `biz_user_site`:现有用户与客户现场的多对多关联。
|
|
||||||
- 本次不建立工厂内部装填产线。
|
|
||||||
|
|
||||||
### 4.4 `biz_pallet`
|
|
||||||
|
|
||||||
- 保存托盘编号、容量包数、资产启停状态和固定二维码标识。
|
|
||||||
- `pallet_code`、`qr_token` 非空且唯一,创建后不可修改。
|
|
||||||
- 托盘创建时同时建立 `biz_pallet_inventory`,初始流转状态为 `UNSTOCKED`。
|
|
||||||
- 二维码只编码不可推测的随机 `qr_token`,不包含菌种、批次、数量、客户或权限信息。
|
|
||||||
- 使用后端 `qrcode` 和现有 Pillow 生成可打印二维码;二维码损坏时补打原标识,不允许换码。
|
|
||||||
|
|
||||||
## 5. 业务与库存模型
|
|
||||||
|
|
||||||
### 5.1 单据与事实表
|
|
||||||
|
|
||||||
| 表 | 用途和核心约束 |
|
|
||||||
|---|---|
|
|
||||||
| `biz_inbound_order` | 首次入库单,状态仅为 `DRAFT`、`CONFIRMED`,单号唯一。 |
|
|
||||||
| `biz_inbound_item` | 入库单托盘明细,同一单据内托盘唯一。 |
|
|
||||||
| `biz_filling_record` | 单托盘即时装填;保存菌种、批次、包装规格、初始整数包数、仓库和操作人。 |
|
|
||||||
| `biz_shipment_order` | 发货单,状态仅为 `DRAFT`、`CONFIRMED`,单号唯一。 |
|
|
||||||
| `biz_shipment_item` | 多托盘整托发货明细;包数由确认时的当前余量快照生成,不允许手填拆分。 |
|
|
||||||
| `biz_usage_record` | 每次现场使用事实;保存产线、本次使用包数、使用后余量和幂等键。 |
|
|
||||||
| `biz_return_order` | 两阶段退回单,状态为 `REQUESTED`、`CONFIRMED`。 |
|
|
||||||
| `biz_return_item` | 退回托盘及发起时余量快照;同一退回单内托盘唯一。 |
|
|
||||||
| `biz_pallet_inventory` | 每托盘唯一一行当前状态、位置、余量、当前装填记录、当前绑定产线和版本号。 |
|
|
||||||
| `biz_pallet_event` | 只追加追溯流水;记录来源单据、前后状态、位置、数量、操作人、时间和幂等键。 |
|
|
||||||
|
|
||||||
### 5.2 数量约束
|
|
||||||
|
|
||||||
- 库存最小单位为“包”,所有包数均为整数。
|
|
||||||
- 装填量和使用量必须大于零。
|
|
||||||
- 剩余量必须满足 `0 <= remaining_packages <= pallet.capacity_packages`。
|
|
||||||
- 一个托盘的同一装填周期只能包含一个菌种和一个生产批次,禁止混装。
|
|
||||||
- 当前装填记录在余量归零后关闭;下一次装填形成新的独立记录,不覆盖历史。
|
|
||||||
|
|
||||||
### 5.3 位置约束
|
|
||||||
|
|
||||||
`biz_pallet_inventory` 在任意时刻只能表达一个有效位置:
|
|
||||||
|
|
||||||
- 位于仓库时只能有 `warehouse_id`。
|
|
||||||
- 位于客户现场时只能有 `site_id`。
|
|
||||||
- `UNSTOCKED` 时两者都为空。
|
|
||||||
- `current_line_id` 只能在客户现场第一次使用后设置,并在仓库确认退回时清空。
|
|
||||||
|
|
||||||
## 6. 托盘状态机
|
|
||||||
|
|
||||||
```text
|
|
||||||
UNSTOCKED
|
|
||||||
└─ 首次入库确认 → EMPTY_IN_WAREHOUSE
|
|
||||||
└─ 单托盘装填 → FILLED_IN_WAREHOUSE
|
|
||||||
└─ 发货确认 → AT_SITE_UNUSED
|
|
||||||
├─ 部分使用 → AT_SITE_PARTIAL
|
|
||||||
├─ 全部用完 → AT_SITE_EMPTY
|
|
||||||
└─ 发起退回 → RETURNING
|
|
||||||
AT_SITE_PARTIAL
|
|
||||||
├─ 继续部分使用 → AT_SITE_PARTIAL
|
|
||||||
├─ 全部用完 → AT_SITE_EMPTY
|
|
||||||
└─ 发起退回 → RETURNING
|
|
||||||
AT_SITE_EMPTY
|
|
||||||
└─ 发起退回 → RETURNING
|
|
||||||
RETURNING
|
|
||||||
└─ 仓库确认收货:
|
|
||||||
├─ remaining_packages > 0 → FILLED_IN_WAREHOUSE
|
|
||||||
└─ remaining_packages = 0 → EMPTY_IN_WAREHOUSE
|
|
||||||
```
|
|
||||||
|
|
||||||
补充规则:
|
|
||||||
|
|
||||||
- 发货确认后直接归属目标客户现场,不增加在途或签收状态。
|
|
||||||
- 客户现场第一次使用时选择产线,本次驻场周期内不可变更。
|
|
||||||
- 客户发起退回后冻结使用和再次发货,直到仓库确认。
|
|
||||||
- 仓库确认退回后解除产线绑定。
|
|
||||||
- 带料退回保留原菌种、批次和余量,可再次整托发货;再次到达现场后重新绑定产线。
|
|
||||||
- 空托盘必须退回仓库后才能再次装填。
|
|
||||||
|
|
||||||
## 7. 五类业务流程
|
|
||||||
|
|
||||||
### 7.1 仓库入库
|
|
||||||
|
|
||||||
- 只处理已建档空托盘的首次入库。
|
|
||||||
- 创建草稿,选择目标仓库,连续扫描 `UNSTOCKED` 托盘。
|
|
||||||
- 草稿可编辑或删除;确认后锁定全部托盘并整单转为 `EMPTY_IN_WAREHOUSE`。
|
|
||||||
- 流程图中连续出现的第二个“创建入库单”按重复节点处理。
|
|
||||||
|
|
||||||
### 7.2 工厂装填
|
|
||||||
|
|
||||||
- 扫描 `EMPTY_IN_WAREHOUSE` 托盘。
|
|
||||||
- 校验当前用户拥有该仓库数据权限。
|
|
||||||
- 选择菌种,填写生产批次、包装规格和正整数包数。
|
|
||||||
- 一次操作只处理一个托盘并即时确认。
|
|
||||||
- 成功后创建装填记录,状态转为 `FILLED_IN_WAREHOUSE`。
|
|
||||||
|
|
||||||
### 7.3 发货出库
|
|
||||||
|
|
||||||
- 创建草稿,选择来源仓库和目标客户现场。
|
|
||||||
- 一张发货单允许多个托盘,只允许整托发货。
|
|
||||||
- 当前用户必须同时拥有来源仓库和目标客户现场数据权限。
|
|
||||||
- 确认时按托盘 ID 排序加锁;全部成功后转为 `AT_SITE_UNUSED`。
|
|
||||||
- 不增加在途、签收或拒收流程。
|
|
||||||
|
|
||||||
### 7.4 客户现场使用
|
|
||||||
|
|
||||||
- 用户必须具备 `business:usage:add` 且关联当前客户现场。
|
|
||||||
- 扫码后后端返回托盘、菌种、批次、当前余量和允许操作。
|
|
||||||
- 第一次使用选择该现场下的产线,并锁定至本次退回完成。
|
|
||||||
- 使用包数必须为正整数且不得超过当前余量。
|
|
||||||
- 使用后余量大于零时转为 `AT_SITE_PARTIAL`,等于零时转为 `AT_SITE_EMPTY`。
|
|
||||||
- 使用请求必须携带幂等键,PDA 重复提交不得重复扣减。
|
|
||||||
|
|
||||||
### 7.5 退回仓库
|
|
||||||
|
|
||||||
- 客户现场用户扫描一个或多个同现场托盘,选择目标仓库并发起退回。
|
|
||||||
- 发起后保存余量快照,托盘转为 `RETURNING`,禁止继续使用。
|
|
||||||
- 仓库人员实际收货扫码确认,必须拥有目标仓库数据权限。
|
|
||||||
- 确认时重新校验状态和余量快照,整单成功或整单回滚。
|
|
||||||
- 有余料时转为 `FILLED_IN_WAREHOUSE`,无余料时转为 `EMPTY_IN_WAREHOUSE`。
|
|
||||||
|
|
||||||
## 8. 菜单、页面、API 与权限
|
|
||||||
|
|
||||||
### 8.1 菜单结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
系统管理
|
|
||||||
├─ 基础资料维护
|
|
||||||
│ ├─ 菌种资料
|
|
||||||
│ ├─ 客户资料
|
|
||||||
│ ├─ 客户现场
|
|
||||||
│ ├─ 产线资料
|
|
||||||
│ ├─ 仓库资料
|
|
||||||
│ └─ 托盘资料
|
|
||||||
├─ 业务管理
|
|
||||||
│ ├─ 仓库入库
|
|
||||||
│ ├─ 工厂装填
|
|
||||||
│ ├─ 发货出库
|
|
||||||
│ ├─ 客户现场使用
|
|
||||||
│ └─ 退回仓库
|
|
||||||
└─ 报表查询
|
|
||||||
├─ 托盘库存
|
|
||||||
├─ 装填记录
|
|
||||||
├─ 发货出库
|
|
||||||
├─ 产线消耗
|
|
||||||
├─ 退回仓库
|
|
||||||
└─ 托盘追溯
|
|
||||||
```
|
|
||||||
|
|
||||||
三个目录节点不设置权限字符串。页面菜单使用 `*:list`,按钮节点使用具体操作权限。
|
|
||||||
|
|
||||||
### 8.2 权限映射
|
|
||||||
|
|
||||||
| 页面 | 资源前缀 | 动作 |
|
|
||||||
|---|---|---|
|
|
||||||
| 菌种资料 | `master:strain` | `list/query/add/edit/remove` |
|
|
||||||
| 客户资料 | `master:customer` | `list/query/add/edit/remove` |
|
|
||||||
| 客户现场 | `master:site` | `list/query/add/edit/remove/assign` |
|
|
||||||
| 产线资料 | `master:line` | `list/query/add/edit/remove` |
|
|
||||||
| 仓库资料 | `master:warehouse` | `list/query/add/edit/remove/assign` |
|
|
||||||
| 托盘资料 | `master:pallet` | `list/query/add/edit/remove/print` |
|
|
||||||
| 仓库入库 | `business:inbound` | `list/query/add/edit/remove/confirm` |
|
|
||||||
| 工厂装填 | `business:filling` | `list/query/add` |
|
|
||||||
| 发货出库 | `business:shipment` | `list/query/add/edit/remove/confirm` |
|
|
||||||
| 客户现场使用 | `business:usage` | `list/query/add` |
|
|
||||||
| 退回仓库 | `business:return` | `list/query/request/confirm` |
|
|
||||||
| 托盘库存 | `report:palletinventory` | `list` |
|
|
||||||
| 装填记录 | `report:filling` | `list` |
|
|
||||||
| 发货出库 | `report:shipment` | `list` |
|
|
||||||
| 产线消耗 | `report:consumption` | `list` |
|
|
||||||
| 退回仓库 | `report:return` | `list` |
|
|
||||||
| 托盘追溯 | `report:pallettrace` | `list` |
|
|
||||||
|
|
||||||
每个权限字符串必须同时出现在:
|
|
||||||
|
|
||||||
1. `sys_menu.perms`。
|
|
||||||
2. Web 的 `v-hasPermi` 或页面权限判断。
|
|
||||||
3. 后端 `UserInterfaceAuthDependency`。
|
|
||||||
4. 对应权限测试。
|
|
||||||
|
|
||||||
未授权用户绕过 Web 直接调用接口时返回现有规范的 403。拥有功能权限但没有目标仓库或客户现场关联时,同样拒绝访问。
|
|
||||||
|
|
||||||
### 8.3 API 前缀
|
|
||||||
|
|
||||||
- 主数据:`/master/strain`、`/master/customer`、`/master/site`、`/master/line`、`/master/warehouse`、`/master/pallet`。
|
|
||||||
- 业务:`/business/inbound`、`/business/filling`、`/business/shipment`、`/business/usage`、`/business/return`。
|
|
||||||
- 报表:`/report/pallet-inventory`、`/report/filling`、`/report/shipment`、`/report/consumption`、`/report/return`、`/report/pallet-trace`。
|
|
||||||
|
|
||||||
各业务 Controller 提供自己的扫码查询接口,避免现场操作员被迫获得托盘主数据管理权限。所有写接口由后端重新查询当前状态,不信任前端提交的库存、位置或状态。
|
|
||||||
|
|
||||||
### 8.4 角色
|
|
||||||
|
|
||||||
- 不新增固定业务角色。
|
|
||||||
- 不写入新增菜单对应的普通角色种子。
|
|
||||||
- 超级管理员沿用现有特例获得全部菜单和数据。
|
|
||||||
- 其他用户通过现有角色管理分配菜单和按钮,并通过仓库/客户现场页面分配数据范围。
|
|
||||||
- 角色授权必须包含“系统管理 → 分组目录 → 页面 → 按钮”的完整祖先链。
|
|
||||||
|
|
||||||
## 9. Web 页面行为
|
|
||||||
|
|
||||||
### 9.1 基础资料
|
|
||||||
|
|
||||||
- 沿用现有搜索、分页、表格和弹窗表单风格。
|
|
||||||
- 仓库和客户现场页面提供“分配用户”。
|
|
||||||
- 托盘页面提供二维码生成、查看、打印和补打。
|
|
||||||
- 被引用数据只允许停用;未引用数据才显示删除操作。
|
|
||||||
|
|
||||||
### 9.2 PDA Web
|
|
||||||
|
|
||||||
- 页面使用大尺寸输入区和操作按钮,适配 Android PDA 浏览器。
|
|
||||||
- 扫码输入框在成功、失败和页面恢复后重新聚焦。
|
|
||||||
- 回车触发扫码查询,重复扫码给出明确提示。
|
|
||||||
- 使用 `crypto.randomUUID()` 生成请求幂等键,不新增前端 UUID 依赖。
|
|
||||||
- 前端展示后端返回的状态和允许动作,不自行推算业务状态。
|
|
||||||
|
|
||||||
### 9.3 报表
|
|
||||||
|
|
||||||
六个独立页面只提供查询、筛选和分页:
|
|
||||||
|
|
||||||
- 托盘库存:当前状态、位置、菌种、批次、初始包数和剩余包数。
|
|
||||||
- 装填记录:装填时间、仓库、托盘、菌种、批次、规格和包数。
|
|
||||||
- 发货出库:发货单、客户现场、托盘和发货时余量。
|
|
||||||
- 产线消耗:客户、现场、产线、托盘、菌种、批次和使用包数。
|
|
||||||
- 退回仓库:退回申请、目标仓库、托盘、余量和确认时间。
|
|
||||||
- 托盘追溯:按事件时间展示托盘完整状态、位置和数量变化。
|
|
||||||
|
|
||||||
筛选条件从日期、仓库、客户、现场、产线、菌种、批次、托盘编号和状态中按页面需要选取。本次不提供 Excel 导出。
|
|
||||||
|
|
||||||
## 10. AI 管理与外链菜单清理
|
|
||||||
|
|
||||||
### 10.1 删除范围
|
|
||||||
|
|
||||||
- 删除后端 `module_ai` 全部源码。
|
|
||||||
- 删除 `utils/ai_util.py` 和仅被 AI 使用的 `utils/crypto_util.py`。
|
|
||||||
- 删除 Web `src/api/ai`、`src/views/ai` 和三个 AI 图标。
|
|
||||||
- 删除 13 个 `/ai/*` 接口及 `ai:model:*`、`ai:chat:*` 权限。
|
|
||||||
- 删除 AI 专属缓存命名空间、限流常量和 LiteLLM 日志配置。
|
|
||||||
- 从后端 requirements 删除 Agno 和模型提供商 SDK。
|
|
||||||
- 从前端 `package.json` 删除仅供 AI 使用的 Markdown、Monaco、UUID 等依赖,并删除 Monaco Vite 配置。
|
|
||||||
- README 删除当前 AI 功能说明;CHANGELOG 保留历史新增记录并增加移除记录。
|
|
||||||
|
|
||||||
### 10.2 保留范围
|
|
||||||
|
|
||||||
- 认证、JWT、接口权限、数据范围、自动路由扫描、缓存、日志、限流和通用上传。
|
|
||||||
- `utils/transport_crypto_util.py`。
|
|
||||||
- `@vueuse/core`、Pillow、PyJWT、cryptography 等共享依赖。
|
|
||||||
- 通用 API Key 日志脱敏测试。
|
|
||||||
- 导航栏独立“源码地址”和“文档地址”入口。
|
|
||||||
|
|
||||||
### 10.3 数据库残留策略
|
|
||||||
|
|
||||||
- 两套初始化脚本删除 AI 菜单、角色关联、AI 字典和 AI 表 DDL,确保新环境不再创建 AI 功能。
|
|
||||||
- MySQL 增量补丁删除 AI 菜单元数据、AI 字典元数据和“施尔丰仓库”菜单,但不执行。
|
|
||||||
- 增量补丁不得包含 `DROP TABLE ai_models`、`DROP TABLE ai_chat_config` 或任何 Agno 表删除语句。
|
|
||||||
- 现有 AI 表、行数、敏感配置和备份需求在最终交付中单独列出,后续获得明确确认后再清理。
|
|
||||||
|
|
||||||
## 11. SQL 交付策略
|
|
||||||
|
|
||||||
### 11.1 初始化脚本
|
|
||||||
|
|
||||||
MySQL 和 PostgreSQL 初始化脚本保持语义一致:
|
|
||||||
|
|
||||||
- 删除 AI 和外链菜单种子。
|
|
||||||
- 删除 AI 字典及 AI 表定义。
|
|
||||||
- 新增业务表、唯一约束、检查约束和索引。
|
|
||||||
- 新增三个目录、十一个业务/主数据页面、六个报表页面及按钮权限。
|
|
||||||
- 不重新编号现有无关菜单、字典或序列。
|
|
||||||
- 不给普通角色增加 `sys_role_menu` 记录。
|
|
||||||
|
|
||||||
### 11.2 MySQL 增量补丁
|
|
||||||
|
|
||||||
- 文件名使用日期和业务含义,作为一次性、可审查补丁。
|
|
||||||
- DDL 使用明确表名;菜单删除使用已审计的明确 ID,禁止通配删除。
|
|
||||||
- DDL 与菜单 DML 分节书写,明确 MySQL DDL 隐式提交边界。
|
|
||||||
- 包含业务表创建、菜单与权限插入、AI 元数据和菜单 ID 99 清理。
|
|
||||||
- 不连接、不执行本地 MySQL。
|
|
||||||
- 补丁应用后的 Redis 菜单、用户信息和 AI 字典缓存清理作为部署步骤记录,不在本次自动执行。
|
|
||||||
|
|
||||||
## 12. 错误、并发与审计
|
|
||||||
|
|
||||||
- 认证失败、功能越权和数据范围越权沿用项目现有异常与 403 处理。
|
|
||||||
- 非法数量、资源不存在、托盘状态冲突或单据状态冲突必须返回失败,不得伪成功。
|
|
||||||
- 所有确认、使用和退回请求使用唯一幂等键。
|
|
||||||
- 状态变更使用数据库行锁;多托盘按托盘 ID 排序加锁以降低死锁概率。
|
|
||||||
- `biz_pallet_inventory.version` 用于并发版本校验。
|
|
||||||
- `biz_pallet_event` 只追加、不修改、不删除;`sys_oper_log` 只作为补充审计,不能替代业务追溯。
|
|
||||||
- 已确认单据不可编辑或删除,本次不实现冲销或补偿事件入口。
|
|
||||||
|
|
||||||
## 13. 验证与验收
|
|
||||||
|
|
||||||
### 13.1 后端
|
|
||||||
|
|
||||||
- 运行 `ruff` 和相关 `pytest`。
|
|
||||||
- 验证应用可以导入,OpenAPI 中不存在 `/ai/*`。
|
|
||||||
- 覆盖状态机合法/非法迁移、整数数量边界、超用、幂等、数据范围和 403。
|
|
||||||
- 覆盖入库、装填、发货、使用、退回的事务回滚行为。
|
|
||||||
- 验证二维码生成接口输出有效图形。
|
|
||||||
|
|
||||||
### 13.2 Web
|
|
||||||
|
|
||||||
- 运行项目可用的 lint/check 命令和生产构建。
|
|
||||||
- 检查所有按钮权限和请求 URL 与后端一致。
|
|
||||||
- 检查 PDA 扫码输入、自动聚焦、重复提交和错误展示。
|
|
||||||
- 确认 `shierfeng-fastapi-app` 没有本次任务产生的改动。
|
|
||||||
|
|
||||||
### 13.3 静态和 SQL
|
|
||||||
|
|
||||||
- 使用 `rg` 检查 AI 页面、接口、权限、依赖和配置残留。
|
|
||||||
- 对比 MySQL、PostgreSQL 初始化脚本中的业务表和权限集合。
|
|
||||||
- 检查 MySQL 增量补丁不包含 AI 业务表删除。
|
|
||||||
- 检查菜单组件路径均对应真实 Web 页面。
|
|
||||||
|
|
||||||
### 13.4 本轮无法完成的运行态验收
|
|
||||||
|
|
||||||
由于用户选择只生成而不执行 MySQL 补丁,本轮不能真实验证:
|
|
||||||
|
|
||||||
- 业务表在 MySQL 中的实际建表结果。
|
|
||||||
- 运行态 `/getRouters` 是否返回新增菜单并移除旧菜单。
|
|
||||||
- 不同真实角色和仓库/现场授权数据下的完整端到端流程。
|
|
||||||
- 补丁执行后的 Redis 缓存失效行为。
|
|
||||||
|
|
||||||
这些项目必须在用户后续审查并应用补丁后执行,交付时不得表述为已通过。
|
|
||||||
|
|
||||||
## 14. 实施顺序
|
|
||||||
|
|
||||||
1. 保护现有未提交改动并建立验证基线。
|
|
||||||
2. 删除 AI 专属代码、配置、依赖和外链菜单种子。
|
|
||||||
3. 实现主数据、用户授权、二维码和状态机基础。
|
|
||||||
4. 实现首次入库、装填、发货、现场使用和两阶段退回。
|
|
||||||
5. 实现六类只读报表和托盘追溯。
|
|
||||||
6. 同步两套初始化脚本并生成不执行的 MySQL 增量补丁。
|
|
||||||
7. 执行后端、Web、权限、静态和 SQL 验证。
|
|
||||||
8. 交付修改清单、验证证据、AI 数据库残留和补丁应用后验收项。
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 业务菜单提升为一级菜单设计
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
将“基础资料维护”、“业务管理”、“报表查询”从“系统管理”下提升为独立一级菜单,使业务入口与系统管理入口在侧边栏中并列展示。
|
|
||||||
|
|
||||||
## 目标层级与顺序
|
|
||||||
|
|
||||||
一级菜单按以下顺序展示:
|
|
||||||
|
|
||||||
1. 基础资料维护(`menu_id=121`)
|
|
||||||
2. 业务管理(`menu_id=122`)
|
|
||||||
3. 报表查询(`menu_id=123`)
|
|
||||||
4. 系统管理(`menu_id=1`)
|
|
||||||
5. 系统监控(`menu_id=2`)
|
|
||||||
6. 系统工具(`menu_id=3`)
|
|
||||||
|
|
||||||
对应的持久化字段为:
|
|
||||||
|
|
||||||
| 菜单 ID | 菜单名称 | `parent_id` | `order_num` |
|
|
||||||
|---|---|---:|---:|
|
|
||||||
| 121 | 基础资料维护 | 0 | 1 |
|
|
||||||
| 122 | 业务管理 | 0 | 2 |
|
|
||||||
| 123 | 报表查询 | 0 | 3 |
|
|
||||||
| 1 | 系统管理 | 0 | 4 |
|
|
||||||
| 2 | 系统监控 | 0 | 5 |
|
|
||||||
| 3 | 系统工具 | 0 | 6 |
|
|
||||||
|
|
||||||
## 架构与改动边界
|
|
||||||
|
|
||||||
项目的 Web 路由和侧边栏由后端 `/getRouters` 根据 `sys_menu` 动态生成,因此应修改菜单数据,不在前端增加拍平或重排逻辑。
|
|
||||||
|
|
||||||
本次仅调整六个目录菜单的层级和排序:
|
|
||||||
|
|
||||||
- 保持业务子菜单、组件路径、API 和权限标识不变。
|
|
||||||
- 保持角色菜单关联不变。
|
|
||||||
- 不修改 APP 端。
|
|
||||||
- 不新增依赖,不改动现有动态路由机制。
|
|
||||||
|
|
||||||
## SQL 与迁移设计
|
|
||||||
|
|
||||||
1. 同步修改 MySQL 和 PostgreSQL 初始化 SQL,使新建数据库直接得到目标层级。
|
|
||||||
2. 保留已执行的 `20260724_pallet_workflow_mysql.sql` 历史补丁,不改写已落库迁移的语义。
|
|
||||||
3. 新增一个 MySQL 增量补丁,同时支持“调整前”和“已调整”两种合法状态,保证可幂等重跑。
|
|
||||||
4. 增量补丁在第一条永久 DML 前校验六个固定 ID 的菜单身份和允许的层级状态。任一 ID 缺失、被复用或稳定字段异常时,在写入前中止。
|
|
||||||
5. 六条更新置于显式事务中,只修改 `parent_id` 和 `order_num`;执行器不得使用 `--force` 或 continue-on-error 模式。
|
|
||||||
6. 补丁末尾输出六个目录的实际层级和排序,用于人工核对。
|
|
||||||
|
|
||||||
## 缓存与运行态
|
|
||||||
|
|
||||||
数据库更新后,不使用 Redis `FLUSHALL`。本地验证通过重新登录触发现有登录缓存失效逻辑,并使用独立查询参数核对 `/getRouters` 的新层级。
|
|
||||||
|
|
||||||
## 测试与验收
|
|
||||||
|
|
||||||
- 先增加失败契约,要求 MySQL/PostgreSQL 初始化 SQL 定义上述六个一级菜单顺序。
|
|
||||||
- 增加增量补丁契约,覆盖写前校验、幂等性、显式事务、精确更新范围和事后查询。
|
|
||||||
- 运行后端 SQL 契约、业务测试、Ruff 检查、前端业务契约和生产构建。
|
|
||||||
- 数据库执行前重新创建备份,并只有在用户再次明确确认后才执行增量补丁。
|
|
||||||
- 数据库验收必须确认六个目录均为 `parent_id=0`,排序为 `1..6`,业务子菜单数量、页面组件和权限标识不变。
|
|
||||||
|
|
||||||
## 不在本次范围内
|
|
||||||
|
|
||||||
- 业务页面的功能、样式或交互调整。
|
|
||||||
- 普通角色的新授权或仓库/现场数据映射。
|
|
||||||
- 历史 AI 数据库表或其他数据清理。
|
|
||||||
- Redis 全库清理。
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version" : "1.0",
|
||||||
|
"configurations" : [
|
||||||
|
{
|
||||||
|
"customPlaygroundType" : "device",
|
||||||
|
"packageName" : "",
|
||||||
|
"playground" : "standard",
|
||||||
|
"type" : "uni-app:app-android"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -23,6 +23,18 @@
|
|||||||
>
|
>
|
||||||
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
|
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
|
||||||
|
|
||||||
|
### 配置 PDA 后端地址
|
||||||
|
|
||||||
|
PDA 默认请求本机 `http://localhost:9099`,仅适用于后端运行在同一设备的本地开发。部署到独立 PDA 或局域网服务器时,在构建环境注入 `VITE_APP_BASE_API`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把包含真实凭据的环境文件或地址配置提交到仓库。
|
||||||
|
|
||||||
|
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
|
||||||
|
|
||||||
### vscode
|
### vscode
|
||||||
|
|
||||||
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
|
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.ico" />
|
||||||
<script>
|
<script>
|
||||||
var coverSupport =
|
var coverSupport =
|
||||||
'CSS' in window &&
|
'CSS' in window &&
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"packageManager": "pnpm@10.28.1",
|
"packageManager": "pnpm@10.28.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:business": "node --test tests/*.test.mjs",
|
||||||
"dev": "uni -p mp-weixin",
|
"dev": "uni -p mp-weixin",
|
||||||
"build": "npm run build:mp-weixin",
|
"build": "npm run build:mp-weixin",
|
||||||
"dev:app": "uni -p app",
|
"dev:app": "uni -p app",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listScanExceptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/dashboard/scan-exceptions",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listFilling(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFillingStrainOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/strain-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanFillingPallet(qrToken) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/scan/" + encodeURIComponent(qrToken),
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilling(fillingId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/" + fillingId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFilling(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listInbound(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInboundWarehouseOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/warehouse-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanInboundPallet(qrToken, query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/scan/" + encodeURIComponent(qrToken),
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInbound(inboundId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/" + inboundId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInbound(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInbound(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmInbound(inboundId, data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/" + inboundId + "/confirm",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
headers: { repeatSubmit: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listReturn(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReturnWarehouseOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/warehouse-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanReturnPallet(qrToken, query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/scan/" + encodeURIComponent(qrToken),
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestReturn(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/request",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReturn(returnId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/" + returnId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmReturn(returnId, data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/" + returnId + "/confirm",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listShipment(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipmentWarehouseOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/warehouse-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipmentSiteOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/site-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanShipmentPallet(qrToken, query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/scan/" + encodeURIComponent(qrToken),
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipment(shipmentId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/" + shipmentId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addShipment(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateShipment(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmShipment(shipmentId, data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/" + shipmentId + "/confirm",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
|
||||||
|
export function listShipmentReceipts(query) {
|
||||||
|
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipmentReceipt(shipmentId) {
|
||||||
|
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanShipmentReceipt(shipmentId, identifier) {
|
||||||
|
return request({
|
||||||
|
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmShipmentReceipt(shipmentId, data) {
|
||||||
|
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listUsage(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsageLineOptions(siteId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/line-options",
|
||||||
|
method: "get",
|
||||||
|
params: { siteId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsageTunnelOptions(siteId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/tunnel-options",
|
||||||
|
method: "get",
|
||||||
|
params: { siteId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enterUsageTunnel(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/enter-tunnel",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanUsagePallet(qrToken) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/scan/" + encodeURIComponent(qrToken),
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsageFifoRecommendation(qrToken, tunnelId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage-fifo/recommendation",
|
||||||
|
method: "get",
|
||||||
|
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsage(usageId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/" + usageId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUsage(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listPalletInventory(query) {
|
||||||
|
return request({
|
||||||
|
url: "/report/pallet-inventory/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listShipmentReport(query) {
|
||||||
|
return request({
|
||||||
|
url: "/report/shipment/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
// PDA 只读取当前登录用户被授权且仍处于 ACTIVE 状态的批次。
|
||||||
|
export function listAvailableTestBatches() {
|
||||||
|
return request({
|
||||||
|
url: "/system/test-batch/options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<view class="w-full">
|
||||||
|
<view class="flex items-center rounded-xl bg-white p-2 shadow-sm">
|
||||||
|
<input
|
||||||
|
v-model="scanValue"
|
||||||
|
class="h-11 min-w-0 flex-1 px-3 text-sm text-gray-700"
|
||||||
|
type="text"
|
||||||
|
confirm-type="done"
|
||||||
|
maxlength="128"
|
||||||
|
:focus="focusRequested"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
placeholder="扫描二维码或输入托盘号后按回车"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@input="clearError"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="ml-2 h-10 rounded-lg bg-blue-500 px-3 text-xs text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ loading ? "校验中" : "提交扫描" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="errorMessage" class="mt-2 block text-xs text-red-500">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, ref, watch } from "vue";
|
||||||
|
import { isDuplicateScan, normalizeScanValue } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
duplicateWindowMs: {
|
||||||
|
type: Number,
|
||||||
|
default: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["scan", "invalid", "duplicate"]);
|
||||||
|
const scanValue = ref("");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const focusRequested = ref(false);
|
||||||
|
let lastScanValue = "";
|
||||||
|
let lastScanAt = 0;
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
if (props.disabled || props.loading) return;
|
||||||
|
focusRequested.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
if (!props.disabled && !props.loading) {
|
||||||
|
focusRequested.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (props.disabled || props.loading) return;
|
||||||
|
|
||||||
|
let normalizedValue;
|
||||||
|
try {
|
||||||
|
normalizedValue = normalizeScanValue(scanValue.value);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message;
|
||||||
|
emit("invalid", error.message);
|
||||||
|
focusInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scannedAt = Date.now();
|
||||||
|
if (
|
||||||
|
isDuplicateScan(
|
||||||
|
normalizedValue,
|
||||||
|
lastScanValue,
|
||||||
|
scannedAt,
|
||||||
|
lastScanAt,
|
||||||
|
props.duplicateWindowMs,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
scanValue.value = "";
|
||||||
|
emit("duplicate", normalizedValue);
|
||||||
|
focusInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScanValue = normalizedValue;
|
||||||
|
lastScanAt = scannedAt;
|
||||||
|
scanValue.value = "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
emit("scan", normalizedValue);
|
||||||
|
focusInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(focusInput);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.disabled, props.loading],
|
||||||
|
([disabled, loading]) => {
|
||||||
|
if (!disabled && !loading) focusInput();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="canReceive" class="space-y-4">
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<text class="mb-3 block font-bold text-gray-800">收货单</text>
|
||||||
|
<picker :range="receiptStateOptions" range-key="label" :value="receiptStateIndex" @change="changeReceiptState">
|
||||||
|
<view class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ receiptStateOptions[receiptStateIndex].label }}</view>
|
||||||
|
</picker>
|
||||||
|
<input v-model="queryParams.shipmentNo" class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm" maxlength="64" placeholder="输入原发货单号" confirm-type="search" @confirm="handleQuery" />
|
||||||
|
<button size="mini" class="rounded-lg bg-blue-50 text-blue-600" :disabled="loading" @click="handleQuery">查询收货</button>
|
||||||
|
<text v-if="listError" class="mt-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<text v-if="loading" class="mt-3 block text-xs text-gray-400">加载中...</text>
|
||||||
|
<text v-else-if="!receiptList.length" class="mt-3 block text-xs text-gray-400">暂无收货单</text>
|
||||||
|
<view v-for="row in receiptList" :key="row.shipmentId" class="mt-3 rounded-xl border border-gray-100 p-3">
|
||||||
|
<view class="flex items-center justify-between gap-2">
|
||||||
|
<text class="text-sm font-medium text-gray-700">{{ row.shipmentNo }}</text>
|
||||||
|
<text class="text-xs text-orange-600">{{ receiptStateLabel(row.receiptState) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-2 block text-xs text-gray-500">仓库 #{{ row.sourceWarehouseId }} → 现场 #{{ row.targetSiteId }}</text>
|
||||||
|
<text v-if="row.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ row.receivedAt || '未登记' }}</text>
|
||||||
|
<button size="mini" class="mt-3 rounded-lg bg-orange-50 text-orange-600" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</button>
|
||||||
|
</view>
|
||||||
|
<view class="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<button size="mini" :disabled="loading || queryParams.pageNum <= 1" @click="changePage(-1)">上一页</button>
|
||||||
|
<text class="text-xs text-gray-500">第 {{ queryParams.pageNum }} 页 · 共 {{ total }} 单</text>
|
||||||
|
<button size="mini" :disabled="loading || queryParams.pageNum * queryParams.pageSize >= total" @click="changePage(1)">下一页</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="activeShipmentId !== null" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">收货核对</text>
|
||||||
|
<button size="mini" class="text-xs text-gray-500" @click="closeReceipt">关闭</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="detailLoading" class="block text-xs text-gray-400">加载原单明细...</text>
|
||||||
|
<text v-if="detailError" class="mb-3 block text-xs text-red-500">{{ detailError }}</text>
|
||||||
|
<button v-if="detailError" size="mini" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</button>
|
||||||
|
<template v-if="receiptDetail">
|
||||||
|
<text class="block text-sm font-medium text-gray-700">{{ receiptDetail.shipmentNo }} · {{ receiptStateLabel(receiptDetail.receiptState) }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">目标现场 #{{ receiptDetail.targetSiteId }}</text>
|
||||||
|
<text v-if="receiptDetail.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ receiptDetail.receivedAt || '未登记' }}</text>
|
||||||
|
<text v-if="receiptDetail.receiptState === 'LEGACY'" class="mt-3 block text-xs text-gray-500">历史未登记收货:仅供查询原单明细,不补造收货时间。</text>
|
||||||
|
<view v-if="receiptDetail.receiptState === 'PENDING'" class="mt-3">
|
||||||
|
<PalletScanInput :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
|
||||||
|
<text class="mt-2 block text-xs text-gray-500">逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托,全部核对后才可确认。</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="scanError" class="mt-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
<text v-if="!receiptItems.length" class="mt-3 block text-xs text-red-500">原单无托盘明细,无法确认收货</text>
|
||||||
|
<view v-for="item in receiptItems" :key="item.palletId" class="mt-3 rounded-xl bg-gray-50 p-3">
|
||||||
|
<view class="flex items-center justify-between gap-2">
|
||||||
|
<text class="text-sm font-medium text-gray-700">{{ item.palletCode }}</text>
|
||||||
|
<text v-if="receiptDetail.receiptState === 'PENDING'" class="text-xs text-orange-600">{{ scannedPalletIds.includes(item.palletId) ? '已核对' : '待扫描' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-2 block text-xs text-gray-600">品种:{{ item.strainName || '未登记' }} · 原发货 {{ item.packageQty }} 包</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">批号:{{ item.batchNo || '未登记' }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">生产日期:{{ item.productionDate || '未登记' }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">失效日期:{{ item.expiryDate || '未登记' }}</text>
|
||||||
|
<button v-if="receiptDetail.receiptState === 'PENDING' && scannedPalletIds.includes(item.palletId)" size="mini" class="mt-2 text-xs text-red-500" :disabled="!canScan || scanLoading" @click="removeScannedPallet(item.palletId)">撤销核对</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="confirmError" class="mt-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<button v-if="receiptDetail.receiptState === 'PENDING'" class="mt-4 rounded-xl bg-orange-500 py-3 text-sm text-white disabled:bg-gray-300" :disabled="!canConfirmReceipt" :loading="confirmLoading" @click="confirmReceipt">确认收货</button>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import PalletScanInput from '@/components/business/PalletScanInput.vue'
|
||||||
|
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
|
||||||
|
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
const emit = defineEmits(['received'])
|
||||||
|
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
|
||||||
|
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
|
||||||
|
const receiptList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const listError = ref('')
|
||||||
|
const activeShipmentId = ref(null)
|
||||||
|
const receiptDetail = ref(null)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref('')
|
||||||
|
const scannedPalletIds = ref([])
|
||||||
|
const scanLoading = ref(false)
|
||||||
|
const scanError = ref('')
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const confirmError = ref('')
|
||||||
|
const receiptAccepted = ref(false)
|
||||||
|
let session = 0
|
||||||
|
let listGeneration = 0
|
||||||
|
let suspended = false
|
||||||
|
let idempotencyKey = ''
|
||||||
|
|
||||||
|
const receiptItems = computed(() => receiptDetail.value?.items || [])
|
||||||
|
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
|
||||||
|
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
|
||||||
|
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
|
||||||
|
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
|
||||||
|
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
|
||||||
|
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
|
||||||
|
|
||||||
|
function receiptStateLabel(state) {
|
||||||
|
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrent(currentSession) {
|
||||||
|
return currentSession === session && !suspended && canReceive.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReceipts() {
|
||||||
|
if (!canReceive.value || suspended) return
|
||||||
|
const generation = ++listGeneration
|
||||||
|
loading.value = true
|
||||||
|
listError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await listShipmentReceipts({ ...queryParams })
|
||||||
|
if (generation !== listGeneration || suspended || !canReceive.value) return
|
||||||
|
receiptList.value = Array.isArray(response.rows) ? response.rows : []
|
||||||
|
total.value = response.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== listGeneration || suspended || !canReceive.value) return
|
||||||
|
receiptList.value = []
|
||||||
|
total.value = 0
|
||||||
|
listError.value = errorMessage(error, '收货列表加载失败,请重试')
|
||||||
|
} finally {
|
||||||
|
if (generation === listGeneration) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.pageNum = 1
|
||||||
|
return loadReceipts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReceipt() {
|
||||||
|
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
|
||||||
|
session += 1
|
||||||
|
activeShipmentId.value = null
|
||||||
|
receiptDetail.value = null
|
||||||
|
scannedPalletIds.value = []
|
||||||
|
detailLoading.value = false
|
||||||
|
scanLoading.value = false
|
||||||
|
confirmLoading.value = false
|
||||||
|
receiptAccepted.value = false
|
||||||
|
detailError.value = ''
|
||||||
|
scanError.value = ''
|
||||||
|
confirmError.value = ''
|
||||||
|
idempotencyKey = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReceiptDetail(shipmentId, currentSession) {
|
||||||
|
detailLoading.value = true
|
||||||
|
detailError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getShipmentReceipt(shipmentId)
|
||||||
|
if (!isCurrent(currentSession)) return
|
||||||
|
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
|
||||||
|
throw new Error('收货详情与当前单据不一致,请重新打开')
|
||||||
|
}
|
||||||
|
receiptDetail.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent(currentSession)) return
|
||||||
|
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
|
||||||
|
} finally {
|
||||||
|
if (isCurrent(currentSession)) detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReceipt(row) {
|
||||||
|
if (!canReceive.value || suspended || row.shipmentId == null) return
|
||||||
|
closeReceipt()
|
||||||
|
activeShipmentId.value = row.shipmentId
|
||||||
|
await loadReceiptDetail(row.shipmentId, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDetail() {
|
||||||
|
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
|
||||||
|
return loadReceiptDetail(activeShipmentId.value, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReceiptScan(identifier) {
|
||||||
|
if (!canScan.value || scanLoading.value) return
|
||||||
|
const value = String(identifier ?? '').trim()
|
||||||
|
if (!value) {
|
||||||
|
scanError.value = '请扫描二维码或输入托盘号'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentSession = session
|
||||||
|
scanLoading.value = true
|
||||||
|
scanError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await scanShipmentReceipt(activeShipmentId.value, value)
|
||||||
|
if (!isCurrent(currentSession)) return
|
||||||
|
const scanned = response.data
|
||||||
|
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
|
||||||
|
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
|
||||||
|
scanError.value = '该托盘或装填记录不属于当前发货单'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (scannedPalletIds.value.includes(original.palletId)) {
|
||||||
|
scanError.value = '该托盘已核对,请勿重复扫描'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
|
||||||
|
scannedPalletIds.value.push(original.palletId)
|
||||||
|
idempotencyKey = ''
|
||||||
|
confirmError.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
|
||||||
|
} finally {
|
||||||
|
if (isCurrent(currentSession)) scanLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (!canScan.value || scanLoading.value) return
|
||||||
|
if (!scannedPalletIds.value.includes(palletId)) return
|
||||||
|
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
|
||||||
|
idempotencyKey = ''
|
||||||
|
confirmError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReceipt() {
|
||||||
|
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
|
||||||
|
if (!canConfirmReceipt.value) {
|
||||||
|
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentSession = session
|
||||||
|
const shipmentId = activeShipmentId.value
|
||||||
|
confirmLoading.value = true
|
||||||
|
confirmError.value = ''
|
||||||
|
try {
|
||||||
|
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
|
||||||
|
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
|
||||||
|
const response = await confirmShipmentReceipt(shipmentId, {
|
||||||
|
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
|
||||||
|
})
|
||||||
|
if (!isCurrent(currentSession)) return
|
||||||
|
receiptAccepted.value = true
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '收货成功')
|
||||||
|
emit('received')
|
||||||
|
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
|
||||||
|
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
|
||||||
|
} catch (error) {
|
||||||
|
if (isCurrent(currentSession)) {
|
||||||
|
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isCurrent(currentSession)) confirmLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function suspendPanel() {
|
||||||
|
suspended = true
|
||||||
|
listGeneration += 1
|
||||||
|
loading.value = false
|
||||||
|
closeReceipt()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePanel() {
|
||||||
|
if (!suspended) return
|
||||||
|
suspended = false
|
||||||
|
loadReceipts()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadReceipts)
|
||||||
|
onBeforeUnmount(suspendPanel)
|
||||||
|
|
||||||
|
const receiptStateOptions = [
|
||||||
|
{ label: '待收货', value: 'PENDING' },
|
||||||
|
{ label: '已收货', value: 'RECEIVED' },
|
||||||
|
{ label: '历史未登记收货', value: 'LEGACY' },
|
||||||
|
{ label: '全部', value: undefined }
|
||||||
|
]
|
||||||
|
const receiptStateIndex = computed(() => receiptStateOptions.findIndex(item => item.value === queryParams.receiptState))
|
||||||
|
|
||||||
|
function changeReceiptState(event) {
|
||||||
|
const option = receiptStateOptions[Number(event.detail.value)]
|
||||||
|
if (!option) return
|
||||||
|
queryParams.receiptState = option.value
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(offset) {
|
||||||
|
const page = queryParams.pageNum + offset
|
||||||
|
if (loading.value || page < 1 || (page > 1 && (page - 1) * queryParams.pageSize >= total.value)) return
|
||||||
|
queryParams.pageNum = page
|
||||||
|
loadReceipts()
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(resumePanel)
|
||||||
|
onHide(suspendPanel)
|
||||||
|
onUnload(suspendPanel)
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="testBatchStore.isTestMode" class="test-batch-banner">
|
||||||
|
<view class="test-batch-icon">
|
||||||
|
<view class="i-mdi-flask-outline test-batch-icon-glyph"></view>
|
||||||
|
</view>
|
||||||
|
<view class="test-batch-copy">
|
||||||
|
<text class="test-batch-title">测试数据</text>
|
||||||
|
<text class="test-batch-value">
|
||||||
|
当前批次:{{ testBatchStore.currentBatchName || "已选测试批次" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTestBatchStore } from "@/store";
|
||||||
|
|
||||||
|
const testBatchStore = useTestBatchStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.test-batch-banner {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
border: 1rpx solid #f1b8b4;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
background: #fff5f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-icon {
|
||||||
|
display: flex;
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
flex: 0 0 52rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d9534f;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-icon-glyph {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-title,
|
||||||
|
.test-batch-value {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-title {
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-value {
|
||||||
|
margin-top: 5rpx;
|
||||||
|
color: #c2413b;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
<template>
|
||||||
|
<view class="test-batch-switch">
|
||||||
|
<view class="mode-switch-header">
|
||||||
|
<view class="mode-switch-copy">
|
||||||
|
<text class="mode-switch-title">数据模式</text>
|
||||||
|
<text class="mode-switch-description">
|
||||||
|
{{ testBatchStore.isTestMode ? "当前操作隔离在测试批次" : "当前使用正式数据" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
class="mode-switch-status"
|
||||||
|
:class="testBatchStore.isTestMode ? 'is-test' : 'is-formal'"
|
||||||
|
>
|
||||||
|
{{ testBatchStore.isTestMode ? "测试数据" : "正式数据" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mode-switch-controls">
|
||||||
|
<button
|
||||||
|
class="mode-switch-button"
|
||||||
|
:class="{ active: !testBatchStore.isTestMode }"
|
||||||
|
:disabled="loading"
|
||||||
|
size="mini"
|
||||||
|
@click="handleModeClick('formal')"
|
||||||
|
>
|
||||||
|
正式
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mode-switch-button"
|
||||||
|
:class="{ 'active-test': testBatchStore.isTestMode }"
|
||||||
|
:disabled="loading || !activeOptions.length"
|
||||||
|
size="mini"
|
||||||
|
@click="handleModeClick('test')"
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="testBatchStore.isTestMode || activeOptions.length"
|
||||||
|
class="test-batch-picker-row"
|
||||||
|
>
|
||||||
|
<text class="test-batch-picker-label-title">测试批次</text>
|
||||||
|
<picker
|
||||||
|
:range="activeOptions"
|
||||||
|
range-key="batchName"
|
||||||
|
:value="selectedBatchIndex"
|
||||||
|
:disabled="loading || !activeOptions.length"
|
||||||
|
@change="handleBatchChange"
|
||||||
|
>
|
||||||
|
<view class="test-batch-picker">
|
||||||
|
<text class="test-batch-picker-label">
|
||||||
|
{{ selectedBatch?.batchName || (loading ? "加载中..." : "暂无可用测试批次") }}
|
||||||
|
</text>
|
||||||
|
<view class="i-mdi-chevron-down test-batch-picker-arrow"></view>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loadError" class="mode-switch-error">
|
||||||
|
<text class="mode-switch-error-text">{{ loadError }}</text>
|
||||||
|
<button
|
||||||
|
class="mode-switch-retry"
|
||||||
|
size="mini"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="loadOptions"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useTestBatchStore } from "@/store";
|
||||||
|
|
||||||
|
const testBatchStore = useTestBatchStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
|
const activeOptions = computed(() =>
|
||||||
|
testBatchStore.options.filter((item) => item.status === "ACTIVE"),
|
||||||
|
);
|
||||||
|
const selectedBatch = computed(() =>
|
||||||
|
activeOptions.value.find(
|
||||||
|
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedBatchIndex = computed(() => {
|
||||||
|
const index = activeOptions.value.findIndex(
|
||||||
|
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error?.response?.data?.msg || error?.message || "测试批次加载失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
|
try {
|
||||||
|
await testBatchStore.refreshOptions();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.reason !== "TEST_BATCH_UNAVAILABLE") {
|
||||||
|
loadError.value = errorMessage(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmModeSwitch() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "切换数据模式",
|
||||||
|
content: "切换后会清空当前页面未提交的扫码和表单内容,确定继续吗?",
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确定",
|
||||||
|
success: (result) => resolve(result.confirm === true),
|
||||||
|
fail: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModeClick(mode) {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (mode === "formal") {
|
||||||
|
if (!testBatchStore.isTestMode || !(await confirmModeSwitch())) return;
|
||||||
|
testBatchStore.leaveTestMode();
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testBatchStore.isTestMode) return;
|
||||||
|
const batch = activeOptions.value[0];
|
||||||
|
if (!batch) {
|
||||||
|
uni.showToast({ title: "暂无可用测试批次", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirmModeSwitch())) return;
|
||||||
|
try {
|
||||||
|
testBatchStore.selectBatch(batch);
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchChange(event) {
|
||||||
|
const batch = activeOptions.value[Number(event.detail.value)];
|
||||||
|
if (!batch || String(batch.batchId) === String(testBatchStore.currentBatchId)) return;
|
||||||
|
if (!(await confirmModeSwitch())) return;
|
||||||
|
try {
|
||||||
|
testBatchStore.selectBatch(batch);
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOptions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.test-batch-switch {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1rpx solid #e5eeee;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-title,
|
||||||
|
.mode-switch-description,
|
||||||
|
.test-batch-picker-label-title,
|
||||||
|
.test-batch-picker-label,
|
||||||
|
.mode-switch-error-text {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-title {
|
||||||
|
color: #28225c;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-description {
|
||||||
|
margin-top: 5rpx;
|
||||||
|
color: #8b969d;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1rpx solid #dce7e7;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 7rpx 12rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-status.is-formal {
|
||||||
|
border-color: #dce7e7;
|
||||||
|
background: #f5f8f8;
|
||||||
|
color: #64727a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-status.is-test {
|
||||||
|
border-color: #f1b8b4;
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-controls {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 5rpx;
|
||||||
|
background: #f1f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 64rpx;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: #748087;
|
||||||
|
font-size: 23rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-button::after,
|
||||||
|
.mode-switch-retry::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-button.active {
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(40, 34, 92, 0.08);
|
||||||
|
color: #28225c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-button.active-test {
|
||||||
|
background: #d9534f;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(217, 83, 79, 0.18);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-picker-row {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-picker-label-title {
|
||||||
|
color: #6c7786;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-picker {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
border: 1rpx solid #e0eaea;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
background: #fbfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-picker-label {
|
||||||
|
flex: 1;
|
||||||
|
color: #47545c;
|
||||||
|
font-size: 23rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-batch-picker-arrow {
|
||||||
|
flex: 0 0 30rpx;
|
||||||
|
color: #96a5a9;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-error {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
padding: 10rpx 12rpx;
|
||||||
|
background: #fff8ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-error-text {
|
||||||
|
flex: 1;
|
||||||
|
color: #a15c00;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switch-retry {
|
||||||
|
width: auto;
|
||||||
|
min-width: 74rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #fff0d6;
|
||||||
|
color: #a15c00;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// 应用全局配置
|
// 应用全局配置
|
||||||
export default {
|
export default {
|
||||||
baseUrl: "http://localhost:9099",
|
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
|
||||||
|
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
|
||||||
// 应用信息
|
// 应用信息
|
||||||
appInfo: {
|
appInfo: {
|
||||||
// 应用名称
|
// 应用名称
|
||||||
@@ -8,7 +9,7 @@ export default {
|
|||||||
// 应用版本
|
// 应用版本
|
||||||
version: "1.9.0",
|
version: "1.9.0",
|
||||||
// 应用logo
|
// 应用logo
|
||||||
logo: "/static/logo.png",
|
logo: "/static/sylvan-logo.png",
|
||||||
// 官方网站
|
// 官方网站
|
||||||
site_url: "https://vfadmin.insistence.tech",
|
site_url: "https://vfadmin.insistence.tech",
|
||||||
// 政策协议
|
// 政策协议
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export const businessMenuEntries = [
|
||||||
|
{
|
||||||
|
path: "/pages/business/inbound/index",
|
||||||
|
title: "仓库入库",
|
||||||
|
description: "扫描托盘登记入库",
|
||||||
|
icon: "i-mdi-package-down",
|
||||||
|
iconClass: "bg-blue-50 text-blue-600",
|
||||||
|
permission: "business:inbound:list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/filling/index",
|
||||||
|
title: "工厂装填",
|
||||||
|
description: "录入菌种和包数",
|
||||||
|
icon: "i-mdi-package-variant-closed-plus",
|
||||||
|
iconClass: "bg-green-50 text-green-600",
|
||||||
|
permission: "business:filling:list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/shipment/index",
|
||||||
|
title: "发货出库",
|
||||||
|
description: "扫码创建发货单",
|
||||||
|
icon: "i-mdi-truck-delivery",
|
||||||
|
iconClass: "bg-orange-50 text-orange-600",
|
||||||
|
permission: "business:shipment:list",
|
||||||
|
permissions: ["business:shipment:list", "business:shipment:receive"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/usage/index",
|
||||||
|
title: "客户现场使用",
|
||||||
|
description: "绑定产线记录消耗",
|
||||||
|
icon: "i-mdi-factory",
|
||||||
|
iconClass: "bg-purple-50 text-purple-600",
|
||||||
|
permission: "business:usage:list",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/return/index",
|
||||||
|
title: "退回仓库",
|
||||||
|
description: "申请和确认退回",
|
||||||
|
icon: "i-mdi-undo-variant",
|
||||||
|
iconClass: "bg-red-50 text-red-600",
|
||||||
|
permission: "business:return:list",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -5,10 +5,24 @@ import store from "./store"; // store
|
|||||||
import { install } from "./plugins"; // plugins
|
import { install } from "./plugins"; // plugins
|
||||||
import "./permission"; // permission
|
import "./permission"; // permission
|
||||||
import { useDict } from "@/utils/dict";
|
import { useDict } from "@/utils/dict";
|
||||||
|
import { useTestBatchStore } from "./store";
|
||||||
|
|
||||||
|
let testBatchUnavailableListenerRegistered = false;
|
||||||
|
|
||||||
|
function registerTestBatchUnavailableListener() {
|
||||||
|
if (testBatchUnavailableListenerRegistered || typeof uni.$on !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testBatchUnavailableListenerRegistered = true;
|
||||||
|
uni.$on("test-batch-unavailable", () => {
|
||||||
|
useTestBatchStore().leaveTestMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = createSSRApp(App);
|
const app = createSSRApp(App);
|
||||||
app.use(store);
|
app.use(store);
|
||||||
|
registerTestBatchUnavailableListener();
|
||||||
app.config.globalProperties.useDict = useDict;
|
app.config.globalProperties.useDict = useDict;
|
||||||
install(app);
|
install(app);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,70 +1,72 @@
|
|||||||
{
|
{
|
||||||
"name": "智链云枢管理系统",
|
"name" : "智链云枢管理系统",
|
||||||
"appid": "__UNI__25A9D80",
|
"appid" : "__UNI__F8DBA93",
|
||||||
"description": "",
|
"description" : "",
|
||||||
"versionName": "1.9.0",
|
"versionName" : "1.9.0",
|
||||||
"versionCode": "100",
|
"versionCode" : "100",
|
||||||
"transformPx": false,
|
"transformPx" : false,
|
||||||
"app-plus": {
|
"app-plus" : {
|
||||||
"usingComponents": true,
|
"usingComponents" : true,
|
||||||
"nvueCompiler": "uni-app",
|
"nvueCompiler" : "uni-app",
|
||||||
"splashscreen": {
|
"splashscreen" : {
|
||||||
"alwaysShowBeforeRender": true,
|
"alwaysShowBeforeRender" : true,
|
||||||
"waiting": true,
|
"waiting" : true,
|
||||||
"autoclose": true,
|
"autoclose" : true,
|
||||||
"delay": 0
|
"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": {},
|
"quickapp" : {},
|
||||||
"distribute": {
|
"mp-weixin" : {
|
||||||
"android": {
|
"appid" : "wxccd7e2a0911b3397",
|
||||||
"permissions": [
|
"setting" : {
|
||||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
"urlCheck" : false,
|
||||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
"es6" : false,
|
||||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
"minified" : true,
|
||||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
"postcss" : true
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
},
|
||||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
"optimization" : {
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
"subPackages" : true
|
||||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
},
|
||||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
"usingComponents" : true
|
||||||
"<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
|
|
||||||
},
|
},
|
||||||
"optimization": {
|
"vueVersion" : "3",
|
||||||
"subPackages": true
|
"h5" : {
|
||||||
|
"devServer" : {
|
||||||
|
"port" : 9090,
|
||||||
|
"https" : false
|
||||||
|
},
|
||||||
|
"title" : "智链云枢管理系统",
|
||||||
|
"router" : {
|
||||||
|
"mode" : "hash",
|
||||||
|
"base" : "./"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"usingComponents": true
|
"locale" : "zh-Hans",
|
||||||
},
|
"fallbackLocale" : "zh-Hans"
|
||||||
"vueVersion": "3",
|
|
||||||
"h5": {
|
|
||||||
"devServer": {
|
|
||||||
"port": 9090,
|
|
||||||
"https": false
|
|
||||||
},
|
|
||||||
"title": "智链云枢管理系统",
|
|
||||||
"router": {
|
|
||||||
"mode": "hash",
|
|
||||||
"base": "./"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,36 @@
|
|||||||
"navigationBarTitleText": "工作台"
|
"navigationBarTitleText": "工作台"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/inbound/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "仓库入库"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/filling/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "工厂装填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/shipment/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "发货出库"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/usage/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "现场使用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/return/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "退回仓库"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/mine/index",
|
"path": "pages/mine/index",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -128,5 +158,15 @@
|
|||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarTitleText": "智链云枢管理系统",
|
"navigationBarTitleText": "智链云枢管理系统",
|
||||||
"navigationBarBackgroundColor": "#FFFFFF"
|
"navigationBarBackgroundColor": "#FFFFFF"
|
||||||
|
},
|
||||||
|
"condition" : { //模式配置,仅开发期间生效
|
||||||
|
"current": 0, //当前激活的模式(list 的索引项)
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "", //模式名称
|
||||||
|
"path": "", //启动页面,必选
|
||||||
|
"query": "" //启动参数,在页面的onLoad函数里面得到
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">工厂装填</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
扫描空托盘,录入菌种、批次、规格和初始包数
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有装填操作权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">扫描待装填托盘</text>
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="resetOperation"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleFillingScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受 EMPTY_IN_WAREHOUSE 状态托盘。
|
||||||
|
</text>
|
||||||
|
<text v-if="scanError" class="mt-2 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-green-50 p-3">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">
|
||||||
|
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-green-600">{{ scannedPallet.flowState }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
仓库:{{ scannedPallet.warehouseName || `#${scannedPallet.warehouseId}` }} · 容量:{{ scannedPallet.capacityPackages }} 包
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<text class="mb-3 block font-bold text-gray-800">装填信息</text>
|
||||||
|
<view class="space-y-3">
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">菌种</text>
|
||||||
|
<picker
|
||||||
|
:range="strainOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="strainIndex"
|
||||||
|
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||||
|
@change="handleStrainChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedStrainName || "请选择启用菌种" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">生产批次</text>
|
||||||
|
<input
|
||||||
|
v-model="form.batchNo"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="请输入生产批次"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
@input="markPayloadChanged"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">生产日期</text>
|
||||||
|
<picker mode="date" :value="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('productionDate', $event)">
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.productionDate || '选择来源生产日期' }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">有效期至</text>
|
||||||
|
<picker mode="date" :value="form.expiryDate" :start="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('expiryDate', $event)">
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.expiryDate || '选择有效期截止日期' }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
|
||||||
|
<input
|
||||||
|
v-model="form.packageSpec"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="例如:500g/包"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
@input="markPayloadChanged"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">初始包数</text>
|
||||||
|
<input
|
||||||
|
v-model="form.initialPackages"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
type="number"
|
||||||
|
placeholder="固定 100 包"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||||
|
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="formError" class="mt-3 block text-xs text-red-500">{{ formError }}</text>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-green-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canOperate || operationPending || scanLoading || !scannedPallet"
|
||||||
|
@click="submitFilling"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "提交中" : "确认装填" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">最近装填记录</text>
|
||||||
|
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadFillingList">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!fillingList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无装填记录
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in fillingList"
|
||||||
|
v-else
|
||||||
|
:key="row.fillingId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<text class="block font-medium text-gray-700">{{ row.fillingNo || `#${row.fillingId}` }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ row.strainNameSnapshot || row.strainCodeSnapshot }} · {{ row.batchNo }} · {{ row.initialPackages }} 包
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">托盘 #{{ row.palletId }} · {{ row.operatedAt || row.createTime || "—" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||||
|
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||||
|
import {
|
||||||
|
addFilling,
|
||||||
|
getFillingStrainOptions,
|
||||||
|
listFilling,
|
||||||
|
scanFillingPallet,
|
||||||
|
} from "@/api/business/filling";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const fillingList = ref([]);
|
||||||
|
const strainOptions = ref([]);
|
||||||
|
const scannedPallet = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const scanError = ref("");
|
||||||
|
const formError = ref("");
|
||||||
|
const idempotencyKey = ref("");
|
||||||
|
const form = reactive({
|
||||||
|
qrToken: "",
|
||||||
|
warehouseId: null,
|
||||||
|
strainId: null,
|
||||||
|
batchNo: "",
|
||||||
|
productionDate: "",
|
||||||
|
expiryDate: "",
|
||||||
|
packageSpec: "",
|
||||||
|
initialPackages: 100,
|
||||||
|
});
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:filling:list",
|
||||||
|
"business:filling:query",
|
||||||
|
"business:filling:add",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:filling:list"));
|
||||||
|
const canOperate = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:filling:query", "business:filling:add"]),
|
||||||
|
);
|
||||||
|
const operationPending = computed(() => submitLoading.value);
|
||||||
|
const strainIndex = computed(() => {
|
||||||
|
const index = strainOptions.value.findIndex((item) => item.id === form.strainId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedStrainName = computed(
|
||||||
|
() => strainOptions.value.find((item) => item.id === form.strainId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFillingList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listFilling({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
fillingList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
fillingList.value = [];
|
||||||
|
listError.value = errorMessage(error, "装填记录加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStrainOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:filling:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getFillingStrainOptions()
|
||||||
|
.then((response) => {
|
||||||
|
strainOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
strainOptions.value = [];
|
||||||
|
formError.value = errorMessage(error, "菌种选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOperation() {
|
||||||
|
scanGeneration += 1;
|
||||||
|
scannedPallet.value = null;
|
||||||
|
idempotencyKey.value = "";
|
||||||
|
form.qrToken = "";
|
||||||
|
form.warehouseId = null;
|
||||||
|
form.strainId = null;
|
||||||
|
form.batchNo = "";
|
||||||
|
form.productionDate = "";
|
||||||
|
form.expiryDate = "";
|
||||||
|
form.packageSpec = "";
|
||||||
|
form.initialPackages = 100;
|
||||||
|
scanError.value = "";
|
||||||
|
formError.value = "";
|
||||||
|
scanLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFillingScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
resetOperation();
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanError.value = "";
|
||||||
|
scanFillingPallet(qrToken)
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (pallet?.flowState !== "EMPTY_IN_WAREHOUSE") {
|
||||||
|
scanError.value = "该托盘当前不可装填";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallet.value = {
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
warehouseId: pallet.warehouseId,
|
||||||
|
warehouseName: pallet.warehouseName,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
capacityPackages: pallet.capacityPackages,
|
||||||
|
};
|
||||||
|
form.qrToken = pallet.qrToken || qrToken;
|
||||||
|
form.warehouseId = pallet.warehouseId;
|
||||||
|
loadStrainOptions();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration) {
|
||||||
|
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration) scanLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
resetOperation();
|
||||||
|
scanError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStrainChange(event) {
|
||||||
|
const option = strainOptions.value[Number(event.detail.value)];
|
||||||
|
form.strainId = option?.id || null;
|
||||||
|
markPayloadChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPayloadChanged() {
|
||||||
|
if (!submitLoading.value) idempotencyKey.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMaterialDateChange(field, event) {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
form[field] = event.detail.value;
|
||||||
|
markPayloadChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIdempotencyKey() {
|
||||||
|
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||||
|
return idempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitFilling() {
|
||||||
|
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
const initialPackages = Number(form.initialPackages);
|
||||||
|
if (!scannedPallet.value || !form.qrToken || !form.warehouseId) {
|
||||||
|
formError.value = "请先扫描待装填托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.strainId || !form.batchNo.trim() || !form.packageSpec.trim()) {
|
||||||
|
formError.value = "请完整填写菌种、批次和包装规格";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
|
||||||
|
formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialPackages !== 100) {
|
||||||
|
formError.value = "新装填固定为 100 包";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
formError.value = "";
|
||||||
|
addFilling({
|
||||||
|
qrToken: form.qrToken,
|
||||||
|
warehouseId: form.warehouseId,
|
||||||
|
strainId: form.strainId,
|
||||||
|
batchNo: form.batchNo.trim(),
|
||||||
|
productionDate: form.productionDate,
|
||||||
|
expiryDate: form.expiryDate,
|
||||||
|
packageSpec: form.packageSpec.trim(),
|
||||||
|
initialPackages,
|
||||||
|
idempotencyKey: ensureIdempotencyKey(),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
idempotencyKey.value = "";
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "托盘装填成功");
|
||||||
|
resetOperation();
|
||||||
|
loadFillingList();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = `${errorMessage(error, "装填提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadFillingList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">仓库入库</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
扫描尚未首次入库的空托盘,保存草稿后确认
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="canDraft"
|
||||||
|
class="rounded-lg bg-blue-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startNewDraft"
|
||||||
|
>
|
||||||
|
新建草稿
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有入库操作权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-4 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">
|
||||||
|
{{ editing ? "编辑入库草稿" : "新建入库草稿" }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">入库仓库</text>
|
||||||
|
<picker
|
||||||
|
:range="warehouseOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="warehouseIndex"
|
||||||
|
:disabled="optionLoading || detailLoading || operationPending"
|
||||||
|
@change="handleWarehouseChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedWarehouseName || "请选择授权仓库" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="!form.warehouseId || detailLoading || operationPending"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleInboundScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受 UNSTOCKED 托盘;同一托盘不会重复加入。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||||
|
<view
|
||||||
|
v-for="pallet in scannedPallets"
|
||||||
|
:key="pallet.palletId"
|
||||||
|
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<text class="block text-sm font-medium text-gray-700">
|
||||||
|
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="block text-xs text-gray-500">{{ pallet.flowState }}</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="text-xs text-red-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="removeScannedPallet(pallet.palletId)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||||
|
@click="saveDraft"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="cancelEditor"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">入库单</text>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="loading || operationPending"
|
||||||
|
@click="loadInboundList"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!inboundList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无入库单
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in inboundList"
|
||||||
|
v-else
|
||||||
|
:key="row.inboundId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">{{ row.inboundNo || `#${row.inboundId}` }}</text>
|
||||||
|
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||||
|
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
仓库:{{ warehouseName(row.warehouseId) }} · 托盘:{{ row.items?.length || 0 }} 个
|
||||||
|
</text>
|
||||||
|
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startEditDraft(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canConfirm"
|
||||||
|
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="confirmDraft(row)"
|
||||||
|
>
|
||||||
|
{{ confirmLoading ? "确认中" : "确认" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||||
|
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||||
|
import {
|
||||||
|
addInbound,
|
||||||
|
confirmInbound,
|
||||||
|
getInbound,
|
||||||
|
getInboundWarehouseOptions,
|
||||||
|
listInbound,
|
||||||
|
scanInboundPallet,
|
||||||
|
updateInbound,
|
||||||
|
} from "@/api/business/inbound";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const inboundList = ref([]);
|
||||||
|
const warehouseOptions = ref([]);
|
||||||
|
const scannedPallets = ref([]);
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const editing = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const confirmLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const formError = ref("");
|
||||||
|
const scanError = ref("");
|
||||||
|
const confirmError = ref("");
|
||||||
|
const form = reactive({ inboundId: null, warehouseId: null });
|
||||||
|
const confirmState = reactive({ inboundId: null, key: "" });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:inbound:list",
|
||||||
|
"business:inbound:query",
|
||||||
|
"business:inbound:add",
|
||||||
|
"business:inbound:edit",
|
||||||
|
"business:inbound:confirm",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:inbound:list"));
|
||||||
|
const canDraft = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:add"]),
|
||||||
|
);
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:edit"]),
|
||||||
|
);
|
||||||
|
const canConfirm = computed(() =>
|
||||||
|
proxy.$auth.hasPermi("business:inbound:confirm"),
|
||||||
|
);
|
||||||
|
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||||
|
const operationPending = computed(
|
||||||
|
() =>
|
||||||
|
submitLoading.value ||
|
||||||
|
confirmLoading.value ||
|
||||||
|
detailLoading.value,
|
||||||
|
);
|
||||||
|
const warehouseIndex = computed(() => {
|
||||||
|
const index = warehouseOptions.value.findIndex(
|
||||||
|
(item) => item.id === form.warehouseId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedWarehouseName = computed(
|
||||||
|
() => warehouseOptions.value.find((item) => item.id === form.warehouseId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseName(id) {
|
||||||
|
return warehouseOptions.value.find((item) => item.id === id)?.name || `#${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
scanGeneration += 1;
|
||||||
|
editorVisible.value = false;
|
||||||
|
editing.value = false;
|
||||||
|
form.inboundId = null;
|
||||||
|
form.warehouseId = null;
|
||||||
|
scannedPallets.value = [];
|
||||||
|
detailLoading.value = false;
|
||||||
|
scanLoading.value = false;
|
||||||
|
formError.value = "";
|
||||||
|
scanError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInboundList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listInbound({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
inboundList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
inboundList.value = [];
|
||||||
|
listError.value = errorMessage(error, "入库单列表加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWarehouseOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:inbound:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getInboundWarehouseOptions()
|
||||||
|
.then((response) => {
|
||||||
|
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warehouseOptions.value = [];
|
||||||
|
formError.value = errorMessage(error, "仓库选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewDraft() {
|
||||||
|
if (!canDraft.value || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
loadWarehouseOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditDraft(row) {
|
||||||
|
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
editing.value = true;
|
||||||
|
form.inboundId = row.inboundId;
|
||||||
|
loadWarehouseOptions();
|
||||||
|
detailLoading.value = true;
|
||||||
|
getInbound(row.inboundId)
|
||||||
|
.then((response) => {
|
||||||
|
const detail = response.data;
|
||||||
|
if (!detail || detail.status !== "DRAFT") {
|
||||||
|
formError.value = "该入库单已不是草稿状态,不能编辑";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.warehouseId = detail.warehouseId;
|
||||||
|
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||||
|
(item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
flowState: item.flowState || "UNSTOCKED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "入库单详情加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detailLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditor() {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseChange(event) {
|
||||||
|
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === form.warehouseId) return;
|
||||||
|
form.warehouseId = option.id;
|
||||||
|
scannedPallets.value = [];
|
||||||
|
scanError.value = "";
|
||||||
|
scanGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInboundScan(qrToken) {
|
||||||
|
if (!form.warehouseId || operationPending.value || scanLoading.value) {
|
||||||
|
scanError.value = "请先选择入库仓库";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanError.value = "";
|
||||||
|
scanInboundPallet(qrToken, { warehouseId: form.warehouseId })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (pallet?.flowState !== "UNSTOCKED") {
|
||||||
|
scanError.value = "该托盘不是未入库状态,不能加入入库草稿";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已在当前草稿中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration) {
|
||||||
|
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration) scanLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
scanError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
scannedPallets.value = scannedPallets.value.filter(
|
||||||
|
(item) => item.palletId !== palletId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||||
|
if (!form.warehouseId) {
|
||||||
|
formError.value = "请选择入库仓库";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scannedPallets.value.length) {
|
||||||
|
formError.value = "请至少扫描一个托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
formError.value = "";
|
||||||
|
const payload = {
|
||||||
|
warehouseId: form.warehouseId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||||
|
};
|
||||||
|
const submitRequest = editing.value
|
||||||
|
? updateInbound({ ...payload, inboundId: form.inboundId })
|
||||||
|
: addInbound(payload);
|
||||||
|
submitRequest
|
||||||
|
.then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "入库草稿保存成功");
|
||||||
|
resetEditor();
|
||||||
|
loadInboundList();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "入库草稿保存失败,请根据提示修正后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "系统提示",
|
||||||
|
content,
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确定",
|
||||||
|
success: (result) => resolve(Boolean(result.confirm)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(inboundId) {
|
||||||
|
if (confirmState.inboundId !== inboundId) {
|
||||||
|
confirmState.inboundId = inboundId;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||||
|
return confirmState.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmIdempotency() {
|
||||||
|
confirmState.inboundId = null;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDraft(row) {
|
||||||
|
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
if (!(await askConfirm(`确认入库单“${row.inboundNo || `#${row.inboundId}`}”?`))) return;
|
||||||
|
confirmLoading.value = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
try {
|
||||||
|
await confirmInbound(row.inboundId, {
|
||||||
|
idempotencyKey: ensureConfirmIdempotencyKey(row.inboundId),
|
||||||
|
});
|
||||||
|
clearConfirmIdempotency();
|
||||||
|
proxy.$modal.msgSuccess("入库确认成功");
|
||||||
|
loadInboundList();
|
||||||
|
} catch (error) {
|
||||||
|
confirmError.value = `${errorMessage(error, "入库确认失败")};再次确认将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadInboundList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">退回仓库</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
客户现场发起退回申请,仓库逐托扫描后确认收货
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有退回操作权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="canRequest" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">发起退回申请</text>
|
||||||
|
<button class="text-xs text-gray-500" size="mini" :disabled="requestPending" @click="resetRequestOperation">
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">目标仓库</text>
|
||||||
|
<picker
|
||||||
|
:range="warehouseOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="warehouseIndex"
|
||||||
|
:disabled="optionLoading || requestPending"
|
||||||
|
@change="handleWarehouseChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedWarehouseName || "请选择目标仓库" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="!requestForm.targetWarehouseId || requestPending"
|
||||||
|
:loading="scanLoading.request"
|
||||||
|
@scan="handleRequestScan"
|
||||||
|
@invalid="handleRequestScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
同一申请只能包含同一客户现场的托盘;扫码成功后托盘由服务端冻结。
|
||||||
|
</text>
|
||||||
|
<text v-if="requestError" class="mt-2 block text-xs text-red-500">{{ requestError }}</text>
|
||||||
|
<view v-if="requestScannedPallets.length" class="mt-3 space-y-2">
|
||||||
|
<view
|
||||||
|
v-for="pallet in requestScannedPallets"
|
||||||
|
:key="pallet.palletId"
|
||||||
|
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<text class="block text-sm text-gray-700">{{ pallet.palletCode || `#${pallet.palletId}` }}</text>
|
||||||
|
<text class="block text-xs text-gray-500">现场 #{{ pallet.siteId }} · 剩余 {{ pallet.remainingPackages }} 包</text>
|
||||||
|
</view>
|
||||||
|
<button class="text-xs text-red-500" size="mini" :disabled="requestPending" @click="removeRequestPallet(pallet.palletId)">
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="requestForm.sourceSiteId" class="mt-3 block text-xs text-gray-500">
|
||||||
|
来源现场:#{{ requestForm.sourceSiteId }} · {{ requestScannedPallets.length }} 个托盘
|
||||||
|
</text>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!requestScannedPallets.length || requestPending || scanLoading.request"
|
||||||
|
@click="submitReturnRequest"
|
||||||
|
>
|
||||||
|
{{ requestSubmitLoading ? "申请中" : "提交退回申请" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canConfirm" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">仓库确认退回</text>
|
||||||
|
<text class="text-xs text-gray-400">必须完整扫描单据托盘</text>
|
||||||
|
</view>
|
||||||
|
<picker
|
||||||
|
:range="requestedReturns"
|
||||||
|
range-key="returnNo"
|
||||||
|
:value="requestedReturnIndex"
|
||||||
|
:disabled="confirmPending || detailLoading"
|
||||||
|
@change="handleConfirmSelection"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedReturnLabel || "请选择待确认退回单" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="confirmError" class="mt-2 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<view v-if="confirmExpectedRows.length" class="mt-3 rounded-xl bg-orange-50 p-3">
|
||||||
|
<text class="block text-xs text-gray-500">
|
||||||
|
预期托盘 {{ confirmExpectedRows.length }} 个,已扫描 {{ confirmScannedPallets.length }} 个
|
||||||
|
</text>
|
||||||
|
<view class="mt-2 space-y-1">
|
||||||
|
<text v-for="row in confirmExpectedRows" :key="row.palletId" class="block text-xs text-gray-600">
|
||||||
|
{{ row.palletCode || `#${row.palletId}` }}
|
||||||
|
<text v-if="isConfirmPalletScanned(row.palletId)" class="text-green-600"> · 已扫描</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
class="mt-3"
|
||||||
|
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || detailLoading"
|
||||||
|
:loading="scanLoading.confirm"
|
||||||
|
@scan="handleConfirmScan"
|
||||||
|
@invalid="handleConfirmScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || scanLoading.confirm || detailLoading"
|
||||||
|
@click="submitReturnConfirm"
|
||||||
|
>
|
||||||
|
{{ confirmSubmitLoading ? "确认中" : "确认退回收货" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">退回单</text>
|
||||||
|
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadReturnList">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!returnList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无退回单
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in returnList"
|
||||||
|
v-else
|
||||||
|
:key="row.returnId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">{{ row.returnNo || `#${row.returnId}` }}</text>
|
||||||
|
<text :class="row.status === 'REQUESTED' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||||
|
{{ row.status === "REQUESTED" ? "待确认" : "已确认" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
现场 #{{ row.sourceSiteId }} → {{ warehouseName(row.targetWarehouseId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||||
|
</text>
|
||||||
|
<button
|
||||||
|
v-if="row.status === 'REQUESTED' && canConfirm"
|
||||||
|
class="mt-3 w-full rounded-lg bg-orange-50 py-2 text-xs text-orange-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="confirmPending"
|
||||||
|
@click="selectReturnRow(row)"
|
||||||
|
>
|
||||||
|
选择并确认
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||||
|
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||||
|
import {
|
||||||
|
confirmReturn,
|
||||||
|
getReturn,
|
||||||
|
getReturnWarehouseOptions,
|
||||||
|
listReturn,
|
||||||
|
requestReturn,
|
||||||
|
scanReturnPallet,
|
||||||
|
} from "@/api/business/return";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const returnList = ref([]);
|
||||||
|
const warehouseOptions = ref([]);
|
||||||
|
const requestScannedPallets = ref([]);
|
||||||
|
const confirmExpectedRows = ref([]);
|
||||||
|
const confirmExpectedPalletIds = ref([]);
|
||||||
|
const confirmScannedPallets = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const requestError = ref("");
|
||||||
|
const confirmError = ref("");
|
||||||
|
const scanLoading = reactive({ request: false, confirm: false });
|
||||||
|
const requestSubmitLoading = ref(false);
|
||||||
|
const confirmSubmitLoading = ref(false);
|
||||||
|
const requestIdempotencyKey = ref("");
|
||||||
|
const confirmIdempotencyKey = ref("");
|
||||||
|
const requestForm = reactive({ sourceSiteId: null, targetWarehouseId: null });
|
||||||
|
const confirmForm = reactive({ returnId: null });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let requestScanGeneration = 0;
|
||||||
|
let confirmScanGeneration = 0;
|
||||||
|
let detailGeneration = 0;
|
||||||
|
let requestSession = 0;
|
||||||
|
let confirmSession = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:return:list",
|
||||||
|
"business:return:query",
|
||||||
|
"business:return:request",
|
||||||
|
"business:return:confirm",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:return:list"));
|
||||||
|
const canRequest = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:request"]),
|
||||||
|
);
|
||||||
|
const canConfirm = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:confirm"]),
|
||||||
|
);
|
||||||
|
const requestPending = computed(() => requestSubmitLoading.value);
|
||||||
|
const confirmPending = computed(() => confirmSubmitLoading.value);
|
||||||
|
const warehouseIndex = computed(() => {
|
||||||
|
const index = warehouseOptions.value.findIndex(
|
||||||
|
(item) => item.id === requestForm.targetWarehouseId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedWarehouseName = computed(
|
||||||
|
() => warehouseOptions.value.find((item) => item.id === requestForm.targetWarehouseId)?.name || "",
|
||||||
|
);
|
||||||
|
const requestedReturns = computed(() =>
|
||||||
|
returnList.value.filter((item) => item.status === "REQUESTED"),
|
||||||
|
);
|
||||||
|
const requestedReturnIndex = computed(() => {
|
||||||
|
const index = requestedReturns.value.findIndex(
|
||||||
|
(item) => item.returnId === confirmForm.returnId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedReturnLabel = computed(() => {
|
||||||
|
const item = requestedReturns.value.find((row) => row.returnId === confirmForm.returnId);
|
||||||
|
return item ? `${item.returnNo || `#${item.returnId}`} · 现场 #${item.sourceSiteId}` : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseName(id) {
|
||||||
|
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReturnList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listReturn({ pageNum: 1, pageSize: 30 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
returnList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
returnList.value = [];
|
||||||
|
listError.value = errorMessage(error, "退回单列表加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWarehouseOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:return:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getReturnWarehouseOptions()
|
||||||
|
.then((response) => {
|
||||||
|
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warehouseOptions.value = [];
|
||||||
|
requestError.value = errorMessage(error, "目标仓库选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRequestOperation() {
|
||||||
|
requestSession += 1;
|
||||||
|
requestScanGeneration += 1;
|
||||||
|
requestForm.sourceSiteId = null;
|
||||||
|
requestForm.targetWarehouseId = null;
|
||||||
|
requestScannedPallets.value = [];
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
requestError.value = "";
|
||||||
|
scanLoading.request = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseChange(event) {
|
||||||
|
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === requestForm.targetWarehouseId) return;
|
||||||
|
requestForm.targetWarehouseId = option.id;
|
||||||
|
requestForm.sourceSiteId = null;
|
||||||
|
requestScannedPallets.value = [];
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
requestError.value = "";
|
||||||
|
requestScanGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestScan(qrToken) {
|
||||||
|
if (!requestForm.targetWarehouseId || requestPending.value || scanLoading.request) {
|
||||||
|
requestError.value = "请先选择目标仓库";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = requestSession;
|
||||||
|
const generation = ++requestScanGeneration;
|
||||||
|
scanLoading.request = true;
|
||||||
|
requestError.value = "";
|
||||||
|
scanReturnPallet(qrToken, { mode: "request" })
|
||||||
|
.then((response) => {
|
||||||
|
if (session !== requestSession || generation !== requestScanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (!pallet?.siteId) {
|
||||||
|
requestError.value = "该托盘不在客户现场,不能发起退回";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestForm.sourceSiteId && pallet.siteId !== requestForm.sourceSiteId) {
|
||||||
|
requestError.value = "同一退回申请只能包含同一客户现场的托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已在退回申请中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestForm.sourceSiteId = requestForm.sourceSiteId || pallet.siteId;
|
||||||
|
requestScannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
siteId: pallet.siteId,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
});
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (session === requestSession && generation === requestScanGeneration) {
|
||||||
|
requestError.value = errorMessage(error, "退回托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (session === requestSession && generation === requestScanGeneration) {
|
||||||
|
scanLoading.request = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestScanInvalid(message) {
|
||||||
|
requestError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRequestPallet(palletId) {
|
||||||
|
if (requestPending.value) return;
|
||||||
|
requestScannedPallets.value = requestScannedPallets.value.filter(
|
||||||
|
(item) => item.palletId !== palletId,
|
||||||
|
);
|
||||||
|
if (!requestScannedPallets.value.length) requestForm.sourceSiteId = null;
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRequestIdempotencyKey() {
|
||||||
|
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey();
|
||||||
|
return requestIdempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReturnRequest() {
|
||||||
|
if (requestPending.value || scanLoading.request || !requestScannedPallets.value.length) return;
|
||||||
|
if (!requestForm.sourceSiteId || !requestForm.targetWarehouseId) {
|
||||||
|
requestError.value = "请选择目标仓库并扫描退回托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestSubmitLoading.value = true;
|
||||||
|
requestError.value = "";
|
||||||
|
try {
|
||||||
|
await requestReturn({
|
||||||
|
sourceSiteId: requestForm.sourceSiteId,
|
||||||
|
targetWarehouseId: requestForm.targetWarehouseId,
|
||||||
|
palletIds: requestScannedPallets.value.map((item) => item.palletId),
|
||||||
|
idempotencyKey: ensureRequestIdempotencyKey(),
|
||||||
|
});
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
proxy.$modal.msgSuccess("退回申请已提交");
|
||||||
|
resetRequestOperation();
|
||||||
|
loadReturnList();
|
||||||
|
} catch (error) {
|
||||||
|
requestError.value = `${errorMessage(error, "退回申请提交失败")};直接重试将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
requestSubmitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConfirmOperation() {
|
||||||
|
confirmSession += 1;
|
||||||
|
confirmScanGeneration += 1;
|
||||||
|
detailGeneration += 1;
|
||||||
|
confirmForm.returnId = null;
|
||||||
|
confirmExpectedRows.value = [];
|
||||||
|
confirmExpectedPalletIds.value = [];
|
||||||
|
confirmScannedPallets.value = [];
|
||||||
|
confirmIdempotencyKey.value = "";
|
||||||
|
confirmError.value = "";
|
||||||
|
detailLoading.value = false;
|
||||||
|
scanLoading.confirm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmSelection(event) {
|
||||||
|
const row = requestedReturns.value[Number(event.detail.value)];
|
||||||
|
selectReturnRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectReturnRow(row) {
|
||||||
|
if (!row || row.status !== "REQUESTED" || confirmPending.value) return;
|
||||||
|
resetConfirmOperation();
|
||||||
|
const session = confirmSession;
|
||||||
|
const generation = ++detailGeneration;
|
||||||
|
confirmForm.returnId = row.returnId;
|
||||||
|
detailLoading.value = true;
|
||||||
|
getReturn(row.returnId)
|
||||||
|
.then((response) => {
|
||||||
|
if (session !== confirmSession || generation !== detailGeneration) return;
|
||||||
|
const detail = response.data;
|
||||||
|
if (!detail || detail.status !== "REQUESTED") {
|
||||||
|
confirmError.value = "该退回单已不是待确认状态";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||||
|
(item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId);
|
||||||
|
if (!confirmExpectedPalletIds.value.length) confirmError.value = "该退回单没有托盘明细";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (session === confirmSession && generation === detailGeneration) {
|
||||||
|
confirmError.value = errorMessage(error, "退回单详情加载失败,请稍后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (session === confirmSession && generation === detailGeneration) {
|
||||||
|
detailLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmScan(qrToken) {
|
||||||
|
if (!confirmForm.returnId || !confirmExpectedPalletIds.value.length || confirmPending.value || scanLoading.confirm) return;
|
||||||
|
const session = confirmSession;
|
||||||
|
const generation = ++confirmScanGeneration;
|
||||||
|
scanLoading.confirm = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
scanReturnPallet(qrToken, { mode: "confirm", returnId: confirmForm.returnId })
|
||||||
|
.then((response) => {
|
||||||
|
if (session !== confirmSession || generation !== confirmScanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
|
||||||
|
confirmError.value = "该托盘不属于所选退回单";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已完成扫码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmScannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
});
|
||||||
|
confirmIdempotencyKey.value = "";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||||
|
confirmError.value = errorMessage(error, "到仓托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||||
|
scanLoading.confirm = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmScanInvalid(message) {
|
||||||
|
confirmError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfirmPalletScanned(palletId) {
|
||||||
|
return confirmScannedPallets.value.some((item) => item.palletId === palletId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdSet(left, right) {
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
const leftSet = new Set(left);
|
||||||
|
const rightSet = new Set(right);
|
||||||
|
return (
|
||||||
|
leftSet.size === left.length &&
|
||||||
|
rightSet.size === right.length &&
|
||||||
|
right.every((id) => leftSet.has(id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(returnId) {
|
||||||
|
if (!confirmIdempotencyKey.value) confirmIdempotencyKey.value = createIdempotencyKey();
|
||||||
|
return confirmIdempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReturnConfirm() {
|
||||||
|
if (confirmPending.value || detailLoading.value || scanLoading.confirm) return;
|
||||||
|
const returnId = confirmForm.returnId;
|
||||||
|
const expectedIds = [...confirmExpectedPalletIds.value];
|
||||||
|
const scannedIds = confirmScannedPallets.value.map((item) => item.palletId);
|
||||||
|
if (!returnId || !sameIdSet(expectedIds, scannedIds)) {
|
||||||
|
confirmError.value = "必须逐个扫描并完整匹配退回单全部托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmSubmitLoading.value = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
try {
|
||||||
|
const latest = (await getReturn(returnId)).data;
|
||||||
|
const latestIds = (Array.isArray(latest.items) ? latest.items : []).map((item) => item.palletId);
|
||||||
|
if (latest.status !== "REQUESTED" || !sameIdSet(latestIds, scannedIds)) {
|
||||||
|
confirmError.value = "退回单状态或托盘明细已变化,请重新选择并扫码";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await confirmReturn(returnId, {
|
||||||
|
scannedPalletIds: scannedIds,
|
||||||
|
idempotencyKey: ensureConfirmIdempotencyKey(returnId),
|
||||||
|
});
|
||||||
|
confirmIdempotencyKey.value = "";
|
||||||
|
proxy.$modal.msgSuccess("退回确认成功");
|
||||||
|
resetConfirmOperation();
|
||||||
|
loadReturnList();
|
||||||
|
} catch (error) {
|
||||||
|
confirmError.value = `${errorMessage(error, "退回确认失败")};直接重试将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
confirmSubmitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadReturnList();
|
||||||
|
loadWarehouseOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">{{ activeTab === 'receipt' ? '收货核对' : '发货出库' }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ activeTab === 'receipt' ? '扫描原托盘标签,逐托核对目标现场收货' : '选择来源仓库和目标现场,扫码创建整托发货单' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="canDraft && activeTab === 'shipment'"
|
||||||
|
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startNewDraft"
|
||||||
|
>
|
||||||
|
新建草稿
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
<view v-if="canReceive" class="flex gap-3">
|
||||||
|
<button v-if="canShipment" size="mini" :class="activeTab === 'shipment' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'shipment'">发货出库</button>
|
||||||
|
<button size="mini" :disabled="operationPending || editorVisible" :class="activeTab === 'receipt' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'receipt'">收货核对</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有发货或收货权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<ShipmentReceiptPanel v-else-if="canReceive && activeTab === 'receipt'" @received="loadShipmentList" />
|
||||||
|
<template v-else-if="canShipment && activeTab === 'shipment'">
|
||||||
|
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-4 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">
|
||||||
|
{{ editing ? "编辑发货草稿" : "新建发货草稿" }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">来源仓库</text>
|
||||||
|
<picker
|
||||||
|
:range="warehouseOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="warehouseIndex"
|
||||||
|
:disabled="optionLoading || detailLoading || operationPending"
|
||||||
|
@change="handleWarehouseChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedWarehouseName || "请选择来源仓库" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">目标客户现场</text>
|
||||||
|
<picker
|
||||||
|
:range="siteOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="siteIndex"
|
||||||
|
:disabled="optionLoading || detailLoading || operationPending"
|
||||||
|
@change="handleSiteChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedSiteName || "请选择目标现场" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="!form.sourceWarehouseId || !form.targetSiteId || detailLoading || operationPending"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleShipmentScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受来源仓库内有剩余包数的 FILLED_IN_WAREHOUSE 托盘。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||||
|
<view
|
||||||
|
v-for="pallet in scannedPallets"
|
||||||
|
:key="pallet.palletId"
|
||||||
|
class="rounded-xl bg-orange-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view>
|
||||||
|
<text class="block text-sm font-medium text-gray-700">
|
||||||
|
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="block text-xs text-gray-500">
|
||||||
|
{{ pallet.strainName || pallet.strainCode || "未提供菌种" }} · 剩余 {{ pallet.remainingPackages }} 包
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="text-xs text-red-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="removeScannedPallet(pallet.palletId)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">
|
||||||
|
批次:{{ pallet.batchNo || "—" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||||
|
@click="saveDraft"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="cancelEditor"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">发货单</text>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="loading || operationPending"
|
||||||
|
@click="loadShipmentList"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!shipmentList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无发货单
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in shipmentList"
|
||||||
|
v-else
|
||||||
|
:key="row.shipmentId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
|
||||||
|
<text :class="row.status === 'DRAFT' || row.receiptState === 'PENDING' ? 'text-orange-500' : row.receiptState === 'LEGACY' ? 'text-gray-500' : 'text-green-500'" class="text-xs">
|
||||||
|
{{ shipmentStatusLabel(row) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ warehouseName(row.sourceWarehouseId) }} → {{ siteName(row.targetSiteId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||||
|
</text>
|
||||||
|
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startEditDraft(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canConfirm"
|
||||||
|
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="confirmDraft(row)"
|
||||||
|
>
|
||||||
|
{{ confirmLoading ? "确认中" : "确认" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||||
|
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||||
|
import ShipmentReceiptPanel from "@/components/business/ShipmentReceiptPanel.vue";
|
||||||
|
import {
|
||||||
|
addShipment,
|
||||||
|
confirmShipment,
|
||||||
|
getShipment,
|
||||||
|
getShipmentSiteOptions,
|
||||||
|
getShipmentWarehouseOptions,
|
||||||
|
listShipment,
|
||||||
|
scanShipmentPallet,
|
||||||
|
updateShipment,
|
||||||
|
} from "@/api/business/shipment";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const shipmentList = ref([]);
|
||||||
|
const warehouseOptions = ref([]);
|
||||||
|
const siteOptions = ref([]);
|
||||||
|
const scannedPallets = ref([]);
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const editing = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const confirmLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const formError = ref("");
|
||||||
|
const scanError = ref("");
|
||||||
|
const confirmError = ref("");
|
||||||
|
const form = reactive({ shipmentId: null, sourceWarehouseId: null, targetSiteId: null });
|
||||||
|
const confirmState = reactive({ shipmentId: null, key: "" });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
|
||||||
|
const canShipment = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:shipment:list",
|
||||||
|
"business:shipment:query",
|
||||||
|
"business:shipment:add",
|
||||||
|
"business:shipment:edit",
|
||||||
|
"business:shipment:confirm",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canReceive = computed(() => proxy.$auth.hasPermi("business:shipment:receive"));
|
||||||
|
const canPage = computed(() => canShipment.value || canReceive.value);
|
||||||
|
const activeTab = ref(canShipment.value ? "shipment" : "receipt");
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
|
||||||
|
const canDraft = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
|
||||||
|
);
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:edit"]),
|
||||||
|
);
|
||||||
|
const canConfirm = computed(() => proxy.$auth.hasPermi("business:shipment:confirm"));
|
||||||
|
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||||
|
const operationPending = computed(
|
||||||
|
() =>
|
||||||
|
submitLoading.value ||
|
||||||
|
confirmLoading.value ||
|
||||||
|
detailLoading.value,
|
||||||
|
);
|
||||||
|
const warehouseIndex = computed(() => {
|
||||||
|
const index = warehouseOptions.value.findIndex(
|
||||||
|
(item) => item.id === form.sourceWarehouseId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const siteIndex = computed(() => {
|
||||||
|
const index = siteOptions.value.findIndex((item) => item.id === form.targetSiteId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedWarehouseName = computed(
|
||||||
|
() => warehouseOptions.value.find((item) => item.id === form.sourceWarehouseId)?.name || "",
|
||||||
|
);
|
||||||
|
const selectedSiteName = computed(
|
||||||
|
() => siteOptions.value.find((item) => item.id === form.targetSiteId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shipmentStatusLabel(row) {
|
||||||
|
if (row.status === "DRAFT") return "草稿";
|
||||||
|
return { PENDING: "待收货", RECEIVED: "已收货", LEGACY: "历史未登记收货" }[row.receiptState] || "已确认";
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseName(id) {
|
||||||
|
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteName(id) {
|
||||||
|
return siteOptions.value.find((item) => item.id === id)?.name || `现场 #${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
scanGeneration += 1;
|
||||||
|
editorVisible.value = false;
|
||||||
|
editing.value = false;
|
||||||
|
form.shipmentId = null;
|
||||||
|
form.sourceWarehouseId = null;
|
||||||
|
form.targetSiteId = null;
|
||||||
|
scannedPallets.value = [];
|
||||||
|
detailLoading.value = false;
|
||||||
|
scanLoading.value = false;
|
||||||
|
formError.value = "";
|
||||||
|
scanError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShipmentList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listShipment({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
shipmentList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
shipmentList.value = [];
|
||||||
|
listError.value = errorMessage(error, "发货单列表加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:shipment:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()])
|
||||||
|
.then(([warehouseResponse, siteResponse]) => {
|
||||||
|
warehouseOptions.value = Array.isArray(warehouseResponse.data)
|
||||||
|
? warehouseResponse.data
|
||||||
|
: [];
|
||||||
|
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warehouseOptions.value = [];
|
||||||
|
siteOptions.value = [];
|
||||||
|
formError.value = errorMessage(error, "仓库或现场选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewDraft() {
|
||||||
|
if (!canDraft.value || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
loadOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditDraft(row) {
|
||||||
|
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
editing.value = true;
|
||||||
|
form.shipmentId = row.shipmentId;
|
||||||
|
loadOptions();
|
||||||
|
detailLoading.value = true;
|
||||||
|
getShipment(row.shipmentId)
|
||||||
|
.then((response) => {
|
||||||
|
const detail = response.data;
|
||||||
|
if (!detail || detail.status !== "DRAFT") {
|
||||||
|
formError.value = "该发货单已不是草稿状态,不能编辑";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.sourceWarehouseId = detail.sourceWarehouseId;
|
||||||
|
form.targetSiteId = detail.targetSiteId;
|
||||||
|
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||||
|
(item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
strainName: item.strainName || item.strainCode,
|
||||||
|
batchNo: item.batchNo,
|
||||||
|
remainingPackages: item.remainingPackages ?? item.packageQty ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "发货单详情加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detailLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditor() {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScannedPallets() {
|
||||||
|
scannedPallets.value = [];
|
||||||
|
scanError.value = "";
|
||||||
|
scanGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseChange(event) {
|
||||||
|
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === form.sourceWarehouseId) return;
|
||||||
|
form.sourceWarehouseId = option.id;
|
||||||
|
clearScannedPallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSiteChange(event) {
|
||||||
|
const option = siteOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === form.targetSiteId) return;
|
||||||
|
form.targetSiteId = option.id;
|
||||||
|
clearScannedPallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShipmentScan(qrToken) {
|
||||||
|
if (
|
||||||
|
!form.sourceWarehouseId ||
|
||||||
|
!form.targetSiteId ||
|
||||||
|
operationPending.value ||
|
||||||
|
scanLoading.value
|
||||||
|
) {
|
||||||
|
scanError.value = "请先选择来源仓库和目标现场";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanError.value = "";
|
||||||
|
scanShipmentPallet(qrToken, {
|
||||||
|
sourceWarehouseId: form.sourceWarehouseId,
|
||||||
|
targetSiteId: form.targetSiteId,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (pallet?.flowState !== "FILLED_IN_WAREHOUSE" || pallet.remainingPackages <= 0) {
|
||||||
|
scanError.value = "该托盘不是来源仓库内的有料托盘,不能发货";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已在当前发货草稿中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
strainName: pallet.strainName || pallet.strainCode,
|
||||||
|
batchNo: pallet.batchNo,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration) {
|
||||||
|
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration) scanLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
scanError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
scannedPallets.value = scannedPallets.value.filter(
|
||||||
|
(item) => item.palletId !== palletId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||||
|
if (!form.sourceWarehouseId || !form.targetSiteId) {
|
||||||
|
formError.value = "请选择来源仓库和目标现场";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scannedPallets.value.length) {
|
||||||
|
formError.value = "请至少扫描一个有料托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
formError.value = "";
|
||||||
|
const payload = {
|
||||||
|
sourceWarehouseId: form.sourceWarehouseId,
|
||||||
|
targetSiteId: form.targetSiteId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||||
|
};
|
||||||
|
const submitRequest = editing.value
|
||||||
|
? updateShipment({ ...payload, shipmentId: form.shipmentId })
|
||||||
|
: addShipment(payload);
|
||||||
|
submitRequest
|
||||||
|
.then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "发货草稿保存成功");
|
||||||
|
resetEditor();
|
||||||
|
loadShipmentList();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "发货草稿保存失败,请根据提示修正后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "系统提示",
|
||||||
|
content,
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确定",
|
||||||
|
success: (result) => resolve(Boolean(result.confirm)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(shipmentId) {
|
||||||
|
if (confirmState.shipmentId !== shipmentId) {
|
||||||
|
confirmState.shipmentId = shipmentId;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||||
|
return confirmState.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmIdempotency() {
|
||||||
|
confirmState.shipmentId = null;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDraft(row) {
|
||||||
|
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
if (!(await askConfirm(`确认发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`))) return;
|
||||||
|
confirmLoading.value = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
try {
|
||||||
|
await confirmShipment(row.shipmentId, {
|
||||||
|
idempotencyKey: ensureConfirmIdempotencyKey(row.shipmentId),
|
||||||
|
});
|
||||||
|
clearConfirmIdempotency();
|
||||||
|
proxy.$modal.msgSuccess("发货确认成功");
|
||||||
|
loadShipmentList();
|
||||||
|
} catch (error) {
|
||||||
|
confirmError.value = `${errorMessage(error, "发货确认失败")};再次确认将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadShipmentList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
扫描现场托盘,确认入隧后记录实际使用包数,产线可选
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
|
||||||
|
<text class="block font-bold text-gray-800">提交结果核对</text>
|
||||||
|
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
|
||||||
|
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
|
||||||
|
</text>
|
||||||
|
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
|
||||||
|
<text class="block text-sm text-gray-700">
|
||||||
|
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
|
||||||
|
</text>
|
||||||
|
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
|
||||||
|
恢复重试 / 核对
|
||||||
|
</button>
|
||||||
|
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
|
||||||
|
已人工核对,解除待确认
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
|
||||||
|
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">扫描待使用托盘</text>
|
||||||
|
<button class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
|
||||||
|
重新扫描
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleUsageScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
|
||||||
|
</text>
|
||||||
|
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">
|
||||||
|
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
生产日期:{{ scannedPallet.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="text-sm text-gray-700">先到先用建议</text>
|
||||||
|
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
|
||||||
|
刷新建议
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<text class="mb-3 block font-bold text-gray-800">托盘入隧</text>
|
||||||
|
<view v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||||
|
{{ selectedTunnelName }}(已入隧,不可切换)
|
||||||
|
</view>
|
||||||
|
<picker
|
||||||
|
v-else
|
||||||
|
:range="tunnelPickerOptions"
|
||||||
|
range-key="label"
|
||||||
|
:value="tunnelIndex"
|
||||||
|
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
|
||||||
|
@change="handleTunnelChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedTunnelName || "请选择使用隧道" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
|
||||||
|
先确认入隧,再提交使用包数。
|
||||||
|
</text>
|
||||||
|
<view class="mt-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
|
||||||
|
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
|
||||||
|
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="!tunnelLocked"
|
||||||
|
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
|
||||||
|
@click="enterTunnel"
|
||||||
|
>
|
||||||
|
{{ entryLoading ? "入隧中" : "确认入隧" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<text class="mb-3 block font-bold text-gray-800">使用信息</text>
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">使用产线(可选)</text>
|
||||||
|
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||||
|
{{ selectedLineName }}(已由托盘绑定)
|
||||||
|
</view>
|
||||||
|
<picker
|
||||||
|
v-else
|
||||||
|
:range="linePickerOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="lineIndex"
|
||||||
|
:disabled="operationPending || lineOptionLoading || !scannedPallet"
|
||||||
|
@change="handleLineChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedLineName || "不选择产线" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
|
||||||
|
<input
|
||||||
|
v-model="form.usedPackages"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入正整数"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
@input="markPayloadChanged"
|
||||||
|
/>
|
||||||
|
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||||
|
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
|
||||||
|
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
|
||||||
|
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-600">
|
||||||
|
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
|
||||||
|
@click="submitUsage"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "提交中" : "确认使用" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">最近使用记录</text>
|
||||||
|
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无使用记录
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in usageList"
|
||||||
|
v-else
|
||||||
|
:key="row.usageId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
|
||||||
|
</text>
|
||||||
|
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
|
||||||
|
产线 #{{ row.lineId }}
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">
|
||||||
|
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||||
|
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||||
|
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||||
|
import {
|
||||||
|
addUsage,
|
||||||
|
enterUsageTunnel,
|
||||||
|
getUsageFifoRecommendation,
|
||||||
|
getUsageLineOptions,
|
||||||
|
getUsageTunnelOptions,
|
||||||
|
listUsage,
|
||||||
|
scanUsagePallet,
|
||||||
|
} from "@/api/business/usage";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
import config from "@/config";
|
||||||
|
import constant from "@/utils/constant";
|
||||||
|
import storage from "@/utils/storage";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const usageList = ref([]);
|
||||||
|
const lineOptions = ref([]);
|
||||||
|
const tunnelOptions = ref([]);
|
||||||
|
const scannedPallet = ref(null);
|
||||||
|
const usageResult = ref(null);
|
||||||
|
const lineLocked = ref(false);
|
||||||
|
const tunnelLocked = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const lineOptionLoading = ref(false);
|
||||||
|
const lineOptionError = ref("");
|
||||||
|
const fifoLoading = ref(false);
|
||||||
|
const fifoMessage = ref("");
|
||||||
|
const fifoRequiresSkipReason = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const entryLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const operationError = ref("");
|
||||||
|
const submitError = ref("");
|
||||||
|
const pendingOperations = ref([]);
|
||||||
|
const recoveryStorageKey = ref("");
|
||||||
|
const recoveryLoading = ref(false);
|
||||||
|
const recoveryError = ref("");
|
||||||
|
const recoveryMessage = ref("");
|
||||||
|
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
let optionGeneration = 0;
|
||||||
|
let fifoGeneration = 0;
|
||||||
|
let operationSession = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:usage:list",
|
||||||
|
"business:usage:query",
|
||||||
|
"business:usage:add",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
|
||||||
|
const canOperate = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
|
||||||
|
);
|
||||||
|
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
|
||||||
|
const linePickerOptions = computed(() => [
|
||||||
|
{ id: null, name: "不选择产线" },
|
||||||
|
...lineOptions.value,
|
||||||
|
]);
|
||||||
|
const lineIndex = computed(() => {
|
||||||
|
const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedLineName = computed(
|
||||||
|
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
|
||||||
|
);
|
||||||
|
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: formatTunnelName(item),
|
||||||
|
})));
|
||||||
|
const tunnelIndex = computed(() => {
|
||||||
|
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedTunnelName = computed(() => {
|
||||||
|
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
|
||||||
|
if (option) return formatTunnelName(option);
|
||||||
|
if (!form.tunnelId) return "";
|
||||||
|
return formatTunnelName({
|
||||||
|
id: form.tunnelId,
|
||||||
|
name: scannedPallet.value?.tunnelName,
|
||||||
|
code: scannedPallet.value?.tunnelCode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTunnelName(option) {
|
||||||
|
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentRecoveryStorageKey() {
|
||||||
|
const userId = storage.get(constant.id);
|
||||||
|
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
|
||||||
|
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPendingOperations() {
|
||||||
|
try {
|
||||||
|
const key = currentRecoveryStorageKey();
|
||||||
|
const records = uni.getStorageSync(key) || [];
|
||||||
|
if (!Array.isArray(records) || records.some((item) =>
|
||||||
|
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
|
||||||
|
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
|
||||||
|
recoveryStorageKey.value = key;
|
||||||
|
pendingOperations.value = records;
|
||||||
|
recoveryError.value = "";
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
pendingOperations.value = [];
|
||||||
|
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePendingOperations(records) {
|
||||||
|
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
|
||||||
|
uni.setStorageSync(recoveryStorageKey.value, records);
|
||||||
|
pendingOperations.value = records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingForPallet() {
|
||||||
|
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSignature(action, payload) {
|
||||||
|
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareOperation(action, payload) {
|
||||||
|
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||||||
|
const pending = pendingForPallet();
|
||||||
|
if (pending) {
|
||||||
|
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
|
||||||
|
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
const operation = {
|
||||||
|
action,
|
||||||
|
palletId: scannedPallet.value.palletId,
|
||||||
|
palletCode: scannedPallet.value.palletCode,
|
||||||
|
tunnelName: selectedTunnelName.value,
|
||||||
|
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
|
||||||
|
};
|
||||||
|
savePendingOperations([...pendingOperations.value, operation]);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeOperation(operation, response) {
|
||||||
|
const result = response?.data;
|
||||||
|
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
|
||||||
|
const confirmed = result && (operation.action === "usage"
|
||||||
|
? Number.isInteger(result.usageId) && result.usageId > 0 &&
|
||||||
|
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
|
||||||
|
typeof result.flowState === "string" && result.flowState.trim()
|
||||||
|
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
|
||||||
|
typeof result.traceNo === "string" && result.traceNo.trim());
|
||||||
|
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
|
||||||
|
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||||||
|
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOperation(session, scopeKey) {
|
||||||
|
try {
|
||||||
|
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatedFifoSkipReason() {
|
||||||
|
const reason = form.fifoSkipReason.trim();
|
||||||
|
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
|
||||||
|
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFifoRejection(error, operation) {
|
||||||
|
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
|
||||||
|
const response = error?.response;
|
||||||
|
const body = response?.data && typeof response.data === "object" ? response.data : response;
|
||||||
|
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
|
||||||
|
try {
|
||||||
|
if (!loadPendingOperations()) return true;
|
||||||
|
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
|
||||||
|
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
|
||||||
|
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
|
||||||
|
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||||
|
} catch (storageError) {
|
||||||
|
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
|
||||||
|
fifoRequiresSkipReason.value = true;
|
||||||
|
loadFifoRecommendation();
|
||||||
|
} else {
|
||||||
|
recoveryLoading.value = false;
|
||||||
|
handleUsageScan(operation.payload.qrToken, operation);
|
||||||
|
}
|
||||||
|
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
|
||||||
|
submitError.value = operationError.value;
|
||||||
|
recoveryMessage.value = operationError.value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releasePendingOperation(item) {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
const session = operationSession;
|
||||||
|
const scopeKey = recoveryStorageKey.value;
|
||||||
|
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
|
||||||
|
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||||||
|
if (!operation) return;
|
||||||
|
recoveryLoading.value = true;
|
||||||
|
try {
|
||||||
|
const confirmed = await new Promise((resolve, reject) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "解除本机待确认记录",
|
||||||
|
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确认解除",
|
||||||
|
success: (result) => resolve(Boolean(result.confirm)),
|
||||||
|
fail: reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
|
||||||
|
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
|
||||||
|
if (!loadPendingOperations()) return;
|
||||||
|
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||||
|
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
|
||||||
|
resetUsageOperation();
|
||||||
|
}
|
||||||
|
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryPendingOperation(item) {
|
||||||
|
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
|
||||||
|
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||||||
|
if (!operation) return;
|
||||||
|
resetUsageOperation();
|
||||||
|
const session = operationSession;
|
||||||
|
const scopeKey = recoveryStorageKey.value;
|
||||||
|
recoveryLoading.value = true;
|
||||||
|
recoveryMessage.value = "";
|
||||||
|
try {
|
||||||
|
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
const result = completeOperation(operation, response);
|
||||||
|
recoveryMessage.value = operation.action === "usage"
|
||||||
|
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
|
||||||
|
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
|
||||||
|
loadUsageList();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
if (handleFifoRejection(error, operation)) return;
|
||||||
|
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsageList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listUsage({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
usageList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
usageList.value = [];
|
||||||
|
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUsageOperation() {
|
||||||
|
operationSession += 1;
|
||||||
|
scanGeneration += 1;
|
||||||
|
optionGeneration += 1;
|
||||||
|
fifoGeneration += 1;
|
||||||
|
scannedPallet.value = null;
|
||||||
|
usageResult.value = null;
|
||||||
|
lineOptions.value = [];
|
||||||
|
tunnelOptions.value = [];
|
||||||
|
lineLocked.value = false;
|
||||||
|
tunnelLocked.value = false;
|
||||||
|
form.qrToken = "";
|
||||||
|
form.tunnelId = null;
|
||||||
|
form.lineId = null;
|
||||||
|
form.usedPackages = "";
|
||||||
|
form.fifoSkipReason = "";
|
||||||
|
operationError.value = "";
|
||||||
|
submitError.value = "";
|
||||||
|
scanLoading.value = false;
|
||||||
|
optionLoading.value = false;
|
||||||
|
lineOptionLoading.value = false;
|
||||||
|
lineOptionError.value = "";
|
||||||
|
fifoLoading.value = false;
|
||||||
|
fifoMessage.value = "";
|
||||||
|
fifoRequiresSkipReason.value = false;
|
||||||
|
submitLoading.value = false;
|
||||||
|
entryLoading.value = false;
|
||||||
|
recoveryLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsageOptions(pallet, session, scanRequestGeneration) {
|
||||||
|
const generation = ++optionGeneration;
|
||||||
|
const isCurrent = () => generation === optionGeneration &&
|
||||||
|
session === operationSession && scanRequestGeneration === scanGeneration;
|
||||||
|
optionLoading.value = true;
|
||||||
|
lineOptionLoading.value = true;
|
||||||
|
const lineRequest = pallet.currentLineId == null
|
||||||
|
? getUsageLineOptions(pallet.siteId)
|
||||||
|
: Promise.resolve({ data: lineOptions.value });
|
||||||
|
getUsageTunnelOptions(pallet.siteId)
|
||||||
|
.then((tunnels) => {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
|
||||||
|
if (!tunnelLocked.value && !tunnelOptions.value.length) {
|
||||||
|
operationError.value = "该现场暂无可用隧道";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isCurrent()) optionLoading.value = false;
|
||||||
|
});
|
||||||
|
lineRequest
|
||||||
|
.then((lines) => {
|
||||||
|
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
lineOptions.value = [];
|
||||||
|
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isCurrent()) lineOptionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFifoRecommendation() {
|
||||||
|
if (!scannedPallet.value || !form.qrToken) return;
|
||||||
|
const generation = ++fifoGeneration;
|
||||||
|
const session = operationSession;
|
||||||
|
const scopeKey = recoveryStorageKey.value;
|
||||||
|
const { qrToken, tunnelId } = form;
|
||||||
|
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
|
||||||
|
qrToken === form.qrToken && tunnelId === form.tunnelId;
|
||||||
|
fifoLoading.value = true;
|
||||||
|
fifoMessage.value = "";
|
||||||
|
try {
|
||||||
|
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
|
||||||
|
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
|
||||||
|
} catch {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) fifoLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUsageScan(qrToken, rejectedOperation = null) {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
resetUsageOperation();
|
||||||
|
if (!loadPendingOperations()) return;
|
||||||
|
const session = operationSession;
|
||||||
|
const scopeKey = recoveryStorageKey.value;
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanUsagePallet(qrToken)
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (
|
||||||
|
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
|
||||||
|
pallet.remainingPackages <= 0
|
||||||
|
) {
|
||||||
|
operationError.value = "该托盘当前不可记录使用";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallet.value = {
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
siteId: pallet.siteId,
|
||||||
|
currentLineId: pallet.currentLineId,
|
||||||
|
tunnelId: pallet.tunnelId,
|
||||||
|
tunnelName: pallet.tunnelName,
|
||||||
|
tunnelCode: pallet.tunnelCode,
|
||||||
|
strainCode: pallet.strainCode,
|
||||||
|
strainName: pallet.strainName,
|
||||||
|
batchNo: pallet.batchNo,
|
||||||
|
productionDate: pallet.productionDate,
|
||||||
|
expiryDate: pallet.expiryDate,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
};
|
||||||
|
form.qrToken = pallet.qrToken || qrToken;
|
||||||
|
form.tunnelId = pallet.tunnelId ?? null;
|
||||||
|
tunnelLocked.value = pallet.tunnelId != null;
|
||||||
|
if (rejectedOperation) {
|
||||||
|
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
|
||||||
|
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
|
||||||
|
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
|
||||||
|
fifoRequiresSkipReason.value = true;
|
||||||
|
}
|
||||||
|
if (pallet.currentLineId != null) {
|
||||||
|
lineLocked.value = true;
|
||||||
|
form.lineId = pallet.currentLineId;
|
||||||
|
lineOptions.value = [
|
||||||
|
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
lineLocked.value = false;
|
||||||
|
form.lineId = rejectedOperation?.payload.lineId ?? null;
|
||||||
|
}
|
||||||
|
loadUsageOptions(pallet, session, generation);
|
||||||
|
loadFifoRecommendation();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||||
|
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||||
|
scanLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
resetUsageOperation();
|
||||||
|
operationError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLineChange(event) {
|
||||||
|
if (lineLocked.value || operationPending.value) return;
|
||||||
|
const option = linePickerOptions.value[Number(event.detail.value)];
|
||||||
|
form.lineId = option?.id ?? null;
|
||||||
|
markPayloadChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTunnelChange(event) {
|
||||||
|
if (tunnelLocked.value || operationPending.value) return;
|
||||||
|
const option = tunnelOptions.value[Number(event.detail.value)];
|
||||||
|
if (form.tunnelId === (option?.id ?? null)) return;
|
||||||
|
form.tunnelId = option?.id ?? null;
|
||||||
|
markPayloadChanged();
|
||||||
|
fifoRequiresSkipReason.value = false;
|
||||||
|
loadFifoRecommendation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPayloadChanged() {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
submitError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterTunnel() {
|
||||||
|
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
|
||||||
|
if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
|
||||||
|
operationError.value = "请先扫描托盘并选择隧道";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fifoSkipReason;
|
||||||
|
try {
|
||||||
|
fifoSkipReason = validatedFifoSkipReason();
|
||||||
|
} catch (error) {
|
||||||
|
operationError.value = error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = operationSession;
|
||||||
|
entryLoading.value = true;
|
||||||
|
operationError.value = "";
|
||||||
|
let scopeKey = recoveryStorageKey.value;
|
||||||
|
let operation;
|
||||||
|
try {
|
||||||
|
operation = prepareOperation("entry", {
|
||||||
|
qrToken: form.qrToken,
|
||||||
|
tunnelId: form.tunnelId,
|
||||||
|
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||||||
|
});
|
||||||
|
scopeKey = recoveryStorageKey.value;
|
||||||
|
const response = await enterUsageTunnel({ ...operation.payload });
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
const result = completeOperation(operation, response);
|
||||||
|
form.tunnelId = result.tunnelId;
|
||||||
|
scannedPallet.value.tunnelId = result.tunnelId;
|
||||||
|
tunnelLocked.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
|
||||||
|
loadFifoRecommendation();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
if (handleFifoRejection(error, operation)) return;
|
||||||
|
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUsage() {
|
||||||
|
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
|
||||||
|
const usedPackages = Number(form.usedPackages);
|
||||||
|
if (!scannedPallet.value || !form.qrToken) {
|
||||||
|
submitError.value = "请先扫描待使用托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tunnelLocked.value || !form.tunnelId) {
|
||||||
|
submitError.value = "请先选择隧道并确认入隧";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||||||
|
submitError.value = "使用包数必须为正整数";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fifoSkipReason;
|
||||||
|
try {
|
||||||
|
fifoSkipReason = validatedFifoSkipReason();
|
||||||
|
} catch (error) {
|
||||||
|
submitError.value = error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loadPendingOperations()) {
|
||||||
|
submitError.value = recoveryError.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
|
||||||
|
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
|
||||||
|
submitError.value = "使用包数不能超过当前剩余包数";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = operationSession;
|
||||||
|
submitLoading.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
const scopeKey = recoveryStorageKey.value;
|
||||||
|
let operation;
|
||||||
|
try {
|
||||||
|
operation = prepareOperation("usage", {
|
||||||
|
qrToken: form.qrToken,
|
||||||
|
tunnelId: form.tunnelId,
|
||||||
|
lineId: form.lineId,
|
||||||
|
usedPackages,
|
||||||
|
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||||||
|
});
|
||||||
|
const response = await addUsage({ ...operation.payload });
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
usageResult.value = completeOperation(operation, response);
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
|
||||||
|
loadUsageList();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentOperation(session, scopeKey)) return;
|
||||||
|
if (handleFifoRejection(error, operation)) return;
|
||||||
|
submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
|
||||||
|
} finally {
|
||||||
|
if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveUsagePage() {
|
||||||
|
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
|
||||||
|
resetUsageOperation();
|
||||||
|
listGeneration += 1;
|
||||||
|
loading.value = false;
|
||||||
|
usageList.value = [];
|
||||||
|
listError.value = "";
|
||||||
|
recoveryMessage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onHide(leaveUsagePage);
|
||||||
|
onUnload(leaveUsagePage);
|
||||||
|
onShow(() => {
|
||||||
|
loadPendingOperations();
|
||||||
|
loadUsageList();
|
||||||
|
});
|
||||||
|
loadPendingOperations();
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<view
|
<view class="login-page">
|
||||||
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"
|
<image
|
||||||
>
|
class="login-background"
|
||||||
<!-- Logo Section -->
|
src="/static/login-background.jpg"
|
||||||
<view class="mb-6 flex flex-col items-center">
|
mode="aspectFill"
|
||||||
<view
|
/>
|
||||||
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
|
<view class="login-scrim" />
|
||||||
>
|
|
||||||
<image
|
|
||||||
class="size-10"
|
|
||||||
:src="globalConfig.appInfo.logo"
|
|
||||||
mode="widthFix"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
|
||||||
>智链云枢管理系统登录</text
|
|
||||||
>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Form Section -->
|
<view class="login-content">
|
||||||
<view class="w-full rounded-3xl bg-white/80 p-6 shadow-xl backdrop-blur-md">
|
<view class="login-card">
|
||||||
<!-- Username -->
|
<view class="brand-lockup">
|
||||||
<view class="group relative mb-5">
|
<view class="brand-logo-frame">
|
||||||
<view
|
<image
|
||||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
class="brand-logo"
|
||||||
>
|
:src="globalConfig.appInfo.logo"
|
||||||
<view class="i-mdi-account text-xl"></view>
|
mode="aspectFit"
|
||||||
</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>
|
</view>
|
||||||
<input
|
<text class="brand-title">{{ globalConfig.appInfo.name }}</text>
|
||||||
v-model="loginForm.code"
|
<text class="brand-subtitle">仓储运营管理平台</text>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
<view
|
|
||||||
class="h-12 w-28 overflow-hidden rounded-xl bg-gray-100 shadow-sm transition-opacity active:opacity-80"
|
<view class="login-form">
|
||||||
@click="getCode"
|
<view class="field-control">
|
||||||
>
|
<view class="field-icon i-mdi-account" />
|
||||||
<image :src="codeUrl" class="size-full object-cover"></image>
|
<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>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Login Button -->
|
<view v-if="register" class="register-tip">
|
||||||
<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">
|
|
||||||
<text>没有账号?</text>
|
<text>没有账号?</text>
|
||||||
<text
|
<text class="action-link" @click="handleUserRegister">立即注册</text>
|
||||||
@click="handleUserRegister"
|
|
||||||
class="ml-1 font-medium text-blue-600 active:opacity-70"
|
|
||||||
>立即注册</text
|
|
||||||
>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view
|
<view class="agreement-tip">
|
||||||
class="flex flex-wrap items-center justify-center text-xs text-gray-400"
|
|
||||||
>
|
|
||||||
<text>登录即代表同意</text>
|
<text>登录即代表同意</text>
|
||||||
<text
|
<text class="action-link" @click="handleUserAgrement">《用户协议》</text>
|
||||||
@click="handleUserAgrement"
|
|
||||||
class="mx-1 text-blue-500 active:opacity-70"
|
|
||||||
>《用户协议》</text
|
|
||||||
>
|
|
||||||
<text>和</text>
|
<text>和</text>
|
||||||
<text
|
<text class="action-link" @click="handlePrivacy">《隐私协议》</text>
|
||||||
@click="handlePrivacy"
|
|
||||||
class="mx-1 text-blue-500 active:opacity-70"
|
|
||||||
>《隐私协议》</text
|
|
||||||
>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -129,13 +105,14 @@ import { useConfigStore, useUserStore } from "@/store";
|
|||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const globalConfig = useConfigStore().config;
|
const globalConfig = useConfigStore().config;
|
||||||
const codeUrl = ref("");
|
const codeUrl = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
// 验证码开关
|
// 验证码开关
|
||||||
const captchaEnabled = ref(true);
|
const captchaEnabled = ref(true);
|
||||||
// 用户注册开关
|
// 用户注册开关
|
||||||
const register = ref(false);
|
const register = ref(false);
|
||||||
const loginForm = ref({
|
const loginForm = ref({
|
||||||
username: "admin",
|
username: "admin",
|
||||||
password: "admin123",
|
password: "",
|
||||||
code: "",
|
code: "",
|
||||||
uuid: "",
|
uuid: "",
|
||||||
});
|
});
|
||||||
@@ -160,6 +137,8 @@ function getCode() {
|
|||||||
getCodeImg().then((res) => {
|
getCodeImg().then((res) => {
|
||||||
captchaEnabled.value =
|
captchaEnabled.value =
|
||||||
res.captchaEnabled === undefined ? true : res.captchaEnabled;
|
res.captchaEnabled === undefined ? true : res.captchaEnabled;
|
||||||
|
register.value =
|
||||||
|
res.registerEnabled === undefined ? false : res.registerEnabled;
|
||||||
if (captchaEnabled.value) {
|
if (captchaEnabled.value) {
|
||||||
codeUrl.value = "data:image/gif;base64," + res.img;
|
codeUrl.value = "data:image/gif;base64," + res.img;
|
||||||
loginForm.value.uuid = res.uuid;
|
loginForm.value.uuid = res.uuid;
|
||||||
@@ -176,6 +155,7 @@ async function handleLogin() {
|
|||||||
} else if (loginForm.value.code === "" && captchaEnabled.value) {
|
} else if (loginForm.value.code === "" && captchaEnabled.value) {
|
||||||
proxy.$modal.msgError("请输入验证码");
|
proxy.$modal.msgError("请输入验证码");
|
||||||
} else {
|
} else {
|
||||||
|
loading.value = true;
|
||||||
proxy.$modal.loading("登录中,请耐心等待...");
|
proxy.$modal.loading("登录中,请耐心等待...");
|
||||||
pwdLogin();
|
pwdLogin();
|
||||||
}
|
}
|
||||||
@@ -183,25 +163,26 @@ async function handleLogin() {
|
|||||||
|
|
||||||
// 密码登录
|
// 密码登录
|
||||||
async function pwdLogin() {
|
async function pwdLogin() {
|
||||||
useUserStore()
|
try {
|
||||||
.login(loginForm.value)
|
await useUserStore().login(loginForm.value);
|
||||||
.then(() => {
|
proxy.$modal.closeLoading();
|
||||||
proxy.$modal.closeLoading();
|
loading.value = false;
|
||||||
loginSuccess();
|
loginSuccess();
|
||||||
})
|
} catch (error) {
|
||||||
.catch(() => {
|
proxy.$modal.closeLoading();
|
||||||
if (captchaEnabled.value) {
|
loading.value = false;
|
||||||
getCode();
|
if (captchaEnabled.value) {
|
||||||
}
|
getCode();
|
||||||
});
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功后,处理函数
|
// 登录成功后,处理函数
|
||||||
function loginSuccess(result) {
|
function loginSuccess() {
|
||||||
// 设置用户信息
|
// 设置用户信息
|
||||||
useUserStore()
|
useUserStore()
|
||||||
.getInfo()
|
.getInfo()
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
proxy.$tab.reLaunch("/pages/index");
|
proxy.$tab.reLaunch("/pages/index");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -219,6 +200,239 @@ getCode();
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
page {
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
<view class="flex h-full flex-col bg-gray-50 pb-10">
|
<view class="flex h-full flex-col bg-gray-50 pb-10">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<view class="flex flex-col items-center py-10">
|
<view class="flex flex-col items-center py-10">
|
||||||
<image
|
<view class="flex h-24 w-64 items-center justify-center rounded-xl bg-gray-800 px-4 shadow-md">
|
||||||
class="size-20 rounded-xl shadow-md"
|
<image
|
||||||
src="/static/logo200.png"
|
class="h-20 w-full"
|
||||||
mode="widthFix"
|
src="/static/sylvan-logo.png"
|
||||||
/>
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
<text class="mt-4 text-xl font-bold text-gray-800"
|
<text class="mt-4 text-xl font-bold text-gray-800"
|
||||||
>智链云枢管理系统</text
|
>智链云枢管理系统</text
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<!-- Logo Section -->
|
<!-- Logo Section -->
|
||||||
<view class="mb-6 flex flex-col items-center">
|
<view class="mb-6 flex flex-col items-center">
|
||||||
<view
|
<view
|
||||||
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
|
class="mb-4 flex h-20 w-60 items-center justify-center rounded-2xl bg-gray-800 px-4 shadow-lg"
|
||||||
>
|
>
|
||||||
<image
|
<image
|
||||||
class="size-10"
|
class="h-16 w-full"
|
||||||
:src="globalConfig.appInfo.logo"
|
:src="globalConfig.appInfo.logo"
|
||||||
mode="widthFix"
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||||
|
|||||||
@@ -1,199 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
|
<view class="work-page">
|
||||||
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
|
<scroll-view
|
||||||
<view class="p-4 pb-24 space-y-6">
|
class="work-scroll"
|
||||||
<!-- Banner -->
|
scroll-y
|
||||||
<view
|
:show-scrollbar="false"
|
||||||
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
|
>
|
||||||
>
|
<view class="work-content">
|
||||||
<swiper
|
<view class="work-heading">
|
||||||
class="h-40 w-full"
|
<view class="work-heading-copy">
|
||||||
:current="swiperDotIndex"
|
<text class="work-kicker">业务工作台</text>
|
||||||
@change="changeSwiper"
|
<text class="work-subtitle">
|
||||||
autoplay
|
根据当前账号权限,快速进入现场业务流程
|
||||||
interval="3000"
|
</text>
|
||||||
circular
|
</view>
|
||||||
>
|
<view class="work-permission-count">
|
||||||
<swiper-item v-for="(item, index) in data" :key="index">
|
<text class="work-permission-value">{{ visibleBusinessEntries.length }}</text>
|
||||||
<view class="h-full w-full" @click="clickBannerItem(item)">
|
<text class="work-permission-label">项可用</text>
|
||||||
<image
|
|
||||||
:src="item.image"
|
|
||||||
mode="scaleToFill"
|
|
||||||
class="block h-full w-full"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</swiper-item>
|
|
||||||
</swiper>
|
|
||||||
<!-- Custom Dots -->
|
|
||||||
<view
|
|
||||||
class="absolute bottom-3 right-0 left-0 flex justify-center space-x-2 pointer-events-none"
|
|
||||||
>
|
|
||||||
<view
|
|
||||||
v-for="(item, index) in data"
|
|
||||||
:key="index"
|
|
||||||
class="h-1.5 rounded-full transition-all duration-300"
|
|
||||||
:class="current === index ? 'bg-white w-4' : 'bg-white/50 w-1.5'"
|
|
||||||
>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- AI Tools Section -->
|
<TestBatchModeSwitch />
|
||||||
<view>
|
|
||||||
<view class="mb-4 flex items-center space-x-2">
|
<view class="work-section">
|
||||||
<view class="h-4 w-1 rounded-full bg-indigo-500"></view>
|
<view class="work-section-header">
|
||||||
<text class="text-base font-bold text-gray-800">AI 生产力</text>
|
<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>
|
||||||
<view class="grid grid-cols-2 gap-4">
|
|
||||||
|
<view v-if="visibleBusinessEntries.length" class="work-entry-list">
|
||||||
<view
|
<view
|
||||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
|
v-for="entry in visibleBusinessEntries"
|
||||||
@click="handleToAiChat"
|
:key="entry.path"
|
||||||
|
class="work-entry"
|
||||||
|
@click="handleBusinessEntry(entry)"
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
class="absolute -right-4 -top-4 size-20 rounded-full bg-indigo-50 opacity-50"
|
class="work-entry-icon"
|
||||||
></view>
|
:class="entry.iconClass"
|
||||||
<view class="relative z-10 flex flex-col">
|
>
|
||||||
<view
|
<view :class="entry.icon" class="work-entry-icon-glyph"></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>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="work-entry-copy">
|
||||||
<view
|
<text class="work-entry-title">{{ entry.title }}</text>
|
||||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
|
<text class="work-entry-description">{{ entry.description }}</text>
|
||||||
@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>
|
</view>
|
||||||
|
<view class="i-mdi-chevron-right work-entry-arrow"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- System Management Section -->
|
<view v-else class="work-empty-state">
|
||||||
<view>
|
<view class="work-empty-icon">
|
||||||
<view class="mb-4 flex items-center space-x-2">
|
<view class="i-mdi-lock-outline work-empty-icon-glyph"></view>
|
||||||
<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>
|
</view>
|
||||||
|
<text class="work-empty-title">暂无已授权业务</text>
|
||||||
|
<text class="work-empty-description">请联系管理员分配业务菜单权限</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -202,34 +65,297 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance } from "vue";
|
import { computed, getCurrentInstance } from "vue";
|
||||||
|
import TestBatchModeSwitch from "@/components/business/TestBatchModeSwitch.vue";
|
||||||
|
import { businessMenuEntries } from "@/config/businessMenu";
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const current = ref(0);
|
|
||||||
const swiperDotIndex = ref(0);
|
|
||||||
const data = ref([
|
|
||||||
{ image: "/static/images/banner/banner01.jpg" },
|
|
||||||
{ image: "/static/images/banner/banner02.jpg" },
|
|
||||||
{ image: "/static/images/banner/banner03.jpg" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
function clickBannerItem(item) {
|
const visibleBusinessEntries = computed(() =>
|
||||||
console.info(item);
|
businessMenuEntries.filter(canOpenBusinessEntry),
|
||||||
|
);
|
||||||
|
|
||||||
|
function canOpenBusinessEntry(entry) {
|
||||||
|
return entry.permissions
|
||||||
|
? proxy.$auth.hasPermiOr(entry.permissions)
|
||||||
|
: proxy.$auth.hasPermi(entry.permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSwiper(e) {
|
function handleBusinessEntry(entry) {
|
||||||
current.value = e.detail.current;
|
if (!canOpenBusinessEntry(entry)) {
|
||||||
}
|
proxy.$modal.msgError("当前账号没有该业务菜单权限");
|
||||||
|
return;
|
||||||
function handleToAiChat() {
|
}
|
||||||
handleBuilding();
|
proxy.$tab.navigateTo(entry.path);
|
||||||
}
|
|
||||||
|
|
||||||
function handleToUserList() {
|
|
||||||
handleBuilding();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBuilding() {
|
|
||||||
proxy.$modal.msg("模块建设中~");
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.work-page {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #f4f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-content {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 24rpx 28rpx calc(140rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-heading {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-heading-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-kicker {
|
||||||
|
display: block;
|
||||||
|
color: #28225c;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: #6c7786;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-permission-count {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: baseline;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
background: #e9f7f6;
|
||||||
|
color: #008596;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-permission-value {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-permission-label {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section-header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section-marker {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 38rpx;
|
||||||
|
flex: 0 0 8rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #008596;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section-title {
|
||||||
|
display: block;
|
||||||
|
color: #28225c;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section-description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #8b969d;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-list {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
border: 1rpx solid #e5eeee;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
background: #fbfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry:active {
|
||||||
|
border-color: #8ccbc5;
|
||||||
|
background: #f0faf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-icon {
|
||||||
|
display: flex;
|
||||||
|
width: 82rpx;
|
||||||
|
height: 82rpx;
|
||||||
|
flex: 0 0 82rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-icon-glyph {
|
||||||
|
font-size: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-title,
|
||||||
|
.work-entry-description {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-title {
|
||||||
|
color: #27313a;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-description {
|
||||||
|
margin-top: 7rpx;
|
||||||
|
color: #7b8791;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-arrow {
|
||||||
|
flex: 0 0 34rpx;
|
||||||
|
color: #a9b7bb;
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 70rpx 16rpx 54rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-empty-icon {
|
||||||
|
display: flex;
|
||||||
|
width: 84rpx;
|
||||||
|
height: 84rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f5;
|
||||||
|
color: #9aa5ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-empty-icon-glyph {
|
||||||
|
font-size: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-empty-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-empty-description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #9aa5ad;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 420px) {
|
||||||
|
.work-content {
|
||||||
|
padding-right: 20rpx;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-kicker {
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-section {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry {
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-icon {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
flex-basis: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-entry-icon-glyph {
|
||||||
|
font-size: 36rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 516.1 194.52">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #008596;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #91cdb9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #43b69e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #554d8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #50bcba;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7, .cls-8, .cls-9 {
|
||||||
|
fill: #28225c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-10 {
|
||||||
|
fill: #009a91;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-10, .cls-11 {
|
||||||
|
fill-rule: evenodd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-11 {
|
||||||
|
fill: #a5d7d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-9 {
|
||||||
|
opacity: .25;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="tekening">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M302.33,61.89l-18.13,63.56c-3.35,11.65-7.77,17.59-23.63,17.59-3.99,0-10.36-.86-13.06-1.73-1.19-.43-2.27-.76-2.27-1.94v-8.74c0-1.19,.97-2.05,2.16-2.05h.22c3.56,.21,9.93,.86,13.49,.86,3.88,0,5.83-1.29,7.12-5.61l.97-3.35h-1.62c-3.99,0-7.34-.65-10.04-10.04l-14.24-48.56c-.11-.32-.11-.54-.11-.76,0-1.08,.65-1.73,2.16-1.73h14.35c1.08,0,1.83,.97,2.05,2.05l9.71,40.58c.32,1.51,.65,2.05,1.29,2.05h.97l10.04-42.63c.21-1.08,1.08-2.05,2.27-2.05h14.35c1.08,0,2.05,.86,2.05,1.94,0,.22-.11,.33-.11,.54Z"/>
|
||||||
|
<path class="cls-2" d="M326.72,121.44h-13.17c-1.19,0-2.27-1.08-2.27-2.27V40.83c0-1.19,1.08-2.16,2.27-2.16h13.17c1.19,0,2.05,.97,2.05,2.16V119.17c0,1.19-.86,2.27-2.05,2.27Z"/>
|
||||||
|
<path class="cls-2" d="M396,61.89l-13.6,48.99c-2.7,9.6-9.28,11.33-15.65,11.33s-12.84-1.73-15.54-11.33l-13.49-48.99c-.11-.21-.11-.43-.11-.65,0-1.08,.76-1.84,1.73-1.84h14.24c1.08,0,1.94,.97,2.27,2.27l9.28,42.3c.33,1.62,.86,2.27,1.62,2.27s1.4-.65,1.73-2.27l9.28-42.3c.32-1.3,1.19-2.27,2.27-2.27h14.24c.97,0,1.84,.76,1.84,1.84,0,.21,0,.43-.11,.65Z"/>
|
||||||
|
<path class="cls-2" d="M418.66,121.3c-9.39,0-18.45-4.53-18.45-18.56v-.97c0-12.63,8.09-18.24,26.55-18.24h5.83v-3.89c0-6.15-6.78-7.47-11.85-7.47,0,0-17.96,1.95-17.96-12.69,0,0,16.18-.15,23.3-.15,15.54,0,23.99,4.55,23.99,20.31v39.49c0,1.19-.97,2.16-2.16,2.16h-29.24Zm13.89-27.41h-6.15c-7.56,0-9.5,2.59-9.5,7.88v.97c0,4.53,2.27,5.39,6.8,5.39,3.24,0,8.68,0,8.68,0l.17-14.25Z"/>
|
||||||
|
<path class="cls-2" d="M497.01,58.61c15.65,0,19.1,10.9,19.1,24.5v35.93c0,1.19-1.08,2.27-2.27,2.27h-13.06c-1.19,0-2.16-1.08-2.16-2.27v-35.93c-.1-6.03,.45-8.65-6.13-8.65-3.22,0-8.33,.12-11.99,2.06v42.52c0,1.19-.97,2.27-2.16,2.27h-13.06c-1.19,0-2.27-1.08-2.27-2.27V61.63c0-1.19,.97-2.16,2.16-2.16l31.83-.86Z"/>
|
||||||
|
<path class="cls-2" d="M184.25,121.47c0-17.9,16.39-18.14,16.39-18.14,8.24,0,17.54-.5,17.98-6.18,.24-3.16-.41-4.44-6.02-7.82l-15.23-8.96c-10.04-6.01-12.73-12.89-12.73-20.87,0-12.02,6.69-21.3,27.63-21.3,1.66,0,23.24,.05,23.26,.07-.13,.14,.12,2.04,.12,3.24,0,14.93-20.16,13.71-20.16,13.71-6.15,0-9.85,2.38-9.85,6.31,0,3.06,1.26,4.7,7.09,7.87l13.81,6.38c11.22,6.12,12.5,12.79,12.5,21.64,0,11.58-6.73,24.27-24.96,24.11l-29.81-.05Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M365.04,163.65h-1.67c-.32,0-.58-.29-.58-.64v-22.95c0-.32,.23-.61,.58-.61h1.67c.32,0,.58,.26,.58,.61v22.95c0,.32-.23,.64-.58,.64Z"/>
|
||||||
|
<path class="cls-2" d="M373.88,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M393.77,146.72l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.34-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
|
||||||
|
<path class="cls-2" d="M400.81,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M420.03,163.65h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M440.07,148.1l-1.16,.22c.58,.61,1.09,1.58,1.09,3.32v.26c0,3.54-2.19,5.5-7.15,5.5-1.71,0-2.8-.26-3.73-.64-.55,.26-.8,.61-.8,1.09,0,.55,.26,.84,1,1.03l4.8,1.22c3.15,.8,6.18,1.71,6.18,4.73v.26c0,3.09-2.06,4.99-7.34,4.99s-7.4-1.96-7.4-5.12v-.29c0-1.8,.97-3.28,2.03-4.09l-.23-.1c-1.03-.45-1.48-1.51-1.48-2.51v-.03c0-.87,.42-1.8,1.32-2.32-1-.93-1.48-2.09-1.48-3.73v-.26c0-2.86,1.61-5.7,7.14-5.7h7.05c.39,0,.74,.32,.74,.71v.84c0,.42-.23,.55-.58,.61Zm-2.61,16.74c0-1.26-1.03-1.77-3.93-2.51l-3.64-.93c-.93,.8-1.58,1.61-1.58,3.28v.29c0,1.8,1.06,2.93,4.63,2.93s4.51-1.03,4.51-2.8v-.26Zm-.26-13.2c0-1.93-.87-3.44-4.35-3.44s-4.34,1.64-4.34,3.44v.26c0,1.42,.64,3.28,4.34,3.28,3.48,0,4.35-1.35,4.35-3.28v-.26Z"/>
|
||||||
|
<path class="cls-2" d="M365.07,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M384.28,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M403.17,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M415.71,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
|
||||||
|
<path class="cls-2" d="M440.52,177.36l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.35-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
|
||||||
|
<path class="cls-2" d="M455.97,194.29h-1.48c-.35,0-.64-.29-.64-.64v-.71c-1.54,.9-3.15,1.58-4.96,1.58-2.74,0-5.25-1.19-5.25-5.21v-.39c0-3.48,2.03-5.21,7.76-5.21h2.38v-1.29c0-2.7-.93-3.64-3.44-3.64-2.09,0-3.64,.13-4.92,.26h-.16c-.29,0-.58-.1-.58-.51v-1.13c0-.35,.23-.55,.68-.61,1.32-.23,2.8-.42,4.99-.42,4.47,0,6.28,2.25,6.28,6.05v11.23c0,.35-.29,.64-.64,.64Zm-2.19-8.56h-2.38c-3.9,0-4.93,.87-4.93,3.15v.39c0,2.32,1.09,2.93,2.96,2.93,1.48,0,2.8-.45,4.35-1.29v-5.18Z"/>
|
||||||
|
<path class="cls-2" d="M469.99,194.36c-.64,.1-1.48,.16-2.09,.16-2.96,0-4.38-.77-4.38-4.31v-11.23l-3.12-.35c-.35-.03-.61-.22-.61-.58v-.87c0-.35,.26-.58,.61-.58h3.12v-3.15c0-.32,.22-.58,.58-.64l1.67-.26h.1c.26,0,.48,.22,.48,.55v3.51h3.48c.35,0,.64,.29,.64,.64v1.09c0,.35-.29,.64-.64,.64h-3.48v11.23c0,1.77,.45,2.03,1.74,2.03h1.8c.45,0,.68,.13,.68,.45v1.13c0,.29-.16,.48-.58,.55Z"/>
|
||||||
|
<path class="cls-2" d="M477.14,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
<path class="cls-2" d="M490.01,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
|
||||||
|
<path class="cls-2" d="M514.85,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-11" d="M57.78,67.44c-.03,5.02-.86,9.86-2.35,14.39,0,0,.01,.01,.02,.02,0,0,0,0,.01,0,10.82-31.09,38.25-54.41,71.67-59.3C130.83,15.86,132.95,8.18,132.95,0h0s-46.67,0-46.67,0H46.67C30.78,0,16.76,7.94,8.33,20.07c.88-.04,1.76-.07,2.65-.06,26.02,.17,46.98,21.41,46.8,47.43Z"/>
|
||||||
|
<path class="cls-10" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
|
||||||
|
<path class="cls-6" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
|
||||||
|
<path class="cls-7" d="M55.46,81.85s-.01-.01-.02-.02c0,0,0,0,0,.01,0,0,.02,0,.02,0Z"/>
|
||||||
|
<path class="cls-10" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
|
||||||
|
<path class="cls-8" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
|
||||||
|
<path class="cls-7" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
|
||||||
|
<path class="cls-8" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
|
||||||
|
<path class="cls-10" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
|
||||||
|
<path class="cls-3" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
|
||||||
|
<path class="cls-10" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||||
|
<path class="cls-8" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||||
|
<path class="cls-9" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||||
|
<polygon class="cls-7" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||||
|
<polygon class="cls-8" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||||
|
<polygon class="cls-9" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||||
|
<path class="cls-10" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||||
|
<path class="cls-9" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||||
|
<path class="cls-4" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||||
|
<path class="cls-10" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||||
|
<path class="cls-8" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||||
|
<path class="cls-9" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||||
|
<path class="cls-1" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||||
|
<path class="cls-5" d="M55.47,81.86s0,0,0,0c-.02,0-.03,0-.05,.01,0-.01,0-.02,.01-.03,0,0,0,0,0-.01-9.09-10.29-21.81-17.47-36.5-19.35-5.49-.7-10.89-.6-16.11,.17,2.93,8.02,7.98,15.01,14.5,20.29,5.11,4.14,11.12,7.22,17.69,8.91,2.45,.63,4.96,1.08,7.55,1.3,.24,.02,.24,.34,0,.36-4.74,.41-9.26,1.54-13.49,3.27-8.71,3.55-16.1,9.65-21.25,17.38-4.93,7.4-7.82,16.28-7.82,25.84H0s8.74,0,8.74,0h57.85c1.51-3.77,2.58-7.74,3.11-11.86,2.23-17.44-3.45-34.06-14.23-46.28Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,9 +1,10 @@
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import { useUserStore } from "./modules/user";
|
import { useUserStore } from "./modules/user";
|
||||||
import { useConfigStore } from "./modules/config";
|
import { useConfigStore } from "./modules/config";
|
||||||
|
import { useTestBatchStore } from "./modules/testBatch";
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
|
||||||
export default pinia;
|
export default pinia;
|
||||||
|
|
||||||
export { useUserStore, useConfigStore };
|
export { useUserStore, useConfigStore, useTestBatchStore };
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { listAvailableTestBatches } from "@/api/system/testBatch";
|
||||||
|
import storage from "@/utils/storage";
|
||||||
|
import constant from "@/utils/constant";
|
||||||
|
|
||||||
|
export const useTestBatchStore = defineStore("testBatch", () => {
|
||||||
|
const options = ref([]);
|
||||||
|
const currentBatchId = ref(String(storage.get(constant.testBatchId) || ""));
|
||||||
|
const currentBatchName = ref(storage.get(constant.testBatchName) || "");
|
||||||
|
const scopeVersion = ref(0);
|
||||||
|
const isTestMode = computed(() => Boolean(currentBatchId.value));
|
||||||
|
|
||||||
|
function leaveTestMode() {
|
||||||
|
currentBatchId.value = "";
|
||||||
|
currentBatchName.value = "";
|
||||||
|
storage.remove(constant.testBatchId);
|
||||||
|
storage.remove(constant.testBatchName);
|
||||||
|
scopeVersion.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOptions() {
|
||||||
|
try {
|
||||||
|
const response = await listAvailableTestBatches();
|
||||||
|
const available = (Array.isArray(response.data) ? response.data : []).filter(
|
||||||
|
(item) => item && item.status === "ACTIVE" && item.batchId != null,
|
||||||
|
);
|
||||||
|
options.value = available;
|
||||||
|
|
||||||
|
const current = available.find(
|
||||||
|
(item) => String(item.batchId) === String(currentBatchId.value),
|
||||||
|
);
|
||||||
|
if (current) {
|
||||||
|
currentBatchId.value = String(current.batchId);
|
||||||
|
currentBatchName.value = current.batchName || "";
|
||||||
|
storage.set(constant.testBatchId, currentBatchId.value);
|
||||||
|
storage.set(constant.testBatchName, currentBatchName.value);
|
||||||
|
} else if (currentBatchId.value) {
|
||||||
|
// 本地缓存不能代替后端授权;选项接口未返回当前批次时立即回到正式模式。
|
||||||
|
leaveTestMode();
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
} catch (error) {
|
||||||
|
options.value = [];
|
||||||
|
leaveTestMode();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBatch(batch) {
|
||||||
|
const allowed = options.value.find(
|
||||||
|
(item) =>
|
||||||
|
String(item.batchId) === String(batch?.batchId) && item.status === "ACTIVE",
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new Error("所选测试批次当前不可用");
|
||||||
|
}
|
||||||
|
currentBatchId.value = String(allowed.batchId);
|
||||||
|
currentBatchName.value = allowed.batchName || "";
|
||||||
|
storage.set(constant.testBatchId, currentBatchId.value);
|
||||||
|
storage.set(constant.testBatchName, currentBatchName.value);
|
||||||
|
scopeVersion.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
options.value = [];
|
||||||
|
leaveTestMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
currentBatchId,
|
||||||
|
currentBatchName,
|
||||||
|
scopeVersion,
|
||||||
|
isTestMode,
|
||||||
|
refreshOptions,
|
||||||
|
selectBatch,
|
||||||
|
leaveTestMode,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useTestBatchStore;
|
||||||
@@ -15,6 +15,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
const id = ref(storage.get(constant.id));
|
const id = ref(storage.get(constant.id));
|
||||||
const name = ref(storage.get(constant.name));
|
const name = ref(storage.get(constant.name));
|
||||||
const avatar = ref(storage.get(constant.avatar));
|
const avatar = ref(storage.get(constant.avatar));
|
||||||
|
const admin = ref(storage.get(constant.admin) === true || storage.get(constant.admin) === "true");
|
||||||
const roles = ref(storage.get(constant.roles));
|
const roles = ref(storage.get(constant.roles));
|
||||||
const permissions = ref(storage.get(constant.permissions));
|
const permissions = ref(storage.get(constant.permissions));
|
||||||
|
|
||||||
@@ -33,6 +34,10 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
avatar.value = val;
|
avatar.value = val;
|
||||||
storage.set(constant.avatar, val);
|
storage.set(constant.avatar, val);
|
||||||
};
|
};
|
||||||
|
const SET_ADMIN = (val) => {
|
||||||
|
admin.value = val === true;
|
||||||
|
storage.set(constant.admin, admin.value);
|
||||||
|
};
|
||||||
const SET_ROLES = (val) => {
|
const SET_ROLES = (val) => {
|
||||||
roles.value = val;
|
roles.value = val;
|
||||||
storage.set(constant.roles, val);
|
storage.set(constant.roles, val);
|
||||||
@@ -81,6 +86,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
} else {
|
} else {
|
||||||
SET_ROLES(["ROLE_DEFAULT"]);
|
SET_ROLES(["ROLE_DEFAULT"]);
|
||||||
}
|
}
|
||||||
|
SET_ADMIN(user.admin === true);
|
||||||
SET_ID(userid);
|
SET_ID(userid);
|
||||||
SET_NAME(username);
|
SET_NAME(username);
|
||||||
SET_AVATAR(avatar);
|
SET_AVATAR(avatar);
|
||||||
@@ -115,6 +121,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
avatar,
|
avatar,
|
||||||
|
admin,
|
||||||
roles,
|
roles,
|
||||||
permissions,
|
permissions,
|
||||||
SET_AVATAR,
|
SET_AVATAR,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ const constant = {
|
|||||||
avatar: "user_avatar",
|
avatar: "user_avatar",
|
||||||
id: "user_id",
|
id: "user_id",
|
||||||
name: "user_name",
|
name: "user_name",
|
||||||
|
admin: "user_admin",
|
||||||
roles: "user_roles",
|
roles: "user_roles",
|
||||||
permissions: "user_permissions",
|
permissions: "user_permissions",
|
||||||
|
testBatchId: "test_batch_id",
|
||||||
|
testBatchName: "test_batch_name",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default constant;
|
export default constant;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export function normalizeScanValue(value) {
|
||||||
|
const normalizedValue = String(value ?? "").trim();
|
||||||
|
if (!normalizedValue) {
|
||||||
|
throw new Error("二维码不能为空");
|
||||||
|
}
|
||||||
|
if (normalizedValue.length > 128) {
|
||||||
|
throw new Error("二维码长度不能超过 128 个字符");
|
||||||
|
}
|
||||||
|
return normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDuplicateScan(
|
||||||
|
value,
|
||||||
|
lastValue,
|
||||||
|
scannedAt,
|
||||||
|
lastScannedAt,
|
||||||
|
duplicateWindowMs,
|
||||||
|
) {
|
||||||
|
const elapsed = scannedAt - lastScannedAt;
|
||||||
|
return (
|
||||||
|
value === lastValue &&
|
||||||
|
elapsed >= 0 &&
|
||||||
|
elapsed <= duplicateWindowMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIdempotencyKey(
|
||||||
|
cryptoProvider =
|
||||||
|
typeof globalThis === "undefined" ? undefined : globalThis.crypto,
|
||||||
|
) {
|
||||||
|
if (typeof cryptoProvider?.randomUUID === "function") {
|
||||||
|
return cryptoProvider.randomUUID();
|
||||||
|
}
|
||||||
|
if (typeof cryptoProvider?.getRandomValues !== "function") {
|
||||||
|
throw new Error("当前环境不支持安全随机数,请使用 HTTPS 或 localhost 访问系统");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = cryptoProvider.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (byte) =>
|
||||||
|
byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { getToken } from "@/utils/auth";
|
import { getToken } from "@/utils/auth";
|
||||||
import errorCode from "@/utils/errorCode";
|
import errorCode from "@/utils/errorCode";
|
||||||
|
import constant from "@/utils/constant";
|
||||||
|
import storage from "@/utils/storage";
|
||||||
import { useUserStore } from "@/store/modules/user";
|
import { useUserStore } from "@/store/modules/user";
|
||||||
import { toast, showConfirm, tansParams } from "@/utils/common";
|
import { toast, showConfirm, tansParams } from "@/utils/common";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,37 @@ import {
|
|||||||
|
|
||||||
let timeout = 10000;
|
let timeout = 10000;
|
||||||
const baseUrl = config.baseUrl;
|
const baseUrl = config.baseUrl;
|
||||||
|
let testBatchRecoveryStarted = false;
|
||||||
|
|
||||||
|
function getResponseBody(payload) {
|
||||||
|
return payload?.data && typeof payload.data === "object" ? payload.data : payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponseData(payload) {
|
||||||
|
const body = getResponseBody(payload);
|
||||||
|
return body.data && typeof body.data === "object" ? body.data : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestBatchUnavailable(payload) {
|
||||||
|
return getResponseData(payload)?.reason === "TEST_BATCH_UNAVAILABLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverUnavailableTestBatch(payload) {
|
||||||
|
if (testBatchRecoveryStarted) return;
|
||||||
|
testBatchRecoveryStarted = true;
|
||||||
|
const body = getResponseBody(payload);
|
||||||
|
const message = body.msg || "当前测试批次不可用,已切回正式数据模式";
|
||||||
|
storage.remove(constant.testBatchId);
|
||||||
|
storage.remove(constant.testBatchName);
|
||||||
|
if (typeof uni.$emit === "function") {
|
||||||
|
uni.$emit("test-batch-unavailable", message);
|
||||||
|
}
|
||||||
|
uni.showToast({ title: message, icon: "none", duration: 3500 });
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
setTimeout(() => {
|
||||||
|
testBatchRecoveryStarted = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const request = async (config) => {
|
const request = async (config) => {
|
||||||
// 是否需要设置 token
|
// 是否需要设置 token
|
||||||
@@ -23,6 +56,10 @@ const request = async (config) => {
|
|||||||
if (getToken() && !isToken) {
|
if (getToken() && !isToken) {
|
||||||
config.header["Authorization"] = "Bearer " + getToken();
|
config.header["Authorization"] = "Bearer " + getToken();
|
||||||
}
|
}
|
||||||
|
const testBatchId = storage.get(constant.testBatchId);
|
||||||
|
if (getToken() && testBatchId) {
|
||||||
|
config.header["X-Test-Batch-Id"] = String(testBatchId);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
config = await encryptTransportRequest(config);
|
config = await encryptTransportRequest(config);
|
||||||
@@ -55,8 +92,18 @@ const request = async (config) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = res.data.code || 200;
|
if (isTestBatchUnavailable(res)) {
|
||||||
const msg = errorCode[code] || res.data.msg || errorCode["default"];
|
recoverUnavailableTestBatch(res);
|
||||||
|
const error = new Error(getResponseBody(res).msg || "当前测试批次不可用");
|
||||||
|
error.response = res;
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = getResponseBody(res);
|
||||||
|
const status = res.statusCode ?? res.status;
|
||||||
|
const code = body.code || 200;
|
||||||
|
const msg = errorCode[code] || body.msg || errorCode["default"];
|
||||||
if (code === 401) {
|
if (code === 401) {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
|
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
|
||||||
@@ -80,8 +127,13 @@ const request = async (config) => {
|
|||||||
const error = new Error(msg);
|
const error = new Error(msg);
|
||||||
error.response = res;
|
error.response = res;
|
||||||
reject(error);
|
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 {
|
} else {
|
||||||
resolve(res.data);
|
resolve(body);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -100,10 +152,15 @@ const request = async (config) => {
|
|||||||
return request(config);
|
return request(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTestBatchUnavailable(error?.response)) {
|
||||||
|
recoverUnavailableTestBatch(error.response);
|
||||||
|
}
|
||||||
|
|
||||||
const response = error.response;
|
const response = error.response;
|
||||||
const responseStatus = response?.status ?? response?.statusCode;
|
const responseStatus = response?.status ?? response?.statusCode;
|
||||||
const responseCode = response?.data?.code;
|
const responseBody = getResponseBody(response);
|
||||||
const responseMsg = response?.data?.msg;
|
const responseCode = responseBody?.code;
|
||||||
|
const responseMsg = responseBody?.msg;
|
||||||
if (responseMsg) {
|
if (responseMsg) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: responseMsg,
|
title: responseMsg,
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ let storageNodeKeys = [
|
|||||||
constant.avatar,
|
constant.avatar,
|
||||||
constant.id,
|
constant.id,
|
||||||
constant.name,
|
constant.name,
|
||||||
|
constant.admin,
|
||||||
constant.roles,
|
constant.roles,
|
||||||
constant.permissions,
|
constant.permissions,
|
||||||
|
constant.testBatchId,
|
||||||
|
constant.testBatchName,
|
||||||
];
|
];
|
||||||
|
|
||||||
const storage = {
|
const storage = {
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import JSON5 from "json5";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
|
||||||
|
test("PDA 的 H5、App 和小程序统一使用 Sylvan 品牌标识", async () => {
|
||||||
|
const [config, loginPage, registerPage, aboutPage, indexHtml, fullLogo, markPng] =
|
||||||
|
await Promise.all([
|
||||||
|
readFile(path.join(appRoot, "src/config.js"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "src/pages/login.vue"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "src/pages/register.vue"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "src/pages/mine/about/index.vue"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "index.html"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "src/static/sylvan-logo.svg"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "src/static/sylvan-mark.png")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(config, /logo:\s*["']\/static\/sylvan-logo\.png["']/);
|
||||||
|
assert.match(loginPage, /globalConfig\.appInfo\.logo/);
|
||||||
|
assert.match(registerPage, /globalConfig\.appInfo\.logo/);
|
||||||
|
assert.match(aboutPage, /src=["']\/static\/sylvan-logo\.png["']/);
|
||||||
|
assert.match(indexHtml, /href=["']\/static\/favicon\.ico["']/);
|
||||||
|
assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/);
|
||||||
|
assert.equal(markPng.subarray(1, 4).toString("ascii"), "PNG");
|
||||||
|
assert.doesNotMatch(`${config}\n${aboutPage}`, /\/static\/logo(?:200)?\.png/);
|
||||||
|
assert.doesNotMatch(fullLogo, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiContracts = {
|
||||||
|
"src/api/business/inbound.js": [
|
||||||
|
["listInbound", "'/business/inbound/list'", "get"],
|
||||||
|
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
|
||||||
|
["scanInboundPallet", "'/business/inbound/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||||
|
["getInbound", "'/business/inbound/' + inboundId", "get"],
|
||||||
|
["addInbound", "'/business/inbound'", "post"],
|
||||||
|
["updateInbound", "'/business/inbound'", "put"],
|
||||||
|
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
|
||||||
|
],
|
||||||
|
"src/api/business/filling.js": [
|
||||||
|
["listFilling", "'/business/filling/list'", "get"],
|
||||||
|
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
|
||||||
|
["scanFillingPallet", "'/business/filling/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||||
|
["getFilling", "'/business/filling/' + fillingId", "get"],
|
||||||
|
["addFilling", "'/business/filling'", "post"],
|
||||||
|
],
|
||||||
|
"src/api/business/shipment.js": [
|
||||||
|
["listShipment", "'/business/shipment/list'", "get"],
|
||||||
|
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
|
||||||
|
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
|
||||||
|
["scanShipmentPallet", "'/business/shipment/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||||
|
["getShipment", "'/business/shipment/' + shipmentId", "get"],
|
||||||
|
["addShipment", "'/business/shipment'", "post"],
|
||||||
|
["updateShipment", "'/business/shipment'", "put"],
|
||||||
|
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
|
||||||
|
],
|
||||||
|
"src/api/business/usage.js": [
|
||||||
|
["listUsage", "'/business/usage/list'", "get"],
|
||||||
|
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
|
||||||
|
["scanUsagePallet", "'/business/usage/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||||
|
["getUsage", "'/business/usage/' + usageId", "get"],
|
||||||
|
["addUsage", "'/business/usage'", "post"],
|
||||||
|
],
|
||||||
|
"src/api/business/return.js": [
|
||||||
|
["listReturn", "'/business/return/list'", "get"],
|
||||||
|
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
|
||||||
|
["scanReturnPallet", "'/business/return/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||||
|
["requestReturn", "'/business/return/request'", "post"],
|
||||||
|
["getReturn", "'/business/return/' + returnId", "get"],
|
||||||
|
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceForFunction(source, functionName) {
|
||||||
|
const match = source.match(
|
||||||
|
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
|
||||||
|
);
|
||||||
|
const start = match?.index ?? -1;
|
||||||
|
assert.notEqual(start, -1, `${functionName} 导出不存在`);
|
||||||
|
const end = source.indexOf("\n}", start);
|
||||||
|
return source.slice(start, end === -1 ? source.length : end + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
|
||||||
|
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
|
||||||
|
await t.test(relativePath, async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
||||||
|
for (const [functionName, url, method] of contracts) {
|
||||||
|
const functionSource = sourceForFunction(source, functionName);
|
||||||
|
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
|
||||||
|
const normalizedUrl = url.replaceAll("'", '"');
|
||||||
|
assert.match(
|
||||||
|
normalizedFunctionSource,
|
||||||
|
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
|
||||||
|
);
|
||||||
|
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("扫码值只去除首尾空白并拒绝空值", async () => {
|
||||||
|
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
|
||||||
|
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
|
||||||
|
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同一扫码值只在短时窗口内被识别为重复", async () => {
|
||||||
|
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
|
||||||
|
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
|
||||||
|
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
|
||||||
|
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
|
||||||
|
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
|
||||||
|
const expected = "12345678-1234-4123-8123-123456789abc";
|
||||||
|
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
|
||||||
|
assert.equal(
|
||||||
|
createIdempotencyKey({
|
||||||
|
getRandomValues(bytes) {
|
||||||
|
bytes.fill(0);
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"00000000-0000-4000-8000-000000000000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /@confirm=["']handleSubmit["']/);
|
||||||
|
assert.match(source, /normalizeScanValue/);
|
||||||
|
assert.match(source, /isDuplicateScan/);
|
||||||
|
assert.match(source, /emit\(["']scan["']/);
|
||||||
|
assert.match(source, /emit\(["']invalid["']/);
|
||||||
|
assert.match(source, /emit\(["']duplicate["']/);
|
||||||
|
assert.match(source, /loading/);
|
||||||
|
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
|
||||||
|
// uni-app 的 pages.json 允许开发模式注释,与构建端保持相同解析语义。
|
||||||
|
const pages = JSON5.parse(
|
||||||
|
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const pagePaths = new Set(pages.pages.map((page) => page.path));
|
||||||
|
for (const pagePath of [
|
||||||
|
"pages/business/inbound/index",
|
||||||
|
"pages/business/filling/index",
|
||||||
|
"pages/business/shipment/index",
|
||||||
|
"pages/business/usage/index",
|
||||||
|
"pages/business/return/index",
|
||||||
|
]) {
|
||||||
|
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [workSource, menuSource] = await Promise.all([
|
||||||
|
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
|
||||||
|
readFile(path.join(appRoot, "src/config/businessMenu.js"), "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(workSource, /hasPermi\(entry\.permission\)/);
|
||||||
|
assert.match(menuSource, /business:inbound:list/);
|
||||||
|
assert.match(menuSource, /business:filling:list/);
|
||||||
|
assert.match(menuSource, /business:shipment:list/);
|
||||||
|
assert.match(menuSource, /business:usage:list/);
|
||||||
|
assert.match(menuSource, /business:return:list/);
|
||||||
|
assert.match(menuSource, /pages\/business\/inbound\/index/);
|
||||||
|
assert.match(menuSource, /pages\/business\/filling\/index/);
|
||||||
|
assert.match(menuSource, /pages\/business\/shipment\/index/);
|
||||||
|
assert.match(menuSource, /pages\/business\/usage\/index/);
|
||||||
|
assert.match(menuSource, /pages\/business\/return\/index/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("首页展示真实运营驾驶舱,不保留模板入口和虚假统计", async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, "src/pages/index.vue"), "utf8");
|
||||||
|
assert.match(source, /listScanExceptions/);
|
||||||
|
assert.match(source, /最近扫码异常/);
|
||||||
|
assert.match(source, /TestBatchBanner/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/快捷入口|新闻|计划|请假申请|周报|工作效率|新通知|待办任务|85%/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("工作台只展示真实业务入口并使用后端列表权限", async () => {
|
||||||
|
const [workSource, menuModule] = await Promise.all([
|
||||||
|
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
|
||||||
|
importSource("src/config/businessMenu.js"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
menuModule.businessMenuEntries.map(({ title, permission, path: entryPath }) => ({
|
||||||
|
title,
|
||||||
|
permission,
|
||||||
|
path: entryPath,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "仓库入库",
|
||||||
|
permission: "business:inbound:list",
|
||||||
|
path: "/pages/business/inbound/index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "工厂装填",
|
||||||
|
permission: "business:filling:list",
|
||||||
|
path: "/pages/business/filling/index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "发货出库",
|
||||||
|
permission: "business:shipment:list",
|
||||||
|
path: "/pages/business/shipment/index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "客户现场使用",
|
||||||
|
permission: "business:usage:list",
|
||||||
|
path: "/pages/business/usage/index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "退回仓库",
|
||||||
|
permission: "business:return:list",
|
||||||
|
path: "/pages/business/return/index",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.match(workSource, /businessMenuEntries/);
|
||||||
|
assert.match(workSource, /hasPermi\(entry\.permission\)/);
|
||||||
|
assert.match(workSource, /navigateTo\(entry\.path\)/);
|
||||||
|
assert.doesNotMatch(workSource, /AI 生产力|智能对话|图像生成|系统管理|handleBuilding|模块建设中/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("运营驾驶舱 API 对接现有扫码异常接口", async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, "src/api/business/dashboard.js"), "utf8");
|
||||||
|
assert.match(source, /export function listScanExceptions\(/);
|
||||||
|
assert.match(source, /url:\s*["']\/business\/dashboard\/scan-exceptions["']/);
|
||||||
|
assert.match(source, /method:\s*["']get["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("APP 页面标题与后端业务菜单名称一致", async () => {
|
||||||
|
const [pages, fillingSource] = await Promise.all([
|
||||||
|
JSON5.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
|
||||||
|
readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"),
|
||||||
|
]);
|
||||||
|
const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index");
|
||||||
|
assert.equal(fillingPage?.style?.navigationBarTitleText, "工厂装填");
|
||||||
|
assert.match(fillingSource, />工厂装填</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
|
||||||
|
const requestSource = await readFile(
|
||||||
|
path.join(appRoot, "src/utils/request.js"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(requestSource, /X-Test-Batch-Id/);
|
||||||
|
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
|
||||||
|
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
|
||||||
|
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
|
||||||
|
|
||||||
|
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /TestBatchBanner/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
|
||||||
|
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
|
||||||
|
const source = await readFile(path.join(appRoot, file), "utf8");
|
||||||
|
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
|
||||||
|
}
|
||||||
|
for (const page of ["inbound", "shipment"]) {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("登录页只预填账号,不把管理员密码写入源码", async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
|
||||||
|
assert.match(source, /username:\s*["']admin["']/);
|
||||||
|
assert.match(source, /password:\s*["']["']/);
|
||||||
|
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
|
||||||
|
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
|
||||||
|
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/inbound/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanInboundPallet/);
|
||||||
|
assert.match(source, /UNSTOCKED/);
|
||||||
|
assert.match(source, /warehouseId/);
|
||||||
|
assert.match(source, /palletIds/);
|
||||||
|
assert.match(source, /getInbound\(/);
|
||||||
|
assert.match(source, /updateInbound/);
|
||||||
|
assert.match(source, /confirmInbound/);
|
||||||
|
assert.match(source, /idempotencyKey/);
|
||||||
|
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/shipment/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanShipmentPallet/);
|
||||||
|
assert.match(source, /FILLED_IN_WAREHOUSE/);
|
||||||
|
assert.match(source, /remainingPackages/);
|
||||||
|
assert.match(source, /sourceWarehouseId/);
|
||||||
|
assert.match(source, /targetSiteId/);
|
||||||
|
assert.match(source, /palletIds/);
|
||||||
|
assert.match(source, /getShipment\(/);
|
||||||
|
assert.match(source, /updateShipment/);
|
||||||
|
assert.match(source, /confirmShipment/);
|
||||||
|
assert.match(source, /idempotencyKey/);
|
||||||
|
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/filling/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanFillingPallet/);
|
||||||
|
assert.match(source, /EMPTY_IN_WAREHOUSE/);
|
||||||
|
for (const field of [
|
||||||
|
"qrToken",
|
||||||
|
"warehouseId",
|
||||||
|
"strainId",
|
||||||
|
"batchNo",
|
||||||
|
"packageSpec",
|
||||||
|
"initialPackages",
|
||||||
|
"idempotencyKey",
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(field));
|
||||||
|
}
|
||||||
|
assert.match(source, /addFilling/);
|
||||||
|
assert.match(source, /business:filling:(?:query|add)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/usage/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanUsagePallet/);
|
||||||
|
assert.match(source, /AT_SITE_UNUSED/);
|
||||||
|
assert.match(source, /AT_SITE_PARTIAL/);
|
||||||
|
assert.match(source, /currentLineId/);
|
||||||
|
assert.match(source, /getUsageLineOptions/);
|
||||||
|
assert.match(source, /usedPackages/);
|
||||||
|
assert.match(source, /remainingPackages/);
|
||||||
|
assert.match(source, /flowState/);
|
||||||
|
assert.match(source, /addUsage/);
|
||||||
|
assert.match(source, /idempotencyKey/);
|
||||||
|
assert.match(source, /business:usage:(?:query|add)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/return/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanReturnPallet/);
|
||||||
|
assert.match(source, /mode:\s*["']request["']/);
|
||||||
|
assert.match(source, /mode:\s*["']confirm["']/);
|
||||||
|
assert.match(source, /sourceSiteId/);
|
||||||
|
assert.match(source, /targetWarehouseId/);
|
||||||
|
assert.match(source, /palletIds/);
|
||||||
|
assert.match(source, /scannedPalletIds/);
|
||||||
|
assert.match(source, /requestReturn/);
|
||||||
|
assert.match(source, /getReturn\(/);
|
||||||
|
assert.match(source, /confirmReturn/);
|
||||||
|
assert.match(source, /sameIdSet/);
|
||||||
|
assert.match(source, /REQUESTED/);
|
||||||
|
assert.match(source, /requestIdempotencyKey/);
|
||||||
|
assert.match(source, /confirmIdempotencyKey/);
|
||||||
|
assert.match(source, /business:return:(?:query|request|confirm)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSource(relativePath) {
|
||||||
|
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
||||||
|
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const readSource = (relativePath) =>
|
||||||
|
readFileSync(resolve(appRoot, relativePath), "utf8");
|
||||||
|
|
||||||
|
test("APP 首页展示 PC 端核心运营统计并保护移动端横向空间", () => {
|
||||||
|
const source = readSource("src/pages/index.vue");
|
||||||
|
|
||||||
|
for (const className of [
|
||||||
|
"dashboard-overview",
|
||||||
|
"dashboard-kpi-grid",
|
||||||
|
"dashboard-trend-card",
|
||||||
|
"dashboard-distribution-card",
|
||||||
|
"dashboard-trend-range",
|
||||||
|
"dashboard-status-list",
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(`class=\\"[^\\"]*${className}[^\\"]*\\"`));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const label of [
|
||||||
|
"当前库存托盘",
|
||||||
|
"剩余包数",
|
||||||
|
"今日入库单",
|
||||||
|
"今日出库单",
|
||||||
|
"待确认单据",
|
||||||
|
"入库 / 出库趋势",
|
||||||
|
"托盘流转状态分布",
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(source, /listPalletInventory/);
|
||||||
|
assert.match(source, /listShipmentReport/);
|
||||||
|
assert.match(source, /listInbound/);
|
||||||
|
assert.match(source, /listShipment/);
|
||||||
|
assert.match(source, /listReturn/);
|
||||||
|
assert.match(source, /TREND_RANGE_OPTIONS/);
|
||||||
|
assert.match(source, /overflow-x:\s*hidden/);
|
||||||
|
assert.match(source, /min-width:\s*0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("APP 首页统计保留加载、空数据和部分失败状态", () => {
|
||||||
|
const source = readSource("src/pages/index.vue");
|
||||||
|
|
||||||
|
assert.match(source, /statsLoading/);
|
||||||
|
assert.match(source, /statsError/);
|
||||||
|
assert.match(source, /dashboard-stat-empty/);
|
||||||
|
assert.match(source, /dashboard-stat-error/);
|
||||||
|
assert.match(source, /formatMetric/);
|
||||||
|
assert.match(source, /--/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("APP 首页扫码异常支持默认收起并通过标题展开", () => {
|
||||||
|
const source = readSource("src/pages/index.vue");
|
||||||
|
|
||||||
|
assert.match(source, /const exceptionsExpanded = ref\(false\)/);
|
||||||
|
assert.match(source, /function toggleExceptionList\(\)/);
|
||||||
|
assert.match(source, /class="[^"]*exception-header[^"]*"/);
|
||||||
|
assert.match(source, /@click="toggleExceptionList"/);
|
||||||
|
assert.match(source, /exception-toggle-icon/);
|
||||||
|
assert.match(source, /v-else-if="exceptionsExpanded" class="exception-list"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("APP 首页报表 API 复用现有只读报表路径", () => {
|
||||||
|
const inventorySource = readSource("src/api/report/palletInventory.js");
|
||||||
|
const shipmentSource = readSource("src/api/report/shipment.js");
|
||||||
|
|
||||||
|
assert.match(inventorySource, /export function listPalletInventory\(/);
|
||||||
|
assert.match(inventorySource, /url:\s*["']\/report\/pallet-inventory\/list["']/);
|
||||||
|
assert.match(inventorySource, /method:\s*["']get["']/);
|
||||||
|
assert.match(shipmentSource, /export function listShipmentReport\(/);
|
||||||
|
assert.match(shipmentSource, /url:\s*["']\/report\/shipment\/list["']/);
|
||||||
|
assert.match(shipmentSource, /method:\s*["']get["']/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const loginPagePath = resolve(appRoot, "src/pages/login.vue");
|
||||||
|
const loginPageSource = readFileSync(loginPagePath, "utf8");
|
||||||
|
|
||||||
|
test("APP 登录页沿用 PC 端的品牌背景与登录卡片结构", () => {
|
||||||
|
assert.match(loginPageSource, /class="login-page"/);
|
||||||
|
assert.match(loginPageSource, /\/static\/login-background\.jpg/);
|
||||||
|
assert.match(loginPageSource, /class="login-card"/);
|
||||||
|
assert.match(loginPageSource, /globalConfig\.appInfo\.name/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
loginPageSource,
|
||||||
|
/bg-gradient-to-br from-blue-50 to-indigo-100/,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
existsSync(resolve(appRoot, "src/static/login-background.jpg")),
|
||||||
|
"APP 登录页需要包含与 PC 端一致的背景资源",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("APP 登录页保留验证码刷新与回车登录交互", () => {
|
||||||
|
assert.match(loginPageSource, /@click="getCode"/);
|
||||||
|
assert.match(loginPageSource, /@confirm="handleLogin"/);
|
||||||
|
assert.match(loginPageSource, /captchaEnabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("APP 登录页为白色 Sylvan 字标提供深色承载层", () => {
|
||||||
|
assert.match(loginPageSource, /class="brand-logo-frame"/);
|
||||||
|
assert.match(
|
||||||
|
loginPageSource,
|
||||||
|
/\.brand-logo-frame\s*\{[\s\S]*background:\s*#28225c/,
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const readSource = (relativePath) =>
|
||||||
|
readFileSync(resolve(appRoot, relativePath), "utf8");
|
||||||
|
|
||||||
|
const homeSource = readSource("src/pages/index.vue");
|
||||||
|
const workSource = readSource("src/pages/work/index.vue");
|
||||||
|
const bannerSource = readSource("src/components/business/TestBatchBanner.vue");
|
||||||
|
const modeSwitchSource = readSource(
|
||||||
|
"src/components/business/TestBatchModeSwitch.vue",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("首页使用受约束的运营布局并保护横向空间", () => {
|
||||||
|
assert.match(homeSource, /class="dashboard-page"/);
|
||||||
|
assert.match(homeSource, /class="dashboard-scroll"/);
|
||||||
|
assert.match(homeSource, /class="dashboard-content"/);
|
||||||
|
assert.match(homeSource, /class="dashboard-hero"/);
|
||||||
|
assert.match(homeSource, /class="dashboard-metric-grid"/);
|
||||||
|
assert.match(homeSource, /class="exception-list"/);
|
||||||
|
assert.match(homeSource, /class="exception-item"/);
|
||||||
|
assert.match(homeSource, /overflow-x:\s*hidden/);
|
||||||
|
assert.match(homeSource, /overflow-wrap:\s*anywhere/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("工作台入口在窄屏下保持单列和可换行", () => {
|
||||||
|
assert.match(workSource, /class="work-page"/);
|
||||||
|
assert.match(workSource, /class="work-scroll"/);
|
||||||
|
assert.match(workSource, /class="work-content"/);
|
||||||
|
assert.match(workSource, /class="work-entry-list"/);
|
||||||
|
assert.match(workSource, /class="work-entry"/);
|
||||||
|
assert.match(workSource, /class="work-entry-copy"/);
|
||||||
|
assert.match(workSource, /overflow-x:\s*hidden/);
|
||||||
|
assert.match(workSource, /overflow-wrap:\s*anywhere/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("测试批次提示和切换区不会撑开页面", () => {
|
||||||
|
assert.match(bannerSource, /class="test-batch-banner"/);
|
||||||
|
assert.match(bannerSource, /overflow-wrap:\s*anywhere/);
|
||||||
|
assert.match(modeSwitchSource, /class="test-batch-switch"/);
|
||||||
|
assert.match(modeSwitchSource, /class="test-batch-picker"/);
|
||||||
|
assert.match(modeSwitchSource, /overflow-wrap:\s*anywhere/);
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
|
|||||||
|
|
||||||
|
|
||||||
# -------- 数据库配置 --------
|
# -------- 数据库配置 --------
|
||||||
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
|
# 数据库类型,固定使用'mysql'
|
||||||
DB_TYPE = 'mysql'
|
DB_TYPE = 'mysql'
|
||||||
# 数据库主机
|
# 数据库主机
|
||||||
DB_HOST = '127.0.0.1'
|
DB_HOST = '127.0.0.1'
|
||||||
@@ -140,9 +140,15 @@ LOG_WORKER_ID = 'auto'
|
|||||||
|
|
||||||
# -------- 传输层加解密配置 --------
|
# -------- 传输层加解密配置 --------
|
||||||
# 是否启用传输层加解密
|
# 是否启用传输层加解密
|
||||||
TRANSPORT_CRYPTO_ENABLED = false
|
#TRANSPORT_CRYPTO_ENABLED = false
|
||||||
|
## 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
||||||
|
#TRANSPORT_CRYPTO_MODE = 'off'
|
||||||
|
|
||||||
|
TRANSPORT_CRYPTO_ENABLED = true
|
||||||
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
||||||
TRANSPORT_CRYPTO_MODE = 'off'
|
TRANSPORT_CRYPTO_MODE = 'optional'
|
||||||
|
|
||||||
|
|
||||||
# 传输层加解密算法标识
|
# 传输层加解密算法标识
|
||||||
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
|
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
|
||||||
# 当前启用的密钥版本标识
|
# 当前启用的密钥版本标识
|
||||||
@@ -171,3 +177,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
|||||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
||||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
||||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
||||||
|
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
|
||||||
|
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
|
|||||||
|
|
||||||
|
|
||||||
# -------- 数据库配置 --------
|
# -------- 数据库配置 --------
|
||||||
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
|
# 数据库类型,固定使用'mysql'
|
||||||
DB_TYPE = 'mysql'
|
DB_TYPE = 'mysql'
|
||||||
# 数据库主机
|
# 数据库主机
|
||||||
DB_HOST = 'shierfeng-mysql'
|
DB_HOST = 'shierfeng-mysql'
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
# -------- 应用配置 --------
|
|
||||||
# 应用运行环境
|
|
||||||
APP_ENV = 'prod'
|
|
||||||
# 应用名称
|
|
||||||
APP_NAME = 'ShiErFeng-FastAPI'
|
|
||||||
# 应用代理路径
|
|
||||||
APP_ROOT_PATH = '/docker-api'
|
|
||||||
# 应用主机
|
|
||||||
APP_HOST = '0.0.0.0'
|
|
||||||
# 应用端口
|
|
||||||
APP_PORT = 9099
|
|
||||||
# 应用版本
|
|
||||||
APP_VERSION= '1.9.0'
|
|
||||||
# 应用是否开启热重载
|
|
||||||
APP_RELOAD = false
|
|
||||||
# 应用工作进程数
|
|
||||||
APP_WORKERS = 1
|
|
||||||
# 应用是否开启IP归属区域查询
|
|
||||||
APP_IP_LOCATION_QUERY = true
|
|
||||||
# 应用是否为演示模式
|
|
||||||
APP_DEMO_MODE = false
|
|
||||||
# 应用是否允许账号同时登录
|
|
||||||
APP_SAME_TIME_LOGIN = true
|
|
||||||
# 应用是否禁用Swagger文档
|
|
||||||
APP_DISABLE_SWAGGER = true
|
|
||||||
# 应用是否禁用ReDoc文档
|
|
||||||
APP_DISABLE_REDOC = true
|
|
||||||
# 可信代理IP列表,多个值使用逗号分隔
|
|
||||||
APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1'
|
|
||||||
# 可信代理跳数,单层Nginx代理通常为1
|
|
||||||
APP_TRUSTED_PROXY_HOPS = 1
|
|
||||||
|
|
||||||
# -------- Jwt配置 --------
|
|
||||||
# Jwt秘钥
|
|
||||||
JWT_SECRET_KEY = 'b01c66dc2c58dc6a0aabfe2144256be36226de378bf87f72c0c795dda67f4d55'
|
|
||||||
# Jwt算法
|
|
||||||
JWT_ALGORITHM = 'HS256'
|
|
||||||
# 令牌过期时间
|
|
||||||
JWT_EXPIRE_MINUTES = 1440
|
|
||||||
# redis中令牌过期时间
|
|
||||||
JWT_REDIS_EXPIRE_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
# -------- 数据库配置 --------
|
|
||||||
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
|
|
||||||
DB_TYPE = 'postgresql'
|
|
||||||
# 数据库主机
|
|
||||||
DB_HOST = 'shierfeng-pg'
|
|
||||||
# 数据库端口
|
|
||||||
DB_PORT = 5432
|
|
||||||
# 数据库用户名
|
|
||||||
DB_USERNAME = 'postgres'
|
|
||||||
# 数据库密码
|
|
||||||
DB_PASSWORD = 'root'
|
|
||||||
# 数据库名称
|
|
||||||
DB_DATABASE = 'shierfeng-fastapi'
|
|
||||||
# 是否开启sqlalchemy日志
|
|
||||||
DB_ECHO = true
|
|
||||||
# 允许溢出连接池大小的最大连接数
|
|
||||||
DB_MAX_OVERFLOW = 10
|
|
||||||
# 连接池大小,0表示连接数无限制
|
|
||||||
DB_POOL_SIZE = 50
|
|
||||||
# 连接回收时间(单位:秒)
|
|
||||||
DB_POOL_RECYCLE = 3600
|
|
||||||
# 连接池中没有线程可用时,最多等待的时间(单位:秒)
|
|
||||||
DB_POOL_TIMEOUT = 30
|
|
||||||
|
|
||||||
# -------- Redis配置 --------
|
|
||||||
# Redis主机
|
|
||||||
REDIS_HOST = 'shierfeng-redis'
|
|
||||||
# Redis端口
|
|
||||||
REDIS_PORT = 6379
|
|
||||||
# Redis用户名
|
|
||||||
REDIS_USERNAME = ''
|
|
||||||
# Redis密码
|
|
||||||
REDIS_PASSWORD = ''
|
|
||||||
# Redis数据库
|
|
||||||
REDIS_DATABASE = 2
|
|
||||||
|
|
||||||
# -------- 日志配置 --------
|
|
||||||
# 是否启用日志脱敏
|
|
||||||
LOG_MASK_ENABLED = true
|
|
||||||
# 日志脱敏占位符
|
|
||||||
LOG_MASK_PLACEHOLDER = '******'
|
|
||||||
# 以下三项为互补关系(非互斥):
|
|
||||||
# 1) LOG_MASK_FIELDS:命中后执行全量脱敏
|
|
||||||
# 2) LOG_PARTIAL_MASK_FIELDS:命中后执行部分脱敏
|
|
||||||
# 3) LOG_CONFIG_SECRET_PATTERNS:按 configKey 关键词决定是否脱敏 configValue
|
|
||||||
# 全量脱敏字段,多个值使用逗号分隔
|
|
||||||
LOG_MASK_FIELDS = 'password,old_password,new_password,confirm_password,api_key,token,access_token,refresh_token,authorization,client_secret,secret,secret_key,private_key,private_key_pem,credential,credentials,sms_code,captcha_code,system_prompt'
|
|
||||||
# 部分脱敏字段,多个值使用逗号分隔
|
|
||||||
# 当前默认不对IP脱敏;如后期需要开启,可直接追加 ip,ipaddr,oper_ip,login_ip 并重启服务
|
|
||||||
LOG_PARTIAL_MASK_FIELDS = 'phonenumber,phone,mobile,email'
|
|
||||||
# 按配置键名识别敏感配置的关键词,多个值使用逗号分隔
|
|
||||||
LOG_CONFIG_SECRET_PATTERNS = 'password,token,secret,key,private,credential,access,jwt,captcha,sms'
|
|
||||||
# Redis Stream Key
|
|
||||||
LOG_STREAM_KEY = 'log:stream'
|
|
||||||
# Redis Stream 消费组名称
|
|
||||||
LOG_STREAM_GROUP = 'log_aggregator'
|
|
||||||
# Redis Stream 消费者名称前缀
|
|
||||||
LOG_STREAM_CONSUMER_PREFIX = 'worker'
|
|
||||||
# 每次读取的最大消息数量
|
|
||||||
LOG_STREAM_BATCH_SIZE = 100
|
|
||||||
# 阻塞读取等待时间(毫秒)
|
|
||||||
LOG_STREAM_BLOCK_MS = 2000
|
|
||||||
# Stream 最大长度(近似裁剪)
|
|
||||||
LOG_STREAM_MAXLEN = 100000
|
|
||||||
# Pending 回收最小空闲时间(毫秒)
|
|
||||||
LOG_STREAM_CLAIM_IDLE_MS = 60000
|
|
||||||
# Pending 回收检查间隔(毫秒)
|
|
||||||
LOG_STREAM_CLAIM_INTERVAL_MS = 5000
|
|
||||||
# 每次回收的最大消息数量
|
|
||||||
LOG_STREAM_CLAIM_BATCH_SIZE = 100
|
|
||||||
# 去重 Key 过期时间(秒)
|
|
||||||
LOG_STREAM_DEDUP_TTL = 3600
|
|
||||||
# 去重 Key 前缀
|
|
||||||
LOG_STREAM_DEDUP_PREFIX = 'log:dedup'
|
|
||||||
# stdout 输出是否为 JSON
|
|
||||||
LOGURU_JSON = false
|
|
||||||
# Loguru 最低输出级别
|
|
||||||
LOGURU_LEVEL = 'INFO'
|
|
||||||
# 是否输出到 stdout
|
|
||||||
LOGURU_STDOUT = true
|
|
||||||
# 是否启用文件日志
|
|
||||||
LOG_FILE_ENABLED = true
|
|
||||||
# 文件日志根目录
|
|
||||||
LOG_FILE_BASE_DIR = 'logs'
|
|
||||||
# 文件滚动策略
|
|
||||||
LOGURU_ROTATION = '50MB'
|
|
||||||
# 文件保留策略
|
|
||||||
LOGURU_RETENTION = '30 days'
|
|
||||||
# 文件压缩格式
|
|
||||||
LOGURU_COMPRESSION = 'zip'
|
|
||||||
# 实例标识(用于区分实例)
|
|
||||||
LOG_INSTANCE_ID = 'dockerpg'
|
|
||||||
# 服务名称(用于统一标识服务)
|
|
||||||
LOG_SERVICE_NAME = 'shierfeng-fastapi-backend'
|
|
||||||
# Worker 标识(auto 自动生成)
|
|
||||||
LOG_WORKER_ID = 'auto'
|
|
||||||
|
|
||||||
# -------- 传输层加解密配置 --------
|
|
||||||
# 是否启用传输层加解密
|
|
||||||
TRANSPORT_CRYPTO_ENABLED = true
|
|
||||||
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
|
||||||
TRANSPORT_CRYPTO_MODE = 'optional'
|
|
||||||
# 传输层加解密算法标识
|
|
||||||
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
|
|
||||||
# 当前启用的密钥版本标识
|
|
||||||
TRANSPORT_CRYPTO_KID = 'default'
|
|
||||||
# 传输层RSA位数,需与下方密钥对匹配
|
|
||||||
TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096
|
|
||||||
# 传输层公钥,默认提供一套可用示例值;Docker 部署前请替换为正式密钥
|
|
||||||
TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----'
|
|
||||||
# 传输层私钥,必须与上方公钥成对使用;Docker 部署前请替换为正式密钥
|
|
||||||
TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAwA2ooWpsxLzIVMJp7WcvvR0Bu8paFn8NVPpzz+wGpUlwP5DG\nK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6UbQVyHJ6BXXIMs/BpzcHCbyXR/wWG+\npKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEsA81kskVlDdGeNyaOJg5QuofEErCf\nR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu9YbEbKs7JvNle5vjc72ebbMIeGej\nHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj8F/TySLuOy37iVWfD+5ikLyou4ZD\nI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6SrldwZfZDBl3EGgRby8yJqO6SqGg\nyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqXhK67kbYaA9xJeHRaP04cg16imB7s\nBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6dWdJA3qe3iBSSM8pmedv+Jgfau/PF\nam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIaQ6acv+v5N6QVFptlcx6I7j8yZJ7W\nUHZlB1IxVqfPb69+985eknZyLul60gyu0kPGYUecypUu2wiNDBAErZlUxEujMWgJ\nDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSoRYxPhTbwo68RJDpImBjn+YsCAwEA\nAQKCAgEAtH7hwjp8WPY3rPk+hqEYy7psO6q0ujnUNM5hc8MWh9caSJNuu/D51vCK\nHX3d63hITNK+x9ZaM2Jcj/9XC56vm+EzILngARFDgPf3EHC06UO1IfEUND3CoMqs\njS/sEDCwiXpccc/JgkUT7EgluwDL5hLuFWGE1NTxxlFU/U0s7/HTA+G9AFuPQHjt\nZNJxxozscOh0W44OM4/jdebJ4TBEaDqtdpgDMttDwEVFIizOrbi6ODbmpCQaZzDq\nJczGoTZG7c5w5jfRnD1NnDzB0apjr3DQYVOT5EiWA39Vy6o2NmMojX1ksfxKZtll\n83vYZ91vSR5waQvo5nFJB5+vJ0CCy4BZaV9/gQBP/OSElELfD0bGYidO7NURAJMW\njGc2B6uIog8BZ3ix02FO/30i5rWeDWM7McLol8+IP0Qcg473YvrCgmRZpprdc9HT\nHnB6BgIwu0ZBoP7CxW7pBm0ApwmOf7OvUdAu68kFQ4KxABuv+Qegu0vSsktBtmIs\ngUDzu/ekoayyOvFHVwVTd4QKOSa2gXb3TS7PP22j6QLL1OPy2fK2NBW0xrRDIgtw\nSx3Yo0dxCYkxJWz1nQYsdIjZbz2/xQPZZQ5yNeePwxaOKyDuA50elN86I9RR2gjL\nh9QMrvPG7DOlgmBG9+JsydQZf00G+x45Su/1fydA09X3AFtZLkkCggEBAOj8NY/x\n3G3/1qAkrcQYLwB0mjLNkgKchRIP8NMildk0nSub5SRUxleW2wp0kM0WqkFTXLMa\nFR0qCRjiyPOtBBZxLobIfGc6568LTKfg4N7VsCeAcTjFjSOE57iX5XzwhTy6ZCKe\nZFGFWfhj/a9lNQHneTcklDHq/oHjLJQ1FacbWNycIOX2Hk7SZKyJvyMutAem3BI+\nm5SLyj5648N2ylmz6f3eaTn8CyNCrUKWnPriQZfV0XF/Aalh4sq/I3fI6pqnYkHK\nuSoG9YHujGXKN2iOV/0krCiGj94l7JqvKGs4ZRoZdD+s1EugGD7qBK1wGATuEseg\nR6mS74ylgD88JPcCggEBANMGWrqhHOAtGeIewLbNob54+nqdwU/O+zk0RcTZErcD\naRZMTLPRlpdbgWbNgdfjFnbUx0WmIeTIboD2MaW5KOnDfV/x+eRhGtEmtNRvHA/o\nHK6uYzok7Ekm7oc298CYrfRbEWVx3m4+mHmU+rsvBeof664zpEEIsSwm+NaAnjz8\nru0U8xw8KWLB8F/Q4J4p4D0yVKCmAXmrrTgEYm1H355JlAlmiu6t6YPS2Xd+gNFD\ntWBsQXYnxEZyPhoD1Q4SQjQmQMEN7+lIHm8f/nMwwUkAd3y6D1w/Uu81Gqa/9eAh\nz3woK++tj/U1CMMTWXJumIyJdH28CWQJa9VdJBoFbw0CggEAdIBhPDhh1DNhHDUb\nGvpIzn5/+LVotJuVwwFrl/gsXC9+BCdxPmiRwYyyvRiqq5MQ0ZegvAJn2myBngsR\nFyBF8f7omAc6hdgjsBkDXNMLPwI1kpscgpnuSHuV720tGPugdExz9Ael/EmlQpql\naQY/qlMX/uXwoMF0QHjbesTMrgHzvmTn4nXek+WK5+f9Rtd8uHLMiub5nx8Do5iJ\nudz1tENN119W2OLaougdgTWVC7MFv6nwkENFDnDfGijX0HcMMQdQD7wSORy/uRgS\n5ndmm4bKItAqsh0PZbMrC/JYUL0jeRiPU6PViHdmiFc2vY9Ww2hUxbO1Aetyk67S\nYUxu4wKCAQAvSOQ5n3JV9SUwms156LfOc1NE+GZhmLKITeM4SZ+87IG2omAphtQk\nlDd5rqDinBrjg6gnPiOoHRVNxly3krbNMqW8Qv9Iok2dAfxRAZibI9qRdbf8Rlu/\nxH58Q9/eAxgvgdxzC8HYmCQYUj6ghNfhb0ejICU1AVqZ1x25CtbqOYCE1UXoVL69\nR1GyVp6OMjnx3H5EBQ6dkc9dlvXrIMjFshz+wkBtXQDPLgbWlL0OpwpUUc2VSTHc\nyyqJL8Skw4icINovqAzTC/rt0ZB1hT46OmWLyDE67WGLAi43oRnaIBla67F0okJ4\nomqVM5e+YXPDQeWdau37wXStOZKmVgNdAoIBADojGo0h9mBgnr+u1oYB+mKx6LVD\n3TTy45IB1ikqprBnSjgXNbQycsTbl+qDo5ge+KqlPNk6Scvn0L7k43/VB9Y5qK2s\nBgxa1KgdeC5WUNU0rs/1UKIODA5SlWIk6JqekiAE+glljVZ5E53l93gPL3uRcvr5\nSD5CPe+qqoBh9nYIRJDogP0e2xV11EuE10j1WxuLkHL5//hePGPpgnb0/rZkP+vb\nkzwz1fTv88kQDnRk6uIe0L78iFTpEwAnlDmuMJ4KafKnujaR5VVCoI7bwryCp4+f\n8zyU7ZpKo/2EE8bYmxHMDPiYByJxCwAbm2Xn7Kw4H/17MLJOxg8685A8jyA=\n-----END RSA PRIVATE KEY-----'
|
|
||||||
# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md
|
|
||||||
TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]'
|
|
||||||
# 公钥缓存有效期(秒)
|
|
||||||
TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600
|
|
||||||
# 前端传输加密策略缓存有效期(秒)
|
|
||||||
TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300
|
|
||||||
# GET/DELETE 请求参数加密后的最大 URL 长度
|
|
||||||
TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096
|
|
||||||
# 请求时间窗允许偏差(秒)
|
|
||||||
TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120
|
|
||||||
# 防重放随机数有效期(秒)
|
|
||||||
TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300
|
|
||||||
# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用
|
|
||||||
TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
|
||||||
# 强制要求传输层加密的路径列表,多个值使用逗号分隔
|
|
||||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
|
||||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
|
||||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
|
||||||
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
|
|||||||
|
|
||||||
|
|
||||||
# -------- 数据库配置 --------
|
# -------- 数据库配置 --------
|
||||||
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
|
# 数据库类型,固定使用'mysql'
|
||||||
DB_TYPE = 'mysql'
|
DB_TYPE = 'mysql'
|
||||||
# 数据库主机
|
# 数据库主机
|
||||||
DB_HOST = '127.0.0.1'
|
DB_HOST = '127.0.0.1'
|
||||||
@@ -171,3 +171,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
|||||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
||||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
||||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
||||||
|
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
|
||||||
|
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
FROM python:3.10
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 复制源代码
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
RUN pip install --no-cache-dir -r requirements-pg.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 9099
|
|
||||||
|
|
||||||
# 启动命令
|
|
||||||
CMD ["shierfeng", "app", "run", "--env=dockerpg"]
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from cli.runtime import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService
|
|
||||||
|
|
||||||
|
|
||||||
class AppBootstrapService:
|
|
||||||
"""
|
|
||||||
应用引导服务。
|
|
||||||
|
|
||||||
该服务负责解析应用原始入口路径、构建启动命令,并以 `exec`
|
|
||||||
方式将当前 CLI 进程切换为 FastAPI 应用入口进程。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *, runtime_environment: RuntimeEnvironmentService | None = None) -> None:
|
|
||||||
"""
|
|
||||||
初始化应用引导服务。
|
|
||||||
|
|
||||||
:param runtime_environment: 运行时环境服务
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT
|
|
||||||
|
|
||||||
def get_app_entry_path(self) -> str:
|
|
||||||
"""
|
|
||||||
获取应用启动入口路径。
|
|
||||||
|
|
||||||
:return: 应用启动入口绝对路径
|
|
||||||
"""
|
|
||||||
return os.path.join(self.runtime_environment.get_backend_dir(), 'app.py')
|
|
||||||
|
|
||||||
def build_app_run_command(self, env: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
构建应用启动命令。
|
|
||||||
|
|
||||||
:param env: 运行环境
|
|
||||||
:return: 应用启动命令参数列表
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
self.runtime_environment.get_python_executable(),
|
|
||||||
self.get_app_entry_path(),
|
|
||||||
'--env',
|
|
||||||
env,
|
|
||||||
]
|
|
||||||
|
|
||||||
def exec_app_run_command(self, env: str) -> None:
|
|
||||||
"""
|
|
||||||
以应用原始入口进程启动当前 FastAPI 应用。
|
|
||||||
|
|
||||||
:param env: 运行环境
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
command = self.build_app_run_command(env)
|
|
||||||
os.execvp(command[0], command)
|
|
||||||
|
|
||||||
|
|
||||||
APP_BOOTSTRAP = AppBootstrapService()
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from cli.completion.controller import CompletionCommandController
|
|
||||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
|
|
||||||
from cli.context import OutputOption
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompletionCommandRegistration:
|
|
||||||
"""
|
|
||||||
completion 子命令注册描述。
|
|
||||||
|
|
||||||
:param name: 子命令名称
|
|
||||||
:param help_text: 子命令帮助文本
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
help_text: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CompletionSubcommandRegistrar:
|
|
||||||
"""
|
|
||||||
completion 子命令注册器。
|
|
||||||
|
|
||||||
该对象负责将 `show/install/doctor` 子命令挂载到 completion 应用,
|
|
||||||
将 Typer 命令声明细节从构建器主体中拆出。
|
|
||||||
|
|
||||||
:param completion_provider_gateway: completion 提供器对外网关
|
|
||||||
"""
|
|
||||||
|
|
||||||
completion_provider_gateway: CompletionProviderGateway = field(default_factory=lambda: COMPLETION_PROVIDER_GATEWAY)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_registrations() -> tuple[CompletionCommandRegistration, ...]:
|
|
||||||
"""
|
|
||||||
返回 completion 子命令注册描述列表。
|
|
||||||
|
|
||||||
:return: 子命令注册描述元组
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
CompletionCommandRegistration(name='show', help_text='输出指定 shell 的 completion 脚本'),
|
|
||||||
CompletionCommandRegistration(name='install', help_text='安装指定 shell 的 completion 脚本'),
|
|
||||||
CompletionCommandRegistration(name='doctor', help_text='检查当前 completion 配置状态'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def register(self, app: typer.Typer, *, controller: CompletionCommandController) -> None:
|
|
||||||
"""
|
|
||||||
向 completion 应用注册全部子命令。
|
|
||||||
|
|
||||||
:param app: completion 子应用
|
|
||||||
:param controller: completion 命令控制器
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
registrations = {registration.name: registration for registration in self.build_registrations()}
|
|
||||||
|
|
||||||
@app.command(registrations['show'].name, help=registrations['show'].help_text)
|
|
||||||
def show(
|
|
||||||
shell: str = typer.Argument(
|
|
||||||
...,
|
|
||||||
help='shell 名称,如 bash、zsh、fish',
|
|
||||||
autocompletion=self.completion_provider_gateway.complete_shell_names,
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出指定 shell 的 completion 脚本。
|
|
||||||
|
|
||||||
:param shell: shell 名称
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
controller.show(shell)
|
|
||||||
|
|
||||||
@app.command(registrations['install'].name, help=registrations['install'].help_text)
|
|
||||||
def install(
|
|
||||||
shell: str | None = typer.Option(
|
|
||||||
None,
|
|
||||||
'--shell',
|
|
||||||
help='shell 名称,默认自动识别当前 shell',
|
|
||||||
autocompletion=self.completion_provider_gateway.complete_shell_names,
|
|
||||||
),
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
target_file: Path | None = typer.Option(None, '--target-file', help='completion 脚本目标文件路径'),
|
|
||||||
activate: bool = typer.Option(False, '--activate', help='将激活命令写入 shell rc 文件'),
|
|
||||||
rc_file: Path | None = typer.Option(None, '--rc-file', help='自定义 shell rc 文件路径'),
|
|
||||||
force: bool = typer.Option(False, '--force', help='覆盖已存在且内容不同的目标文件'),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
安装指定 shell 的 completion 脚本。
|
|
||||||
|
|
||||||
:param shell: shell 名称,默认自动识别当前 shell
|
|
||||||
:param output: 输出格式
|
|
||||||
:param target_file: completion 脚本目标文件路径
|
|
||||||
:param activate: 是否写入 shell rc 文件激活命令
|
|
||||||
:param rc_file: 自定义 shell rc 文件路径
|
|
||||||
:param force: 是否强制覆盖已存在文件
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
controller.install(
|
|
||||||
output,
|
|
||||||
shell=shell,
|
|
||||||
target_file=target_file,
|
|
||||||
activate=activate,
|
|
||||||
rc_file=rc_file,
|
|
||||||
force=force,
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.command(registrations['doctor'].name, help=registrations['doctor'].help_text)
|
|
||||||
def doctor(
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
检查当前 completion 配置状态。
|
|
||||||
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
controller.doctor(output)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CompletionCommandBuilder:
|
|
||||||
"""
|
|
||||||
completion 子应用构建器。
|
|
||||||
|
|
||||||
该构建器负责装配 `completion` 子应用,并将控制器实例和子命令注册
|
|
||||||
细节收口到类式对象协作中。
|
|
||||||
|
|
||||||
:param completion_subcommand_registrar: completion 子命令注册器
|
|
||||||
"""
|
|
||||||
|
|
||||||
completion_subcommand_registrar: CompletionSubcommandRegistrar = field(
|
|
||||||
default_factory=CompletionSubcommandRegistrar
|
|
||||||
)
|
|
||||||
|
|
||||||
def build(self, root_cli: typer.Typer) -> typer.Typer:
|
|
||||||
"""
|
|
||||||
构建 completion 命令组。
|
|
||||||
|
|
||||||
:param root_cli: 根 Typer 应用
|
|
||||||
:return: completion 子应用
|
|
||||||
"""
|
|
||||||
app = typer.Typer(
|
|
||||||
help='shell completion 相关命令',
|
|
||||||
no_args_is_help=True,
|
|
||||||
context_settings={'help_option_names': ['-h', '--help']},
|
|
||||||
)
|
|
||||||
completion_command_controller = CompletionCommandController(root_cli)
|
|
||||||
self.completion_subcommand_registrar.register(app, controller=completion_command_controller)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
COMPLETION_COMMAND_BUILDER = CompletionCommandBuilder()
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from cli.completion.doctor import COMPLETION_DOCTOR, CompletionDoctorService
|
|
||||||
from cli.completion.installers import COMPLETION_INSTALLER, CompletionInstallerService
|
|
||||||
from cli.completion.presenter import CompletionCommandPresenter
|
|
||||||
from cli.core import (
|
|
||||||
DEFAULT_CORE_SERVICES,
|
|
||||||
CliContextFactory,
|
|
||||||
CliExecutionService,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionCommandController:
|
|
||||||
"""
|
|
||||||
completion 命令控制器。
|
|
||||||
|
|
||||||
该控制器负责组织 `completion` 命令组的上下文准备、payload 生成、
|
|
||||||
文本渲染与脚本输出收口。
|
|
||||||
|
|
||||||
:param root_cli: 根 Typer 应用
|
|
||||||
:param context_factory: CLI 上下文工厂
|
|
||||||
:param execution_service: CLI 执行服务
|
|
||||||
:param presenter: completion 命令文本渲染器
|
|
||||||
:param installer_service: completion 安装与脚本生成服务
|
|
||||||
:param doctor_service: completion 诊断服务
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
root_cli: typer.Typer,
|
|
||||||
*,
|
|
||||||
context_factory: CliContextFactory | None = None,
|
|
||||||
execution_service: CliExecutionService | None = None,
|
|
||||||
presenter: CompletionCommandPresenter | None = None,
|
|
||||||
installer_service: CompletionInstallerService | None = None,
|
|
||||||
doctor_service: CompletionDoctorService | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
初始化 completion 命令控制器。
|
|
||||||
|
|
||||||
:param root_cli: 根 Typer 应用
|
|
||||||
:param context_factory: CLI 上下文工厂
|
|
||||||
:param execution_service: CLI 执行服务
|
|
||||||
:param presenter: completion 命令文本渲染器
|
|
||||||
:param installer_service: completion 安装与脚本生成服务
|
|
||||||
:param doctor_service: completion 诊断服务
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.root_cli = root_cli
|
|
||||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
|
||||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
|
||||||
self.presenter = presenter or CompletionCommandPresenter()
|
|
||||||
self.installer_service = installer_service or COMPLETION_INSTALLER
|
|
||||||
self.doctor_service = doctor_service or COMPLETION_DOCTOR
|
|
||||||
|
|
||||||
def show(self, shell: str) -> None:
|
|
||||||
"""
|
|
||||||
输出指定 shell 的 completion 脚本。
|
|
||||||
|
|
||||||
:param shell: shell 名称
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
typer.echo(self.installer_service.render_completion_script(self.root_cli, shell), nl=False)
|
|
||||||
|
|
||||||
def install(
|
|
||||||
self,
|
|
||||||
output: str,
|
|
||||||
*,
|
|
||||||
shell: str | None,
|
|
||||||
target_file: Path | None,
|
|
||||||
activate: bool,
|
|
||||||
rc_file: Path | None,
|
|
||||||
force: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
安装指定 shell 的 completion 脚本。
|
|
||||||
|
|
||||||
:param output: 输出格式
|
|
||||||
:param shell: shell 名称
|
|
||||||
:param target_file: completion 脚本目标文件路径
|
|
||||||
:param activate: 是否写入 shell rc 文件激活命令
|
|
||||||
:param rc_file: 自定义 shell rc 文件路径
|
|
||||||
:param force: 是否强制覆盖已存在文件
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly('dev', output)
|
|
||||||
payload = self.installer_service.install_completion_script(
|
|
||||||
self.root_cli,
|
|
||||||
shell,
|
|
||||||
target_file=target_file,
|
|
||||||
activate=activate,
|
|
||||||
rc_file=rc_file,
|
|
||||||
force=force,
|
|
||||||
)
|
|
||||||
self.execution_service.complete_payload_result(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_completion_install_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
def doctor(self, output: str) -> None:
|
|
||||||
"""
|
|
||||||
检查当前 completion 配置状态。
|
|
||||||
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly('dev', output)
|
|
||||||
payload = self.doctor_service.build_completion_doctor_payload()
|
|
||||||
self.execution_service.complete_payload_result(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_completion_doctor_text,
|
|
||||||
)
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from cli.completion.installers import (
|
|
||||||
CLICK_COMPLETE_ENV_VAR,
|
|
||||||
COMPLETION_INSTALLER,
|
|
||||||
CompletionInstallerService,
|
|
||||||
)
|
|
||||||
from cli.metadata import (
|
|
||||||
COMPLETION_SHELL_SPEC_REGISTRY,
|
|
||||||
ENVIRONMENT_OPTION_SERVICE,
|
|
||||||
CompletionShellSpecRegistry,
|
|
||||||
EnvironmentOptionService,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionDoctorService:
|
|
||||||
"""
|
|
||||||
completion 诊断服务。
|
|
||||||
|
|
||||||
该服务负责汇总活跃 shell、目标脚本路径、rc 文件状态、推荐安装命令
|
|
||||||
与环境候选列表,生成 `completion doctor` 所需的结构化结果。
|
|
||||||
|
|
||||||
:param installer_service: completion 安装服务
|
|
||||||
:param shell_spec_registry: shell 元数据注册表
|
|
||||||
:param environment_option_service: 环境选项服务
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
installer_service: CompletionInstallerService | None = None,
|
|
||||||
shell_spec_registry: CompletionShellSpecRegistry | None = None,
|
|
||||||
environment_option_service: EnvironmentOptionService | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
初始化 completion 诊断服务。
|
|
||||||
|
|
||||||
:param installer_service: completion 安装服务
|
|
||||||
:param shell_spec_registry: shell 元数据注册表
|
|
||||||
:param environment_option_service: 环境选项服务
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.installer_service = installer_service or COMPLETION_INSTALLER
|
|
||||||
self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY
|
|
||||||
self.environment_option_service = environment_option_service or ENVIRONMENT_OPTION_SERVICE
|
|
||||||
|
|
||||||
def build_completion_doctor_payload(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
构建 completion 诊断结果。
|
|
||||||
|
|
||||||
:return: 诊断结果字典
|
|
||||||
"""
|
|
||||||
active_shell = self.installer_service.detect_active_shell()
|
|
||||||
shells: dict[str, dict[str, Any]] = {}
|
|
||||||
for shell_name, shell_spec in self.shell_spec_registry.specs.items():
|
|
||||||
target_file = self.installer_service.resolve_completion_target(shell_name)
|
|
||||||
rc_file = self.installer_service.resolve_completion_rc_file(shell_name)
|
|
||||||
source_command = None
|
|
||||||
if shell_spec.supported and shell_spec.generator in {'click', 'custom'}:
|
|
||||||
source_command = self.installer_service.build_source_command(target_file, shell_name)
|
|
||||||
recommended_install_command = f'shierfeng completion install --shell={shell_name}'
|
|
||||||
if shell_spec.supported and not shell_spec.auto_discovery:
|
|
||||||
recommended_install_command = f'{recommended_install_command} --activate'
|
|
||||||
shells[shell_name] = {
|
|
||||||
'supported': shell_spec.supported,
|
|
||||||
'detected': shell_name == active_shell,
|
|
||||||
'description': shell_spec.description,
|
|
||||||
'targetFile': str(target_file),
|
|
||||||
'targetFileExists': target_file.exists(),
|
|
||||||
'rcFile': str(rc_file) if rc_file is not None else None,
|
|
||||||
'rcFileExists': rc_file.exists() if rc_file is not None else None,
|
|
||||||
'autoDiscovery': shell_spec.auto_discovery,
|
|
||||||
'sourceCommand': source_command,
|
|
||||||
'recommendedInstallCommand': recommended_install_command,
|
|
||||||
}
|
|
||||||
|
|
||||||
recommended_shell = active_shell if self.shell_spec_registry.get_spec(active_shell) is not None else None
|
|
||||||
recommended_install_command = None
|
|
||||||
if recommended_shell is not None:
|
|
||||||
recommended_install_command = shells[recommended_shell]['recommendedInstallCommand']
|
|
||||||
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'message': 'completion 诊断信息已生成',
|
|
||||||
'activeShell': active_shell or None,
|
|
||||||
'projectDir': str(Path.cwd().resolve()),
|
|
||||||
'envChoices': self.environment_option_service.discover_env_names(),
|
|
||||||
'completeEnvVar': CLICK_COMPLETE_ENV_VAR,
|
|
||||||
'recommendedInstallCommand': recommended_install_command,
|
|
||||||
'shells': shells,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
COMPLETION_DOCTOR = CompletionDoctorService()
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import io
|
|
||||||
import os
|
|
||||||
from contextlib import redirect_stderr
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import click
|
|
||||||
import typer
|
|
||||||
import typer.main
|
|
||||||
from click.shell_completion import BashComplete, FishComplete, ZshComplete
|
|
||||||
|
|
||||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
|
|
||||||
from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered
|
|
||||||
from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR
|
|
||||||
from cli.metadata import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry
|
|
||||||
|
|
||||||
CLICK_COMPLETE_ENV_VAR = '_SHIERFENG_COMPLETE'
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionInstallerShellSupport:
|
|
||||||
"""
|
|
||||||
completion shell 差异支持对象。
|
|
||||||
|
|
||||||
该对象负责封装不同 shell 在脚本兼容处理和 source 命令构建上的差异,
|
|
||||||
供安装服务通过轻量 strategy 协作对象统一复用。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def keep_script_text(script_text: str) -> str:
|
|
||||||
"""
|
|
||||||
保持脚本文本原样返回。
|
|
||||||
|
|
||||||
:param script_text: 原始脚本文本
|
|
||||||
:return: 原样脚本文本
|
|
||||||
"""
|
|
||||||
return script_text
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def make_bash_completion_script_compatible(script_text: str) -> str:
|
|
||||||
"""
|
|
||||||
对 Click 生成的 Bash completion 脚本做旧版本兼容处理。
|
|
||||||
|
|
||||||
兼容目标主要包括 macOS 默认 Bash 3.2:
|
|
||||||
|
|
||||||
- `complete -o nosort` 在旧 Bash 上会在 `source` 阶段直接报错
|
|
||||||
- `compopt` 在旧 Bash 上可能不存在
|
|
||||||
|
|
||||||
:param script_text: Click 原始 Bash completion 脚本文本
|
|
||||||
:return: 兼容处理后的脚本文本
|
|
||||||
"""
|
|
||||||
compatible_lines: list[str] = []
|
|
||||||
for line in script_text.splitlines():
|
|
||||||
stripped_line = line.strip()
|
|
||||||
if stripped_line == 'compopt -o dirnames':
|
|
||||||
indent = line[: len(line) - len(line.lstrip(' '))]
|
|
||||||
compatible_lines.extend(
|
|
||||||
[
|
|
||||||
f'{indent}if command -v compopt >/dev/null 2>&1; then',
|
|
||||||
f'{indent} compopt -o dirnames',
|
|
||||||
f'{indent}fi',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if stripped_line == 'compopt -o default':
|
|
||||||
indent = line[: len(line) - len(line.lstrip(' '))]
|
|
||||||
compatible_lines.extend(
|
|
||||||
[
|
|
||||||
f'{indent}if command -v compopt >/dev/null 2>&1; then',
|
|
||||||
f'{indent} compopt -o default',
|
|
||||||
f'{indent}fi',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if stripped_line.startswith('complete -o nosort -F '):
|
|
||||||
compatibility_line = stripped_line.replace('complete -o nosort -F ', '', 1)
|
|
||||||
function_name, command_name = compatibility_line.split(' ', 1)
|
|
||||||
compatible_lines.extend(
|
|
||||||
[
|
|
||||||
f'if complete -o nosort -F {function_name} {command_name} 2>/dev/null; then',
|
|
||||||
' :',
|
|
||||||
'else',
|
|
||||||
f' complete -F {function_name} {command_name}',
|
|
||||||
'fi',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
compatible_lines.append(line)
|
|
||||||
return '\n'.join(compatible_lines) + '\n'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_posix_source_command(target_file: Path) -> str:
|
|
||||||
"""
|
|
||||||
构建 POSIX shell 使用的 source 命令。
|
|
||||||
|
|
||||||
:param target_file: completion 脚本文件路径
|
|
||||||
:return: source 命令
|
|
||||||
"""
|
|
||||||
return f'source {target_file}'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_fish_source_command(target_file: Path) -> str:
|
|
||||||
"""
|
|
||||||
构建 Fish shell 使用的 source 命令。
|
|
||||||
|
|
||||||
:param target_file: completion 脚本文件路径
|
|
||||||
:return: source 命令
|
|
||||||
"""
|
|
||||||
return f'status --is-interactive; and source {target_file}'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_powershell_source_command(target_file: Path) -> str:
|
|
||||||
"""
|
|
||||||
构建 PowerShell 使用的 source 命令。
|
|
||||||
|
|
||||||
:param target_file: completion 脚本文件路径
|
|
||||||
:return: source 命令
|
|
||||||
"""
|
|
||||||
return f'. "{target_file}"'
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompletionShellRuntimePolicy:
|
|
||||||
"""
|
|
||||||
completion shell 运行时策略定义。
|
|
||||||
|
|
||||||
:param name: shell 名称
|
|
||||||
:param click_completion_class: Click completion 生成器类型
|
|
||||||
:param script_transformer: 脚本文本后处理函数
|
|
||||||
:param source_command_builder: 激活命令构建函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
click_completion_class: type[Any]
|
|
||||||
script_transformer: Any
|
|
||||||
source_command_builder: Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompletionShellRuntimePolicyRegistry:
|
|
||||||
"""
|
|
||||||
completion shell 运行时策略注册表。
|
|
||||||
|
|
||||||
:param policies: 按 shell 名称索引的运行时策略映射
|
|
||||||
"""
|
|
||||||
|
|
||||||
policies: dict[str, CompletionShellRuntimePolicy]
|
|
||||||
|
|
||||||
def get(self, shell_name: str) -> CompletionShellRuntimePolicy | None:
|
|
||||||
"""
|
|
||||||
获取指定 shell 的运行时策略。
|
|
||||||
|
|
||||||
:param shell_name: shell 名称
|
|
||||||
:return: 运行时策略,不存在时返回 None
|
|
||||||
"""
|
|
||||||
return self.policies.get(shell_name)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES = CompletionShellRuntimePolicyRegistry(
|
|
||||||
policies={
|
|
||||||
'bash': CompletionShellRuntimePolicy(
|
|
||||||
name='bash',
|
|
||||||
click_completion_class=BashComplete,
|
|
||||||
script_transformer=CompletionInstallerShellSupport.make_bash_completion_script_compatible,
|
|
||||||
source_command_builder=CompletionInstallerShellSupport.build_posix_source_command,
|
|
||||||
),
|
|
||||||
'zsh': CompletionShellRuntimePolicy(
|
|
||||||
name='zsh',
|
|
||||||
click_completion_class=ZshComplete,
|
|
||||||
script_transformer=CompletionInstallerShellSupport.keep_script_text,
|
|
||||||
source_command_builder=CompletionInstallerShellSupport.build_posix_source_command,
|
|
||||||
),
|
|
||||||
'fish': CompletionShellRuntimePolicy(
|
|
||||||
name='fish',
|
|
||||||
click_completion_class=FishComplete,
|
|
||||||
script_transformer=CompletionInstallerShellSupport.keep_script_text,
|
|
||||||
source_command_builder=CompletionInstallerShellSupport.build_fish_source_command,
|
|
||||||
),
|
|
||||||
'powershell': CompletionShellRuntimePolicy(
|
|
||||||
name='powershell',
|
|
||||||
click_completion_class=PowerShellComplete,
|
|
||||||
script_transformer=CompletionInstallerShellSupport.keep_script_text,
|
|
||||||
source_command_builder=CompletionInstallerShellSupport.build_powershell_source_command,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionInstallerService:
|
|
||||||
"""
|
|
||||||
completion 安装与脚本生成服务。
|
|
||||||
|
|
||||||
该服务负责 shell 元数据解析、脚本生成、目标路径解析、激活命令构建、
|
|
||||||
rc 文件写入以及安装结果收口。
|
|
||||||
|
|
||||||
:param completion_provider_gateway: completion 提供器对外网关
|
|
||||||
:param shell_spec_registry: shell 元数据注册表
|
|
||||||
:param shell_runtime_policy_registry: shell 运行时策略注册表
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
completion_provider_gateway: CompletionProviderGateway | None = None,
|
|
||||||
shell_spec_registry: CompletionShellSpecRegistry | None = None,
|
|
||||||
shell_runtime_policy_registry: CompletionShellRuntimePolicyRegistry | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
初始化 completion 安装服务。
|
|
||||||
|
|
||||||
:param completion_provider_gateway: completion 提供器对外网关
|
|
||||||
:param shell_spec_registry: shell 元数据注册表
|
|
||||||
:param shell_runtime_policy_registry: shell 运行时策略注册表
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.completion_provider_gateway = completion_provider_gateway or COMPLETION_PROVIDER_GATEWAY
|
|
||||||
self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY
|
|
||||||
self.shell_runtime_policy_registry = shell_runtime_policy_registry or DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES
|
|
||||||
ensure_custom_completion_classes_registered()
|
|
||||||
|
|
||||||
def resolve_completion_shell_spec(self, shell: str) -> CompletionShellSpec:
|
|
||||||
"""
|
|
||||||
获取指定 shell 的 completion 元数据。
|
|
||||||
|
|
||||||
:param shell: shell 名称
|
|
||||||
:return: shell 元数据
|
|
||||||
:raises typer.BadParameter: shell 不存在时抛出异常
|
|
||||||
"""
|
|
||||||
normalized_shell = shell.strip().lower()
|
|
||||||
shell_spec = self.shell_spec_registry.get_spec(normalized_shell)
|
|
||||||
if shell_spec is None:
|
|
||||||
supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells())
|
|
||||||
raise typer.BadParameter(f'不支持的 shell:{shell},可选值为 {supported_shells}')
|
|
||||||
return shell_spec
|
|
||||||
|
|
||||||
def resolve_shell_runtime_policy(self, shell: str) -> CompletionShellRuntimePolicy:
|
|
||||||
"""
|
|
||||||
获取指定 shell 的运行时策略。
|
|
||||||
|
|
||||||
:param shell: shell 名称
|
|
||||||
:return: shell 运行时策略
|
|
||||||
:raises typer.BadParameter: 当前 shell 未实现运行时策略时抛出异常
|
|
||||||
"""
|
|
||||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
|
||||||
runtime_policy = self.shell_runtime_policy_registry.get(shell_spec.name)
|
|
||||||
if runtime_policy is None:
|
|
||||||
raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现')
|
|
||||||
return runtime_policy
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_completion_click_command(root_cli: typer.Typer) -> click.Command:
|
|
||||||
"""
|
|
||||||
将 Typer 根应用转换为 Click 命令对象,供 shell completion 生成使用。
|
|
||||||
|
|
||||||
:param root_cli: Typer 根应用
|
|
||||||
:return: Click 命令对象
|
|
||||||
"""
|
|
||||||
return typer.main.get_command(root_cli)
|
|
||||||
|
|
||||||
def render_completion_script(self, root_cli: typer.Typer, shell: str) -> str:
|
|
||||||
"""
|
|
||||||
生成指定 shell 的 completion 脚本文本。
|
|
||||||
|
|
||||||
:param root_cli: Typer 根应用
|
|
||||||
:param shell: shell 名称
|
|
||||||
:return: completion 脚本文本
|
|
||||||
:raises typer.BadParameter: 当前 shell 未实现脚本生成时抛出异常
|
|
||||||
"""
|
|
||||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
|
||||||
if not shell_spec.supported or shell_spec.generator not in {'click', 'custom'}:
|
|
||||||
raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现')
|
|
||||||
|
|
||||||
runtime_policy = self.resolve_shell_runtime_policy(shell_spec.name)
|
|
||||||
click_command = self.build_completion_click_command(root_cli)
|
|
||||||
stderr_buffer = io.StringIO()
|
|
||||||
with redirect_stderr(stderr_buffer):
|
|
||||||
script_text = runtime_policy.click_completion_class(
|
|
||||||
click_command,
|
|
||||||
{},
|
|
||||||
'shierfeng',
|
|
||||||
CLICK_COMPLETE_ENV_VAR,
|
|
||||||
).source()
|
|
||||||
return runtime_policy.script_transformer(script_text)
|
|
||||||
|
|
||||||
def resolve_completion_target(self, shell: str, target_file: Path | None = None) -> Path:
|
|
||||||
"""
|
|
||||||
解析 completion 脚本目标文件路径。
|
|
||||||
|
|
||||||
:param shell: shell 名称
|
|
||||||
:param target_file: 用户显式指定的目标文件
|
|
||||||
:return: 目标文件绝对路径
|
|
||||||
"""
|
|
||||||
if target_file is not None:
|
|
||||||
return target_file.expanduser().resolve()
|
|
||||||
|
|
||||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
|
||||||
return (Path.home() / shell_spec.default_target).expanduser().resolve()
|
|
||||||
|
|
||||||
def resolve_completion_rc_file(self, shell: str, rc_file: Path | None = None) -> Path | None:
|
|
||||||
"""
|
|
||||||
解析 completion 激活所使用的 rc 文件路径。
|
|
||||||
|
|
||||||
:param shell: shell 名称
|
|
||||||
:param rc_file: 用户显式指定的 rc 文件
|
|
||||||
:return: rc 文件绝对路径,若当前 shell 无 rc 文件则返回 None
|
|
||||||
"""
|
|
||||||
if rc_file is not None:
|
|
||||||
return rc_file.expanduser().resolve()
|
|
||||||
|
|
||||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
|
||||||
if shell_spec.default_rc_file is None:
|
|
||||||
return None
|
|
||||||
return (Path.home() / shell_spec.default_rc_file).expanduser().resolve()
|
|
||||||
|
|
||||||
def build_source_command(self, target_file: Path, shell: str) -> str:
|
|
||||||
"""
|
|
||||||
构建当前 shell 对应的激活命令。
|
|
||||||
|
|
||||||
:param target_file: completion 脚本文件路径
|
|
||||||
:param shell: shell 名称
|
|
||||||
:return: 激活命令文本
|
|
||||||
"""
|
|
||||||
runtime_policy = self.resolve_shell_runtime_policy(shell)
|
|
||||||
return runtime_policy.source_command_builder(target_file)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def detect_active_shell() -> str:
|
|
||||||
"""
|
|
||||||
检测当前进程环境下的活跃 shell 名称。
|
|
||||||
|
|
||||||
:return: shell 名称,未知时返回空字符串
|
|
||||||
"""
|
|
||||||
shell_path = os.environ.get('SHELL', '').strip()
|
|
||||||
if not shell_path:
|
|
||||||
return ''
|
|
||||||
return Path(shell_path).name.lower()
|
|
||||||
|
|
||||||
def resolve_install_shell(self, shell: str | None) -> str:
|
|
||||||
"""
|
|
||||||
解析安装命令实际使用的 shell。
|
|
||||||
|
|
||||||
:param shell: 用户显式指定的 shell,允许为空
|
|
||||||
:return: 实际使用的 shell 名称
|
|
||||||
:raises typer.BadParameter: 无法推断或不支持时抛出异常
|
|
||||||
"""
|
|
||||||
if shell and shell.strip():
|
|
||||||
return self.resolve_completion_shell_spec(shell).name
|
|
||||||
|
|
||||||
active_shell = self.detect_active_shell()
|
|
||||||
if not active_shell:
|
|
||||||
raise typer.BadParameter('未检测到当前 shell,请显式传入 --shell')
|
|
||||||
|
|
||||||
shell_spec = self.shell_spec_registry.get_spec(active_shell)
|
|
||||||
if shell_spec is None:
|
|
||||||
supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells())
|
|
||||||
raise typer.BadParameter(
|
|
||||||
f'当前 shell `{active_shell}` 不在支持列表中,请显式传入 --shell,可选值为 {supported_shells}'
|
|
||||||
)
|
|
||||||
return shell_spec.name
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def append_activation_line(rc_file: Path, source_command: str) -> bool:
|
|
||||||
"""
|
|
||||||
将激活命令追加到 rc 文件,若已存在则不重复写入。
|
|
||||||
|
|
||||||
:param rc_file: rc 文件路径
|
|
||||||
:param source_command: 激活命令文本
|
|
||||||
:return: 本次是否发生写入
|
|
||||||
"""
|
|
||||||
existing_text = ''
|
|
||||||
if rc_file.exists():
|
|
||||||
existing_text = rc_file.read_text(encoding='utf-8')
|
|
||||||
if source_command in existing_text:
|
|
||||||
return False
|
|
||||||
|
|
||||||
rc_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with rc_file.open('a', encoding='utf-8') as file_object:
|
|
||||||
if existing_text and not existing_text.endswith('\n'):
|
|
||||||
file_object.write('\n')
|
|
||||||
file_object.write(f'{source_command}\n')
|
|
||||||
return True
|
|
||||||
|
|
||||||
def install_completion_script(
|
|
||||||
self,
|
|
||||||
root_cli: typer.Typer,
|
|
||||||
shell: str | None,
|
|
||||||
*,
|
|
||||||
target_file: Path | None = None,
|
|
||||||
activate: bool = False,
|
|
||||||
rc_file: Path | None = None,
|
|
||||||
force: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
安装指定 shell 的 completion 脚本文件。
|
|
||||||
|
|
||||||
:param root_cli: Typer 根应用
|
|
||||||
:param shell: shell 名称
|
|
||||||
:param target_file: completion 脚本目标文件
|
|
||||||
:param activate: 是否写入 rc 文件激活命令
|
|
||||||
:param rc_file: 自定义 rc 文件路径
|
|
||||||
:param force: 目标文件已存在时是否强制覆盖
|
|
||||||
:return: 安装结果字典
|
|
||||||
"""
|
|
||||||
resolved_shell = self.resolve_install_shell(shell)
|
|
||||||
shell_spec = self.resolve_completion_shell_spec(resolved_shell)
|
|
||||||
if not shell_spec.supported:
|
|
||||||
return {
|
|
||||||
'ok': False,
|
|
||||||
'message': f'{shell_spec.name} completion 当前版本未实现',
|
|
||||||
'shell': shell_spec.name,
|
|
||||||
'exit_code': ARGUMENT_ERROR,
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved_target_file = self.resolve_completion_target(shell_spec.name, target_file)
|
|
||||||
script_text = self.render_completion_script(root_cli, shell_spec.name)
|
|
||||||
existing_text = resolved_target_file.read_text(encoding='utf-8') if resolved_target_file.exists() else None
|
|
||||||
|
|
||||||
if existing_text is not None and existing_text != script_text and not force:
|
|
||||||
return {
|
|
||||||
'ok': False,
|
|
||||||
'message': '目标文件已存在且内容不同,请传入 --force 覆盖',
|
|
||||||
'shell': shell_spec.name,
|
|
||||||
'targetFile': str(resolved_target_file),
|
|
||||||
'exit_code': RUNTIME_ERROR,
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved_target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
resolved_target_file.write_text(script_text, encoding='utf-8')
|
|
||||||
|
|
||||||
source_command = self.build_source_command(resolved_target_file, shell_spec.name)
|
|
||||||
activated = shell_spec.auto_discovery
|
|
||||||
rc_file_path = self.resolve_completion_rc_file(shell_spec.name, rc_file)
|
|
||||||
rc_file_updated = False
|
|
||||||
activation_required = not shell_spec.auto_discovery
|
|
||||||
detected_shell = self.detect_active_shell() or None
|
|
||||||
|
|
||||||
if activate and rc_file_path is not None:
|
|
||||||
rc_file_updated = self.append_activation_line(rc_file_path, source_command)
|
|
||||||
activated = True
|
|
||||||
|
|
||||||
next_step = '当前 shell 会自动发现 completion 脚本,无需额外 source 命令'
|
|
||||||
if activation_required and not activate:
|
|
||||||
next_step = f'请执行 `{source_command}`,或重新运行并传入 --activate'
|
|
||||||
elif activation_required and activate:
|
|
||||||
next_step = '请重启当前 shell,或手动执行 rc 文件中的 source 命令使其立即生效'
|
|
||||||
|
|
||||||
return {
|
|
||||||
'ok': True,
|
|
||||||
'message': 'completion 脚本已安装',
|
|
||||||
'shell': shell_spec.name,
|
|
||||||
'detectedShell': detected_shell,
|
|
||||||
'targetFile': str(resolved_target_file),
|
|
||||||
'activated': activated,
|
|
||||||
'activateRequested': activate,
|
|
||||||
'rcFile': str(rc_file_path) if rc_file_path is not None else None,
|
|
||||||
'rcFileUpdated': rc_file_updated,
|
|
||||||
'sourceCommand': source_command,
|
|
||||||
'autoDiscovery': shell_spec.auto_discovery,
|
|
||||||
'activationRequired': activation_required,
|
|
||||||
'nextStep': next_step,
|
|
||||||
'completeEnvVar': CLICK_COMPLETE_ENV_VAR,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
COMPLETION_INSTALLER = CompletionInstallerService()
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class CompletionCommandPresenter:
|
|
||||||
"""
|
|
||||||
completion 命令文本渲染器。
|
|
||||||
|
|
||||||
该渲染器负责将 `completion` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
|
||||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def build_completion_doctor_text(self, payload: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
将 completion 诊断结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: completion 诊断结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
lines = [
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'message: {payload.get("message", "-")}',
|
|
||||||
f'active_shell: {payload.get("activeShell") or "-"}',
|
|
||||||
f'project_dir: {payload.get("projectDir", "-")}',
|
|
||||||
f'complete_env_var: {payload.get("completeEnvVar", "-")}',
|
|
||||||
]
|
|
||||||
if payload.get('recommendedInstallCommand'):
|
|
||||||
lines.append(f'recommended_install_command: {payload.get("recommendedInstallCommand")}')
|
|
||||||
env_choices = payload.get('envChoices')
|
|
||||||
if isinstance(env_choices, list) and env_choices:
|
|
||||||
lines.append('env_choices:')
|
|
||||||
lines.extend(f' - {env_name}' for env_name in env_choices)
|
|
||||||
shells = payload.get('shells')
|
|
||||||
if isinstance(shells, dict) and shells:
|
|
||||||
lines.append('shells:')
|
|
||||||
for shell_name, shell_payload in shells.items():
|
|
||||||
if not isinstance(shell_payload, dict):
|
|
||||||
continue
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f' {shell_name}:',
|
|
||||||
f' supported: {str(shell_payload.get("supported", False)).lower()}',
|
|
||||||
f' detected: {str(shell_payload.get("detected", False)).lower()}',
|
|
||||||
f' target_file: {shell_payload.get("targetFile", "-")}',
|
|
||||||
f' target_file_exists: {str(shell_payload.get("targetFileExists", False)).lower()}',
|
|
||||||
f' rc_file: {shell_payload.get("rcFile", "-") or "-"}',
|
|
||||||
f' rc_file_exists: {str(shell_payload.get("rcFileExists", False)).lower()}',
|
|
||||||
f' auto_discovery: {str(shell_payload.get("autoDiscovery", False)).lower()}',
|
|
||||||
f' source_command: {shell_payload.get("sourceCommand", "-")}',
|
|
||||||
f' recommended_install_command: {shell_payload.get("recommendedInstallCommand", "-")}',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_completion_install_text(payload: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
将 completion 安装结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: completion 安装结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
lines = [
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'message: {payload.get("message", "-")}',
|
|
||||||
f'shell: {payload.get("shell", "-")}',
|
|
||||||
]
|
|
||||||
field_label_mapping = {
|
|
||||||
'detectedShell': 'detected_shell',
|
|
||||||
'targetFile': 'target_file',
|
|
||||||
'activated': 'activated',
|
|
||||||
'activateRequested': 'activate_requested',
|
|
||||||
'rcFile': 'rc_file',
|
|
||||||
'rcFileUpdated': 'rc_file_updated',
|
|
||||||
'sourceCommand': 'source_command',
|
|
||||||
'autoDiscovery': 'auto_discovery',
|
|
||||||
'activationRequired': 'activation_required',
|
|
||||||
'nextStep': 'next_step',
|
|
||||||
'completeEnvVar': 'complete_env_var',
|
|
||||||
}
|
|
||||||
for field_name, field_label in field_label_mapping.items():
|
|
||||||
if field_name in payload:
|
|
||||||
lines.append(f'{field_label}: {payload.get(field_name)}')
|
|
||||||
return '\n'.join(lines)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,113 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from click.shell_completion import (
|
|
||||||
CompletionItem,
|
|
||||||
ShellComplete,
|
|
||||||
add_completion_class,
|
|
||||||
get_completion_class,
|
|
||||||
split_arg_string,
|
|
||||||
)
|
|
||||||
|
|
||||||
_SOURCE_POWERSHELL = """\
|
|
||||||
$%(complete_func)s = {
|
|
||||||
param($wordToComplete, $commandAst, $cursorPosition)
|
|
||||||
|
|
||||||
$previousCompWords = $env:COMP_WORDS
|
|
||||||
$previousCompCword = $env:COMP_CWORD
|
|
||||||
$previousCompleteInstruction = $env:%(complete_var)s
|
|
||||||
|
|
||||||
$commandLine = $commandAst.ToString()
|
|
||||||
if ($cursorPosition -lt $commandLine.Length) {
|
|
||||||
$commandLine = $commandLine.Substring(0, $cursorPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
$env:COMP_WORDS = $commandLine
|
|
||||||
$env:COMP_CWORD = $wordToComplete
|
|
||||||
$env:%(complete_var)s = "powershell_complete"
|
|
||||||
|
|
||||||
try {
|
|
||||||
%(prog_name)s | ForEach-Object {
|
|
||||||
$line = $_.ToString()
|
|
||||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
$parts = $line -split "`t", 3
|
|
||||||
$completionValue = if ($parts.Length -ge 2) { $parts[1] } else { "" }
|
|
||||||
$completionHelp = if ($parts.Length -ge 3 -and $parts[2]) { $parts[2] } else { $completionValue }
|
|
||||||
[System.Management.Automation.CompletionResult]::new(
|
|
||||||
$completionValue,
|
|
||||||
$completionValue,
|
|
||||||
[System.Management.Automation.CompletionResultType]::ParameterValue,
|
|
||||||
$completionHelp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if ($null -ne $previousCompWords) {
|
|
||||||
$env:COMP_WORDS = $previousCompWords
|
|
||||||
} else {
|
|
||||||
Remove-Item Env:\\COMP_WORDS -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
if ($null -ne $previousCompCword) {
|
|
||||||
$env:COMP_CWORD = $previousCompCword
|
|
||||||
} else {
|
|
||||||
Remove-Item Env:\\COMP_CWORD -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
if ($null -ne $previousCompleteInstruction) {
|
|
||||||
$env:%(complete_var)s = $previousCompleteInstruction
|
|
||||||
} else {
|
|
||||||
Remove-Item Env:\\%(complete_var)s -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $%(complete_func)s
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class PowerShellComplete(ShellComplete):
|
|
||||||
"""
|
|
||||||
PowerShell shell completion 支持。
|
|
||||||
|
|
||||||
基于 PowerShell `Register-ArgumentCompleter -Native` 协议,将当前
|
|
||||||
命令行和光标位置传回 Click completion 分发器,再把返回的候选项转换为
|
|
||||||
`CompletionResult` 对象。
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = 'powershell'
|
|
||||||
source_template = _SOURCE_POWERSHELL
|
|
||||||
|
|
||||||
def get_completion_args(self) -> tuple[list[str], str]:
|
|
||||||
"""
|
|
||||||
从 PowerShell 注入的环境变量中恢复 CLI 上下文。
|
|
||||||
|
|
||||||
:return: 已解析的完整参数与当前不完整输入
|
|
||||||
"""
|
|
||||||
cwords = split_arg_string(os.environ.get('COMP_WORDS', ''))
|
|
||||||
incomplete = os.environ.get('COMP_CWORD', '')
|
|
||||||
if incomplete:
|
|
||||||
incomplete_parts = split_arg_string(incomplete)
|
|
||||||
incomplete = incomplete_parts[0] if incomplete_parts else incomplete
|
|
||||||
args = cwords[1:]
|
|
||||||
if incomplete and args and args[-1] == incomplete:
|
|
||||||
args.pop()
|
|
||||||
return args, incomplete
|
|
||||||
|
|
||||||
def format_completion(self, item: CompletionItem) -> str:
|
|
||||||
"""
|
|
||||||
将候选项格式化为 PowerShell 脚本可解析的文本行。
|
|
||||||
|
|
||||||
:param item: Click completion 候选项
|
|
||||||
:return: 格式化后的文本
|
|
||||||
"""
|
|
||||||
help_text = item.help or item.value
|
|
||||||
return f'{item.type}\t{item.value}\t{help_text}'
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_custom_completion_classes_registered() -> None:
|
|
||||||
"""
|
|
||||||
确保自定义 shell completion 类已注册到 Click。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if get_completion_class(PowerShellComplete.name) is None:
|
|
||||||
add_completion_class(PowerShellComplete)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Annotated, Literal
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliContext:
|
|
||||||
"""
|
|
||||||
CLI 运行上下文。
|
|
||||||
|
|
||||||
env: 当前命令使用的环境名称
|
|
||||||
output: 结果输出格式
|
|
||||||
operator: 操作者标识,后续用于审计
|
|
||||||
allow_prod: 是否显式允许生产环境执行危险命令
|
|
||||||
yes: 是否跳过二次确认
|
|
||||||
dry_run: 是否执行演练模式
|
|
||||||
"""
|
|
||||||
|
|
||||||
env: str = 'dev'
|
|
||||||
output: Literal['text', 'json'] = 'text'
|
|
||||||
operator: str | None = None
|
|
||||||
allow_prod: bool = False
|
|
||||||
yes: bool = False
|
|
||||||
dry_run: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class CliContextBuilder:
|
|
||||||
"""
|
|
||||||
CLI 上下文构建器。
|
|
||||||
|
|
||||||
该对象负责将命令层显式参数收口为统一的 `CliContext` 数据对象,
|
|
||||||
供上下文工厂与测试场景复用。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build(
|
|
||||||
env: str,
|
|
||||||
output: Literal['text', 'json'],
|
|
||||||
allow_prod: bool,
|
|
||||||
yes: bool,
|
|
||||||
dry_run: bool,
|
|
||||||
) -> CliContext:
|
|
||||||
"""
|
|
||||||
构建命令执行上下文。
|
|
||||||
|
|
||||||
:param env: 当前命令环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过二次确认
|
|
||||||
:param dry_run: 是否执行演练模式
|
|
||||||
:return: 构建完成的 CLI 上下文
|
|
||||||
"""
|
|
||||||
return CliContext(
|
|
||||||
env=env,
|
|
||||||
output=output,
|
|
||||||
allow_prod=allow_prod,
|
|
||||||
yes=yes,
|
|
||||||
dry_run=dry_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CLI_CONTEXT_BUILDER = CliContextBuilder()
|
|
||||||
|
|
||||||
EnvOption = Annotated[
|
|
||||||
str,
|
|
||||||
typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values),
|
|
||||||
]
|
|
||||||
OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')]
|
|
||||||
AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')]
|
|
||||||
YesOption = Annotated[bool, typer.Option('--yes', help='跳过危险命令确认')]
|
|
||||||
DryRunOption = Annotated[bool, typer.Option('--dry-run', help='仅执行预演,不落地实际变更')]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from cli.core.app_builder import CliApplicationBuilder, ProjectRuntimeLocator
|
|
||||||
from cli.core.completion_dispatcher import CompletionDispatcher
|
|
||||||
from cli.core.context_factory import CliContextFactory, CliRuntimeState
|
|
||||||
from cli.core.execution import CliExecutionService
|
|
||||||
from cli.output import OutputRenderer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CoreServiceContainer:
|
|
||||||
"""
|
|
||||||
CLI 核心服务容器。
|
|
||||||
|
|
||||||
该容器负责统一装配输出渲染器、运行期状态、上下文工厂与执行服务,
|
|
||||||
作为控制器、向导和 TUI 等编排层的默认依赖入口。
|
|
||||||
|
|
||||||
:param output_renderer: 输出渲染器
|
|
||||||
:param runtime_state: CLI 运行期状态对象
|
|
||||||
"""
|
|
||||||
|
|
||||||
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
|
|
||||||
runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState)
|
|
||||||
context_factory: CliContextFactory = field(init=False)
|
|
||||||
execution_service: CliExecutionService = field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
"""
|
|
||||||
初始化核心服务依赖图。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.context_factory = CliContextFactory(
|
|
||||||
runtime_state=self.runtime_state,
|
|
||||||
output_renderer=self.output_renderer,
|
|
||||||
)
|
|
||||||
self.execution_service = CliExecutionService(output_renderer=self.output_renderer)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CORE_SERVICES = CoreServiceContainer()
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'DEFAULT_CORE_SERVICES',
|
|
||||||
'CliApplicationBuilder',
|
|
||||||
'CliContextFactory',
|
|
||||||
'CliExecutionService',
|
|
||||||
'CliRuntimeState',
|
|
||||||
'CompletionDispatcher',
|
|
||||||
'CoreServiceContainer',
|
|
||||||
'ProjectRuntimeLocator',
|
|
||||||
]
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
import importlib
|
|
||||||
import sys
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated, Literal
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from cli.output import OutputRenderer
|
|
||||||
from cli.runtime.base import RUNTIME_ENVIRONMENT
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProjectRuntimeLocator:
|
|
||||||
"""
|
|
||||||
CLI 项目运行时定位器。
|
|
||||||
|
|
||||||
该对象负责判断当前目录是否为后端项目根目录、注入 `sys.path`,
|
|
||||||
并为配置导入阶段提取最小化的 `argv` 参数。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def is_backend_project_dir(self, project_dir: Path) -> bool:
|
|
||||||
"""
|
|
||||||
判断给定目录是否为后端项目根目录。
|
|
||||||
|
|
||||||
:param project_dir: 待检查目录
|
|
||||||
:return: 是否为后端项目根目录
|
|
||||||
"""
|
|
||||||
return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir)
|
|
||||||
|
|
||||||
def ensure_backend_dir_on_sys_path(self) -> Path:
|
|
||||||
"""
|
|
||||||
将当前后端项目根目录加入 `sys.path` 首位。
|
|
||||||
|
|
||||||
:return: 当前后端项目根目录
|
|
||||||
:raises typer.Exit: 当前目录不是后端项目根目录时退出命令
|
|
||||||
"""
|
|
||||||
project_dir = Path.cwd().resolve()
|
|
||||||
if not self.is_backend_project_dir(project_dir):
|
|
||||||
typer.echo('`shierfeng` 命令需在 `shierfeng-fastapi-backend` 目录下执行', err=True)
|
|
||||||
raise typer.Exit(code=2)
|
|
||||||
|
|
||||||
project_dir_str = str(project_dir)
|
|
||||||
if sys.path and sys.path[0] == project_dir_str:
|
|
||||||
return project_dir
|
|
||||||
|
|
||||||
sys.path.insert(0, project_dir_str)
|
|
||||||
return project_dir
|
|
||||||
|
|
||||||
def extract_import_argv(self, argv: list[str] | None = None) -> list[str]:
|
|
||||||
"""
|
|
||||||
提取供配置模块导入阶段使用的最小参数集。
|
|
||||||
|
|
||||||
:param argv: 原始命令行参数列表
|
|
||||||
:return: 仅保留程序名和 `--env` 的参数列表
|
|
||||||
"""
|
|
||||||
current_argv = argv or sys.argv
|
|
||||||
program_name = current_argv[0] if current_argv else 'shierfeng'
|
|
||||||
import_argv = [program_name]
|
|
||||||
env_value = ''
|
|
||||||
current_args = current_argv[1:]
|
|
||||||
for index, argument in enumerate(current_args):
|
|
||||||
if argument.startswith('--env='):
|
|
||||||
env_value = argument.split('=', 1)[1].strip()
|
|
||||||
break
|
|
||||||
if argument == '--env' and index + 1 < len(current_args):
|
|
||||||
env_value = current_args[index + 1].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
if env_value:
|
|
||||||
import_argv.extend(['--env', env_value])
|
|
||||||
return import_argv
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliCommandGroupRegistry:
|
|
||||||
"""
|
|
||||||
CLI 命令组注册表。
|
|
||||||
|
|
||||||
该注册表集中维护根 CLI 需要挂载的业务命令组模块路径,
|
|
||||||
避免命令组声明散落在应用构建器主体中。
|
|
||||||
|
|
||||||
:param command_modules: 命令组到模块路径的映射
|
|
||||||
"""
|
|
||||||
|
|
||||||
command_modules: dict[str, str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliExtensionRegistration:
|
|
||||||
"""
|
|
||||||
CLI 扩展入口注册描述。
|
|
||||||
|
|
||||||
:param module_path: 扩展模块导入路径
|
|
||||||
:param attach: 已加载模块挂载到根 CLI 的函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
module_path: str
|
|
||||||
attach: Callable[[typer.Typer, object], None]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliExtensionRegistry:
|
|
||||||
"""
|
|
||||||
CLI 扩展入口注册表。
|
|
||||||
|
|
||||||
该注册表集中维护 completion、wizard、TUI 等扩展入口的模块路径与
|
|
||||||
挂载方式,避免这些规则继续散落在注册器主体中。
|
|
||||||
|
|
||||||
:param registrations: 扩展注册描述列表
|
|
||||||
"""
|
|
||||||
|
|
||||||
registrations: tuple[CliExtensionRegistration, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliCommandGroupRegistrar:
|
|
||||||
"""
|
|
||||||
CLI 命令组注册器。
|
|
||||||
|
|
||||||
该对象负责根据命令组注册表向根 Typer 应用挂载业务命令组。
|
|
||||||
|
|
||||||
:param command_group_registry: CLI 命令组注册表
|
|
||||||
"""
|
|
||||||
|
|
||||||
command_group_registry: CliCommandGroupRegistry
|
|
||||||
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
|
|
||||||
|
|
||||||
def register(self, cli: typer.Typer) -> None:
|
|
||||||
"""
|
|
||||||
向根应用注册业务命令组。
|
|
||||||
|
|
||||||
:param cli: 根 Typer 应用
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
for command_name, module_path in self.command_group_registry.command_modules.items():
|
|
||||||
command_module = self.module_loader.load(module_path)
|
|
||||||
cli.add_typer(command_module.app, name=command_name)
|
|
||||||
|
|
||||||
|
|
||||||
class CliExtensionMountSupport:
|
|
||||||
"""
|
|
||||||
CLI 扩展入口挂载支持对象。
|
|
||||||
|
|
||||||
该对象负责封装不同扩展模块挂载到根 CLI 的差异,避免扩展注册器本体
|
|
||||||
继续堆积 `if/else` 或硬编码模块处理逻辑。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def attach_completion(cli: typer.Typer, module: object) -> None:
|
|
||||||
"""
|
|
||||||
挂载 completion 扩展入口。
|
|
||||||
|
|
||||||
:param cli: 根 Typer 应用
|
|
||||||
:param module: 已加载模块
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
cli.add_typer(module.COMPLETION_COMMAND_BUILDER.build(cli), name='completion')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def attach_wizard(cli: typer.Typer, module: object) -> None:
|
|
||||||
"""
|
|
||||||
挂载 wizard 扩展入口。
|
|
||||||
|
|
||||||
:param cli: 根 Typer 应用
|
|
||||||
:param module: 已加载模块
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
cli.add_typer(module.WIZARD_COMMAND_BUILDER.build(), name='wizard')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def attach_tui(cli: typer.Typer, module: object) -> None:
|
|
||||||
"""
|
|
||||||
挂载 TUI 扩展入口。
|
|
||||||
|
|
||||||
:param cli: 根 Typer 应用
|
|
||||||
:param module: 已加载模块
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
module.TUI_COMMAND_REGISTRATION.register(cli)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliExtensionRegistrar:
|
|
||||||
"""
|
|
||||||
CLI 扩展入口注册器。
|
|
||||||
|
|
||||||
该对象负责向根 Typer 应用挂载 completion、wizard 与 TUI
|
|
||||||
等扩展入口。
|
|
||||||
"""
|
|
||||||
|
|
||||||
extension_registry: CliExtensionRegistry
|
|
||||||
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
|
|
||||||
|
|
||||||
def register(self, cli: typer.Typer) -> None:
|
|
||||||
"""
|
|
||||||
向根应用注册扩展入口。
|
|
||||||
|
|
||||||
:param cli: 根 Typer 应用
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
for registration in self.extension_registry.registrations:
|
|
||||||
extension_module = self.module_loader.load(registration.module_path)
|
|
||||||
registration.attach(cli, extension_module)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliRootOptionInitializer:
|
|
||||||
"""
|
|
||||||
CLI 根级选项初始化器。
|
|
||||||
|
|
||||||
该对象负责将根级 `--color`、`--icon` 选项映射到共享输出渲染器,
|
|
||||||
避免根回调继续内联输出配置逻辑。
|
|
||||||
|
|
||||||
:param output_renderer: 根命令共享的输出渲染器
|
|
||||||
"""
|
|
||||||
|
|
||||||
output_renderer: OutputRenderer
|
|
||||||
|
|
||||||
def initialize(self, *, color: str, icon: str) -> None:
|
|
||||||
"""
|
|
||||||
初始化根级输出选项。
|
|
||||||
|
|
||||||
:param color: 文本输出颜色模式
|
|
||||||
:param icon: 文本输出图标模式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.output_renderer.set_color_mode(color)
|
|
||||||
self.output_renderer.set_icon_mode(icon)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliRootCallbackRegistrar:
|
|
||||||
"""
|
|
||||||
CLI 根回调注册器。
|
|
||||||
|
|
||||||
该对象负责把根级 `--color`、`--icon` 选项回调挂载到 Typer 根应用,
|
|
||||||
让 `CliApplicationBuilder` 更专注于整体装配流程。
|
|
||||||
|
|
||||||
:param root_option_initializer: 根级选项初始化器
|
|
||||||
"""
|
|
||||||
|
|
||||||
root_option_initializer: CliRootOptionInitializer
|
|
||||||
|
|
||||||
def register(self, cli: typer.Typer) -> None:
|
|
||||||
"""
|
|
||||||
向根应用注册回调。
|
|
||||||
|
|
||||||
:param cli: 根 Typer 应用
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
@cli.callback()
|
|
||||||
def root_callback(
|
|
||||||
color: Annotated[
|
|
||||||
Literal['auto', 'always', 'never'],
|
|
||||||
typer.Option('--color', help='文本输出颜色模式'),
|
|
||||||
] = 'always',
|
|
||||||
icon: Annotated[
|
|
||||||
Literal['emoji', 'ascii', 'none'],
|
|
||||||
typer.Option('--icon', help='文本输出图标模式'),
|
|
||||||
] = 'emoji',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
初始化 CLI 根级运行参数。
|
|
||||||
|
|
||||||
:param color: 文本输出颜色模式
|
|
||||||
:param icon: 文本输出图标模式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.root_option_initializer.initialize(color=color, icon=icon)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CliModuleLoader:
|
|
||||||
"""
|
|
||||||
CLI 模块加载服务。
|
|
||||||
|
|
||||||
该对象负责集中执行命令组与扩展入口所需的延迟导入,
|
|
||||||
让注册器本身更专注于挂载动作。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def load(module_path: str) -> object:
|
|
||||||
"""
|
|
||||||
按模块路径加载模块对象。
|
|
||||||
|
|
||||||
:param module_path: 模块导入路径
|
|
||||||
:return: 已加载模块
|
|
||||||
"""
|
|
||||||
return importlib.import_module(module_path)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliApplicationBuilder:
|
|
||||||
"""
|
|
||||||
CLI 根应用构建器。
|
|
||||||
|
|
||||||
:param output_renderer: 根命令共享的输出渲染器
|
|
||||||
:param command_group_registrar: CLI 命令组注册器
|
|
||||||
:param extension_registrar: CLI 扩展入口注册器
|
|
||||||
"""
|
|
||||||
|
|
||||||
output_renderer: OutputRenderer
|
|
||||||
command_group_registrar: CliCommandGroupRegistrar = field(
|
|
||||||
default_factory=lambda: CliCommandGroupRegistrar(DEFAULT_COMMAND_GROUP_REGISTRY)
|
|
||||||
)
|
|
||||||
extension_registrar: CliExtensionRegistrar = field(
|
|
||||||
default_factory=lambda: CliExtensionRegistrar(DEFAULT_CLI_EXTENSION_REGISTRY)
|
|
||||||
)
|
|
||||||
root_option_initializer: CliRootOptionInitializer = field(init=False)
|
|
||||||
root_callback_registrar: CliRootCallbackRegistrar = field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
"""
|
|
||||||
初始化根应用构建器依赖。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.root_option_initializer = CliRootOptionInitializer(self.output_renderer)
|
|
||||||
self.root_callback_registrar = CliRootCallbackRegistrar(self.root_option_initializer)
|
|
||||||
|
|
||||||
def build(self) -> typer.Typer:
|
|
||||||
"""
|
|
||||||
构建 Typer 根应用并注册全部命令组。
|
|
||||||
|
|
||||||
:return: Typer 根应用
|
|
||||||
"""
|
|
||||||
cli = typer.Typer(
|
|
||||||
name='shierfeng',
|
|
||||||
help='ShiErFeng FastAPI 后端统一命令入口',
|
|
||||||
no_args_is_help=True,
|
|
||||||
add_completion=False,
|
|
||||||
context_settings={'help_option_names': ['-h', '--help']},
|
|
||||||
)
|
|
||||||
self.root_callback_registrar.register(cli)
|
|
||||||
self.command_group_registrar.register(cli)
|
|
||||||
self.extension_registrar.register(cli)
|
|
||||||
return cli
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_COMMAND_GROUP_REGISTRY = CliCommandGroupRegistry(
|
|
||||||
command_modules={
|
|
||||||
'app': 'cli.groups.app',
|
|
||||||
'db': 'cli.groups.db',
|
|
||||||
'ops': 'cli.groups.ops',
|
|
||||||
'cache': 'cli.groups.cache',
|
|
||||||
'job': 'cli.groups.job',
|
|
||||||
'config': 'cli.groups.config',
|
|
||||||
'crypto': 'cli.groups.crypto',
|
|
||||||
'gen': 'cli.groups.gen',
|
|
||||||
'dev': 'cli.groups.dev',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DEFAULT_CLI_EXTENSION_REGISTRY = CliExtensionRegistry(
|
|
||||||
registrations=(
|
|
||||||
CliExtensionRegistration('cli.completion.commands', CliExtensionMountSupport.attach_completion),
|
|
||||||
CliExtensionRegistration('cli.wizard.commands', CliExtensionMountSupport.attach_wizard),
|
|
||||||
CliExtensionRegistration('cli.tui', CliExtensionMountSupport.attach_tui),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
DEFAULT_CLI_MODULE_LOADER = CliModuleLoader()
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import typer
|
|
||||||
from click.shell_completion import shell_complete as click_shell_complete
|
|
||||||
from typer.completion import shell_complete as typer_shell_complete
|
|
||||||
|
|
||||||
from cli.completion.shells import ensure_custom_completion_classes_registered
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompletionInstructionSupport:
|
|
||||||
"""
|
|
||||||
completion 指令解析支持对象。
|
|
||||||
|
|
||||||
该对象负责管理 completion 环境变量名、受支持 shell 列表,以及
|
|
||||||
Click / Typer 两种指令格式的判定逻辑。
|
|
||||||
|
|
||||||
:param complete_env_var: completion 环境变量名
|
|
||||||
:param supported_shells: 支持的 shell 名称列表
|
|
||||||
"""
|
|
||||||
|
|
||||||
complete_env_var: str = '_SHIERFENG_COMPLETE'
|
|
||||||
supported_shells: tuple[str, ...] = ('bash', 'zsh', 'fish', 'powershell')
|
|
||||||
|
|
||||||
def is_click_style_instruction(self, instruction: str) -> bool:
|
|
||||||
"""
|
|
||||||
判断 completion 指令是否为 Click 原生格式。
|
|
||||||
|
|
||||||
Click 原生格式为 `<shell>_<instruction>`,例如 `bash_complete`。
|
|
||||||
|
|
||||||
:param instruction: completion 指令文本
|
|
||||||
:return: 是否为 Click 风格指令
|
|
||||||
"""
|
|
||||||
return any(instruction.startswith(f'{shell}_') for shell in self.supported_shells)
|
|
||||||
|
|
||||||
def is_typer_style_instruction(self, instruction: str) -> bool:
|
|
||||||
"""
|
|
||||||
判断 completion 指令是否为 Typer 兼容格式。
|
|
||||||
|
|
||||||
Typer 兼容格式为 `<instruction>_<shell>`,例如 `complete_bash`。
|
|
||||||
|
|
||||||
:param instruction: completion 指令文本
|
|
||||||
:return: 是否为 Typer 风格指令
|
|
||||||
"""
|
|
||||||
return any(instruction.endswith(f'_{shell}') for shell in self.supported_shells)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompletionInstructionReader:
|
|
||||||
"""
|
|
||||||
completion 指令读取器。
|
|
||||||
|
|
||||||
该对象负责从当前进程环境中读取并规范化 completion 指令。
|
|
||||||
|
|
||||||
:param support: completion 指令解析支持对象
|
|
||||||
"""
|
|
||||||
|
|
||||||
support: CompletionInstructionSupport
|
|
||||||
|
|
||||||
def read_instruction(self) -> str:
|
|
||||||
"""
|
|
||||||
读取当前进程中的 completion 指令文本。
|
|
||||||
|
|
||||||
:return: 规范化后的 completion 指令文本
|
|
||||||
"""
|
|
||||||
return os.environ.get(self.support.complete_env_var, '').strip()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompletionDispatcher:
|
|
||||||
"""
|
|
||||||
shell completion 请求分发器。
|
|
||||||
|
|
||||||
该对象负责识别 Click 与 Typer 两种 completion 指令格式,
|
|
||||||
并在命中时直接执行补全流程后退出当前进程。
|
|
||||||
|
|
||||||
:param support: completion 指令解析支持对象
|
|
||||||
:param instruction_reader: completion 指令读取器
|
|
||||||
"""
|
|
||||||
|
|
||||||
support: CompletionInstructionSupport = field(default_factory=CompletionInstructionSupport)
|
|
||||||
instruction_reader: CompletionInstructionReader = field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
"""
|
|
||||||
初始化 completion 指令读取器。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
object.__setattr__(self, 'instruction_reader', CompletionInstructionReader(self.support))
|
|
||||||
|
|
||||||
def dispatch(self, cli: typer.Typer) -> None:
|
|
||||||
"""
|
|
||||||
处理 shell completion 请求并在命中时直接退出。
|
|
||||||
|
|
||||||
这里同时兼容 Click 与 Typer 两种 completion 指令格式,避免
|
|
||||||
生成脚本格式和运行时解析格式不一致时出现 `Shell complete not supported.`。
|
|
||||||
|
|
||||||
:param cli: Typer 根应用
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ensure_custom_completion_classes_registered()
|
|
||||||
instruction = self.instruction_reader.read_instruction()
|
|
||||||
if not instruction:
|
|
||||||
return
|
|
||||||
|
|
||||||
click_command = typer.main.get_command(cli)
|
|
||||||
if self.support.is_click_style_instruction(instruction):
|
|
||||||
raise SystemExit(
|
|
||||||
click_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction)
|
|
||||||
)
|
|
||||||
if self.support.is_typer_style_instruction(instruction):
|
|
||||||
raise SystemExit(
|
|
||||||
typer_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction)
|
|
||||||
)
|
|
||||||
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_COMPLETION_INSTRUCTION_SUPPORT = CompletionInstructionSupport()
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from importlib import import_module
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from cli.context import CLI_CONTEXT_BUILDER, CliContext, CliContextBuilder
|
|
||||||
from cli.guards import (
|
|
||||||
DEFAULT_DANGEROUS_COMMAND_GUARD,
|
|
||||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY,
|
|
||||||
DangerousCommandGuardService,
|
|
||||||
DangerousCommandRuleRegistry,
|
|
||||||
)
|
|
||||||
from cli.output import OutputRenderer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliRuntimeState:
|
|
||||||
"""
|
|
||||||
CLI 运行期状态。
|
|
||||||
|
|
||||||
:param logs_suppressed: 当前进程日志是否已切换为静默模式
|
|
||||||
"""
|
|
||||||
|
|
||||||
logs_suppressed: bool = False
|
|
||||||
sqlalchemy_logs_suppressed: bool = False
|
|
||||||
|
|
||||||
def get_logger(self) -> Any:
|
|
||||||
"""
|
|
||||||
获取 CLI 使用的日志对象。
|
|
||||||
|
|
||||||
:return: 日志对象
|
|
||||||
"""
|
|
||||||
return import_module('utils.log_util').logger
|
|
||||||
|
|
||||||
def suppress_logs(self) -> None:
|
|
||||||
"""
|
|
||||||
关闭 CLI 进程中的终端日志输出。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if self.logs_suppressed:
|
|
||||||
return
|
|
||||||
self.get_logger().remove()
|
|
||||||
self.logs_suppressed = True
|
|
||||||
|
|
||||||
def suppress_sqlalchemy_logs(self) -> None:
|
|
||||||
"""
|
|
||||||
关闭 CLI 进程中的 SQLAlchemy 终端 SQL 日志输出。
|
|
||||||
|
|
||||||
该逻辑同时处理两类来源:
|
|
||||||
|
|
||||||
1. 将 `config.env.DataBaseConfig.db_echo` 强制关闭,避免后续新建 Engine 时
|
|
||||||
继续打开 SQLAlchemy echo。
|
|
||||||
2. 将已知 SQLAlchemy logger 级别提升到 WARNING,避免已有 logger 配置把
|
|
||||||
`INFO sqlalchemy.engine.Engine ...` 继续打到标准输出。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if self.sqlalchemy_logs_suppressed:
|
|
||||||
return
|
|
||||||
env_module = import_module('config.env')
|
|
||||||
database_config = getattr(env_module, 'DataBaseConfig', None)
|
|
||||||
if database_config is not None and hasattr(database_config, 'db_echo'):
|
|
||||||
database_config.db_echo = False
|
|
||||||
for logger_name in (
|
|
||||||
'sqlalchemy',
|
|
||||||
'sqlalchemy.engine',
|
|
||||||
'sqlalchemy.engine.Engine',
|
|
||||||
'sqlalchemy.pool',
|
|
||||||
):
|
|
||||||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
|
||||||
self.sqlalchemy_logs_suppressed = True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliLogPolicy:
|
|
||||||
"""
|
|
||||||
CLI 日志策略服务。
|
|
||||||
|
|
||||||
该对象负责在上下文构建前应用统一的日志输出策略,
|
|
||||||
将“是否静默日志”这一策略从上下文工厂主体中拆出。
|
|
||||||
|
|
||||||
:param runtime_state: CLI 运行期状态对象
|
|
||||||
"""
|
|
||||||
|
|
||||||
runtime_state: CliRuntimeState
|
|
||||||
|
|
||||||
def prepare_regular_command(self) -> None:
|
|
||||||
"""
|
|
||||||
为普通 CLI 命令应用默认日志策略。
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.runtime_state.suppress_logs()
|
|
||||||
self.runtime_state.suppress_sqlalchemy_logs()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DangerousCommandContextSupport:
|
|
||||||
"""
|
|
||||||
危险命令上下文支持服务。
|
|
||||||
|
|
||||||
该对象负责危险命令规则查询、保护执行与拒绝结果收口,
|
|
||||||
让上下文工厂本体只保留装配职责。
|
|
||||||
|
|
||||||
:param dangerous_command_rule_registry: 危险命令规则注册表
|
|
||||||
:param dangerous_command_guard_service: 危险命令保护执行服务
|
|
||||||
:param output_renderer: 输出渲染器
|
|
||||||
"""
|
|
||||||
|
|
||||||
dangerous_command_rule_registry: DangerousCommandRuleRegistry
|
|
||||||
dangerous_command_guard_service: DangerousCommandGuardService
|
|
||||||
output_renderer: OutputRenderer
|
|
||||||
|
|
||||||
def guard_context(self, ctx: CliContext, *, command_name: str) -> CliContext:
|
|
||||||
"""
|
|
||||||
对危险命令上下文执行统一保护。
|
|
||||||
|
|
||||||
:param ctx: 已构建的 CLI 上下文
|
|
||||||
:param command_name: 命令唯一标识
|
|
||||||
:return: 通过保护后的 CLI 上下文
|
|
||||||
"""
|
|
||||||
rule = self.dangerous_command_rule_registry.require_rule(command_name)
|
|
||||||
guard_result = self.dangerous_command_guard_service.guard(ctx, rule=rule)
|
|
||||||
if guard_result is not None:
|
|
||||||
self.output_renderer.complete_command(guard_result, ctx)
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliContextFactory:
|
|
||||||
"""
|
|
||||||
统一构建 CLI 命令上下文。
|
|
||||||
|
|
||||||
:param runtime_state: CLI 运行期状态对象
|
|
||||||
:param output_renderer: 输出渲染器
|
|
||||||
:param cli_context_builder: CLI 上下文构建器
|
|
||||||
:param dangerous_command_rule_registry: 危险命令规则注册表
|
|
||||||
:param dangerous_command_guard_service: 危险命令保护执行服务
|
|
||||||
"""
|
|
||||||
|
|
||||||
runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState)
|
|
||||||
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
|
|
||||||
cli_context_builder: CliContextBuilder = field(default_factory=lambda: CLI_CONTEXT_BUILDER)
|
|
||||||
dangerous_command_rule_registry: DangerousCommandRuleRegistry = field(
|
|
||||||
default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY
|
|
||||||
)
|
|
||||||
dangerous_command_guard_service: DangerousCommandGuardService = field(
|
|
||||||
default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_GUARD
|
|
||||||
)
|
|
||||||
_log_policy: CliLogPolicy | None = field(default=None, init=False, repr=False)
|
|
||||||
_dangerous_command_support: DangerousCommandContextSupport | None = field(
|
|
||||||
default=None,
|
|
||||||
init=False,
|
|
||||||
repr=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_log_policy(self) -> CliLogPolicy:
|
|
||||||
"""
|
|
||||||
获取当前上下文工厂使用的日志策略服务。
|
|
||||||
|
|
||||||
:return: CLI 日志策略服务
|
|
||||||
"""
|
|
||||||
if self._log_policy is None:
|
|
||||||
self._log_policy = CliLogPolicy(runtime_state=self.runtime_state)
|
|
||||||
return self._log_policy
|
|
||||||
|
|
||||||
def get_dangerous_command_support(self) -> DangerousCommandContextSupport:
|
|
||||||
"""
|
|
||||||
获取当前上下文工厂使用的危险命令上下文支持服务。
|
|
||||||
|
|
||||||
:return: 危险命令上下文支持服务
|
|
||||||
"""
|
|
||||||
if self._dangerous_command_support is None:
|
|
||||||
self._dangerous_command_support = DangerousCommandContextSupport(
|
|
||||||
dangerous_command_rule_registry=self.dangerous_command_rule_registry,
|
|
||||||
dangerous_command_guard_service=self.dangerous_command_guard_service,
|
|
||||||
output_renderer=self.output_renderer,
|
|
||||||
)
|
|
||||||
return self._dangerous_command_support
|
|
||||||
|
|
||||||
def build_regular(
|
|
||||||
self,
|
|
||||||
env: str,
|
|
||||||
output: str,
|
|
||||||
allow_prod: bool,
|
|
||||||
yes: bool,
|
|
||||||
dry_run: bool,
|
|
||||||
) -> CliContext:
|
|
||||||
"""
|
|
||||||
构建普通命令上下文。
|
|
||||||
|
|
||||||
:param env: 运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过确认
|
|
||||||
:param dry_run: 是否演练执行
|
|
||||||
:return: CLI 上下文
|
|
||||||
"""
|
|
||||||
self.get_log_policy().prepare_regular_command()
|
|
||||||
return self.cli_context_builder.build(env, output, allow_prod, yes, dry_run)
|
|
||||||
|
|
||||||
def build_readonly(
|
|
||||||
self,
|
|
||||||
env: str,
|
|
||||||
output: str,
|
|
||||||
) -> CliContext:
|
|
||||||
"""
|
|
||||||
构建只读命令上下文。
|
|
||||||
|
|
||||||
:param env: 运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: CLI 上下文
|
|
||||||
"""
|
|
||||||
return self.build_regular(env, output, False, False, False)
|
|
||||||
|
|
||||||
def build_dangerous(
|
|
||||||
self,
|
|
||||||
env: str,
|
|
||||||
output: str,
|
|
||||||
allow_prod: bool,
|
|
||||||
yes: bool,
|
|
||||||
dry_run: bool,
|
|
||||||
*,
|
|
||||||
command_name: str,
|
|
||||||
) -> CliContext:
|
|
||||||
"""
|
|
||||||
构建危险命令上下文并执行统一保护。
|
|
||||||
|
|
||||||
:param env: 运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过确认
|
|
||||||
:param dry_run: 是否演练执行
|
|
||||||
:param command_name: 命令唯一标识
|
|
||||||
:return: CLI 上下文
|
|
||||||
"""
|
|
||||||
ctx = self.build_regular(env, output, allow_prod, yes, dry_run)
|
|
||||||
return self.get_dangerous_command_support().guard_context(ctx, command_name=command_name)
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from cli.context import CliContext
|
|
||||||
from cli.exit_codes import RUNTIME_ERROR
|
|
||||||
from cli.output import CommandResult, OutputRenderer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliExecutionService:
|
|
||||||
"""
|
|
||||||
CLI 执行与结果收口服务。
|
|
||||||
|
|
||||||
:param output_renderer: 输出渲染器
|
|
||||||
"""
|
|
||||||
|
|
||||||
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
|
|
||||||
|
|
||||||
def run_async(self, coroutine: Any) -> Any:
|
|
||||||
"""
|
|
||||||
执行异步协程并返回结果。
|
|
||||||
|
|
||||||
:param coroutine: 待执行协程
|
|
||||||
:return: 协程执行结果
|
|
||||||
"""
|
|
||||||
return asyncio.run(coroutine)
|
|
||||||
|
|
||||||
def complete_payload(
|
|
||||||
self,
|
|
||||||
ctx: CliContext,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
default_exit_code: int = 0,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出标准字典负载并结束命令。
|
|
||||||
|
|
||||||
:param ctx: CLI 上下文
|
|
||||||
:param payload: 标准结果字典
|
|
||||||
:param default_exit_code: 默认退出码
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.output_renderer.complete_command(self.build_result(payload, default_exit_code=default_exit_code), ctx)
|
|
||||||
|
|
||||||
def complete_result(
|
|
||||||
self,
|
|
||||||
ctx: CliContext,
|
|
||||||
result: CommandResult,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出命令结果对象并结束命令。
|
|
||||||
|
|
||||||
:param ctx: CLI 上下文
|
|
||||||
:param result: 命令执行结果对象
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.output_renderer.complete_command(result, ctx)
|
|
||||||
|
|
||||||
def complete_payload_with_text(
|
|
||||||
self,
|
|
||||||
ctx: CliContext,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
text_builder: Callable[[dict[str, Any]], Any],
|
|
||||||
default_exit_code: int = 0,
|
|
||||||
text_condition: Callable[[dict[str, Any]], bool] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
按输出格式收口标准负载,必要时先转换为文本结果。
|
|
||||||
|
|
||||||
:param ctx: CLI 上下文
|
|
||||||
:param payload: 标准结果字典
|
|
||||||
:param text_builder: 文本结果构建函数
|
|
||||||
:param default_exit_code: 默认退出码
|
|
||||||
:param text_condition: 文本模式下是否应用构建函数的判定函数
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if ctx.output != 'text':
|
|
||||||
self.complete_payload(ctx, payload, default_exit_code=default_exit_code)
|
|
||||||
return
|
|
||||||
|
|
||||||
result = self.build_result(payload, default_exit_code=default_exit_code)
|
|
||||||
should_build_text = True
|
|
||||||
if callable(text_condition) and isinstance(result.data, dict):
|
|
||||||
should_build_text = bool(text_condition(result.data))
|
|
||||||
if should_build_text and isinstance(result.data, dict):
|
|
||||||
result.data = text_builder(result.data)
|
|
||||||
self.complete_result(ctx, result)
|
|
||||||
|
|
||||||
def complete_payload_result(
|
|
||||||
self,
|
|
||||||
ctx: CliContext,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
text_builder: Callable[[dict[str, Any]], Any] | None = None,
|
|
||||||
default_exit_code: int = 0,
|
|
||||||
text_condition: Callable[[dict[str, Any]], bool] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
统一收口标准负载,按输出格式决定是否渲染为文本结果。
|
|
||||||
|
|
||||||
:param ctx: CLI 上下文
|
|
||||||
:param payload: 标准结果字典
|
|
||||||
:param text_builder: 文本结果构建函数
|
|
||||||
:param default_exit_code: 默认退出码
|
|
||||||
:param text_condition: 文本模式下是否应用构建函数的判定函数
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
if text_builder is None:
|
|
||||||
self.complete_payload(ctx, payload, default_exit_code=default_exit_code)
|
|
||||||
return
|
|
||||||
self.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=text_builder,
|
|
||||||
default_exit_code=default_exit_code,
|
|
||||||
text_condition=text_condition,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_result(payload: dict[str, Any], *, default_exit_code: int = 0) -> CommandResult:
|
|
||||||
"""
|
|
||||||
将标准字典负载转换为命令结果对象。
|
|
||||||
|
|
||||||
:param payload: 标准结果字典
|
|
||||||
:param default_exit_code: 默认退出码;当 payload 未显式提供 exit_code 且
|
|
||||||
该值仍为 0 时,失败结果会统一回退到 `RUNTIME_ERROR`
|
|
||||||
:return: 命令结果对象
|
|
||||||
"""
|
|
||||||
result_payload = dict(payload)
|
|
||||||
exit_code = result_payload.pop('exit_code', None)
|
|
||||||
if exit_code is None:
|
|
||||||
is_ok = bool(result_payload.get('ok', True))
|
|
||||||
exit_code = default_exit_code if is_ok or default_exit_code != 0 else RUNTIME_ERROR
|
|
||||||
return CommandResult(data=result_payload, exit_code=exit_code)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
SUCCESS = 0
|
|
||||||
ARGUMENT_ERROR = 2
|
|
||||||
DEPENDENCY_ERROR = 10
|
|
||||||
DATABASE_ERROR = 20
|
|
||||||
REDIS_ERROR = 21
|
|
||||||
SCHEDULER_ERROR = 22
|
|
||||||
GUARD_REJECTED = 30
|
|
||||||
RUNTIME_ERROR = 50
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .command import app
|
|
||||||
|
|
||||||
__all__ = ['app']
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
from typing import Annotated, Literal
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from cli.context import EnvOption, OutputOption
|
|
||||||
|
|
||||||
from .controller import AppCommandController
|
|
||||||
|
|
||||||
app = typer.Typer(
|
|
||||||
help='应用相关命令',
|
|
||||||
no_args_is_help=True,
|
|
||||||
context_settings={'help_option_names': ['-h', '--help']},
|
|
||||||
)
|
|
||||||
_APP_COMMAND_CONTROLLER = AppCommandController()
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('run', help='启动当前 FastAPI 应用')
|
|
||||||
def run_app(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
启动当前 FastAPI 应用。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_APP_COMMAND_CONTROLLER.run_app(env)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('doctor', help='执行应用启动前检查')
|
|
||||||
def doctor(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
执行应用启动前检查。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_APP_COMMAND_CONTROLLER.doctor(env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('config', help='查看当前应用配置快照')
|
|
||||||
def app_config(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出当前应用配置快照。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_APP_COMMAND_CONTROLLER.show_config(env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('env', help='查看当前 CLI 解析到的应用环境信息')
|
|
||||||
def app_env(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出当前 CLI 解析到的应用环境信息。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_APP_COMMAND_CONTROLLER.show_env(env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('routes', help='查看当前应用注册路由清单')
|
|
||||||
def routes(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
path_prefix: Annotated[str, typer.Option('--path-prefix', help='按路由前缀过滤')] = '',
|
|
||||||
method: Annotated[str, typer.Option('--method', help='按请求方法过滤,如 GET、POST')] = '',
|
|
||||||
group_by: Annotated[Literal['none', 'tag'], typer.Option('--group-by', help='路由分组方式')] = 'none',
|
|
||||||
include_hidden: Annotated[bool, typer.Option('--include-hidden', help='包含未出现在 OpenAPI 中的路由')] = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出当前应用注册路由清单。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param path_prefix: 路由前缀过滤条件
|
|
||||||
:param method: 请求方法过滤条件
|
|
||||||
:param group_by: 路由分组方式
|
|
||||||
:param include_hidden: 是否包含隐藏路由
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_APP_COMMAND_CONTROLLER.show_routes(
|
|
||||||
env,
|
|
||||||
output,
|
|
||||||
path_prefix=path_prefix,
|
|
||||||
method=method,
|
|
||||||
group_by=group_by,
|
|
||||||
include_hidden=include_hidden,
|
|
||||||
)
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
from cli.bootstrap import APP_BOOTSTRAP, AppBootstrapService
|
|
||||||
from cli.core import (
|
|
||||||
DEFAULT_CORE_SERVICES,
|
|
||||||
CliContextFactory,
|
|
||||||
CliExecutionService,
|
|
||||||
)
|
|
||||||
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
|
|
||||||
from cli.runtime.app import APP_RUNTIME, AppRuntimeService
|
|
||||||
from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService
|
|
||||||
from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService
|
|
||||||
from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService
|
|
||||||
|
|
||||||
from .presenter import AppCommandPresenter
|
|
||||||
|
|
||||||
|
|
||||||
class AppCommandController:
|
|
||||||
"""
|
|
||||||
应用命令控制器。
|
|
||||||
|
|
||||||
该控制器负责组织 `app` 命令组的上下文准备、runtime 调用、
|
|
||||||
payload 构建,以及基于输出格式选择 presenter 或直接返回 JSON。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
context_factory: CliContextFactory | None = None,
|
|
||||||
execution_service: CliExecutionService | None = None,
|
|
||||||
presenter: AppCommandPresenter | None = None,
|
|
||||||
runtime_service: AppRuntimeService | None = None,
|
|
||||||
database_runtime: DatabaseRuntimeService | None = None,
|
|
||||||
operations_runtime: OperationsRuntimeService | None = None,
|
|
||||||
crypto_runtime: CryptoRuntimeService | None = None,
|
|
||||||
bootstrap_service: AppBootstrapService | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
初始化应用命令控制器。
|
|
||||||
|
|
||||||
:param context_factory: CLI 上下文工厂
|
|
||||||
:param execution_service: CLI 执行服务
|
|
||||||
:param presenter: 应用命令文本渲染器
|
|
||||||
:param runtime_service: 应用运行时服务
|
|
||||||
:param database_runtime: 数据库运行时服务
|
|
||||||
:param operations_runtime: 运维运行时服务
|
|
||||||
:param crypto_runtime: 传输加密运行时服务
|
|
||||||
:param bootstrap_service: 应用引导服务
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
|
||||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
|
||||||
self.presenter = presenter or AppCommandPresenter()
|
|
||||||
self.runtime_service = runtime_service or APP_RUNTIME
|
|
||||||
self.database_runtime = database_runtime or DATABASE_RUNTIME
|
|
||||||
self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME
|
|
||||||
self.crypto_runtime = crypto_runtime or CRYPTO_RUNTIME
|
|
||||||
self.bootstrap_service = bootstrap_service or APP_BOOTSTRAP
|
|
||||||
|
|
||||||
def run_app(self, env: str) -> None:
|
|
||||||
"""
|
|
||||||
启动当前 FastAPI 应用。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.bootstrap_service.exec_app_run_command(env)
|
|
||||||
|
|
||||||
def doctor(self, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
执行应用启动前检查。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
db_status = self.execution_service.run_async(self.database_runtime.ping_database())
|
|
||||||
redis_status = self.execution_service.run_async(self.operations_runtime.ping_redis())
|
|
||||||
crypto_status = self.crypto_runtime.validate_crypto_config()
|
|
||||||
payload = {
|
|
||||||
'env': ctx.env,
|
|
||||||
'database': db_status,
|
|
||||||
'redis': redis_status,
|
|
||||||
'crypto': crypto_status,
|
|
||||||
}
|
|
||||||
payload['ok'] = all(item.get('ok', False) for item in (db_status, redis_status, crypto_status))
|
|
||||||
exit_code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR
|
|
||||||
self.execution_service.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_doctor_text,
|
|
||||||
default_exit_code=exit_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
def show_config(self, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
输出当前应用配置快照。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
self.execution_service.complete_payload_result(
|
|
||||||
ctx,
|
|
||||||
{'ok': True, 'env': ctx.env, 'config': self.runtime_service.get_app_config_snapshot()},
|
|
||||||
text_builder=self.presenter.build_app_config_text,
|
|
||||||
default_exit_code=SUCCESS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def show_env(self, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
输出当前 CLI 解析到的应用环境信息。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
self.execution_service.complete_payload_result(
|
|
||||||
ctx,
|
|
||||||
{'ok': True, 'env': ctx.env, 'runtime': self.runtime_service.get_app_env_snapshot()},
|
|
||||||
text_builder=self.presenter.build_app_env_text,
|
|
||||||
default_exit_code=SUCCESS,
|
|
||||||
)
|
|
||||||
|
|
||||||
def show_routes(
|
|
||||||
self,
|
|
||||||
env: str,
|
|
||||||
output: str,
|
|
||||||
*,
|
|
||||||
path_prefix: str,
|
|
||||||
method: str,
|
|
||||||
group_by: str,
|
|
||||||
include_hidden: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
输出当前应用注册路由清单。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param path_prefix: 路由前缀过滤条件
|
|
||||||
:param method: 请求方法过滤条件
|
|
||||||
:param group_by: 路由分组方式
|
|
||||||
:param include_hidden: 是否包含隐藏路由
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
routes_payload = self._serialize_routes(
|
|
||||||
self.runtime_service.build_app_instance(),
|
|
||||||
path_prefix=path_prefix,
|
|
||||||
method=method,
|
|
||||||
include_hidden=include_hidden,
|
|
||||||
)
|
|
||||||
grouped_routes = self._group_routes_by_tag(routes_payload) if group_by == 'tag' else None
|
|
||||||
payload = {
|
|
||||||
'ok': True,
|
|
||||||
'env': ctx.env,
|
|
||||||
'count': len(routes_payload),
|
|
||||||
'filters': {
|
|
||||||
'pathPrefix': path_prefix,
|
|
||||||
'method': method.upper().strip(),
|
|
||||||
'groupBy': group_by,
|
|
||||||
'includeHidden': include_hidden,
|
|
||||||
},
|
|
||||||
'routes': routes_payload,
|
|
||||||
'groupedRoutes': grouped_routes,
|
|
||||||
}
|
|
||||||
self.execution_service.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_routes_text,
|
|
||||||
default_exit_code=SUCCESS,
|
|
||||||
text_condition=lambda data: data.get('ok', False),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _serialize_routes(
|
|
||||||
app_instance: Any,
|
|
||||||
*,
|
|
||||||
path_prefix: str = '',
|
|
||||||
method: str = '',
|
|
||||||
include_hidden: bool = False,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
序列化 FastAPI 路由信息。
|
|
||||||
|
|
||||||
:param app_instance: FastAPI 应用实例
|
|
||||||
:param path_prefix: 路径前缀过滤条件
|
|
||||||
:param method: 请求方法过滤条件
|
|
||||||
:param include_hidden: 是否包含未出现在 OpenAPI 中的路由
|
|
||||||
:return: 序列化后的路由列表
|
|
||||||
"""
|
|
||||||
normalized_method = method.upper().strip()
|
|
||||||
routes = []
|
|
||||||
for route in app_instance.routes:
|
|
||||||
if not isinstance(route, APIRoute):
|
|
||||||
continue
|
|
||||||
if not include_hidden and not route.include_in_schema:
|
|
||||||
continue
|
|
||||||
if path_prefix and not route.path.startswith(path_prefix):
|
|
||||||
continue
|
|
||||||
route_methods = sorted(item for item in route.methods if item not in {'HEAD', 'OPTIONS'})
|
|
||||||
if normalized_method and normalized_method not in route_methods:
|
|
||||||
continue
|
|
||||||
routes.append(
|
|
||||||
{
|
|
||||||
'path': route.path,
|
|
||||||
'methods': route_methods,
|
|
||||||
'name': route.name,
|
|
||||||
'summary': route.summary or '',
|
|
||||||
'operationId': route.operation_id or '',
|
|
||||||
'tags': route.tags or [],
|
|
||||||
'includeInSchema': route.include_in_schema,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return sorted(routes, key=lambda item: (item['path'], ','.join(item['methods'])))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _group_routes_by_tag(routes_payload: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
按标签对路由信息分组。
|
|
||||||
|
|
||||||
:param routes_payload: 原始路由列表
|
|
||||||
:return: 按标签分组后的路由映射
|
|
||||||
"""
|
|
||||||
grouped_routes: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
||||||
for route in routes_payload:
|
|
||||||
tags = route.get('tags') or ['__untagged__']
|
|
||||||
for tag in tags:
|
|
||||||
grouped_routes[tag].append(route)
|
|
||||||
return dict(sorted(grouped_routes.items(), key=lambda item: item[0]))
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from cli.utils import SHELL_TEXT_FORMATTER
|
|
||||||
|
|
||||||
|
|
||||||
class AppCommandPresenter:
|
|
||||||
"""
|
|
||||||
应用命令文本渲染器。
|
|
||||||
|
|
||||||
该渲染器负责将 `app` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
|
||||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def build_routes_text(self, payload: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
将路由列表结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 路由列表结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
lines = [
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'env: {payload.get("env", "")}',
|
|
||||||
f'count: {payload.get("count", 0)}',
|
|
||||||
]
|
|
||||||
filters = payload.get('filters')
|
|
||||||
if isinstance(filters, dict):
|
|
||||||
lines.extend(self._build_route_filter_lines(filters))
|
|
||||||
|
|
||||||
grouped_routes = payload.get('groupedRoutes')
|
|
||||||
if filters and filters.get('groupBy') == 'tag' and isinstance(grouped_routes, dict):
|
|
||||||
lines.extend(self._build_grouped_routes_text(grouped_routes))
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
routes = payload.get('routes')
|
|
||||||
if not isinstance(routes, list) or not routes:
|
|
||||||
lines.append('routes: none')
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
lines.append('routes:')
|
|
||||||
for route in routes:
|
|
||||||
if isinstance(route, dict):
|
|
||||||
lines.extend([f' {line}' for line in self._build_route_item_lines(route)])
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
def build_app_config_text(self, payload: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
将应用配置快照渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 应用配置结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
config = payload.get('config')
|
|
||||||
if not isinstance(config, dict):
|
|
||||||
return '\n'.join(
|
|
||||||
[
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'env: {payload.get("env", "")}',
|
|
||||||
'config: none',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'env: {payload.get("env", "")}',
|
|
||||||
'application:',
|
|
||||||
f' name: {config.get("name", "-")}',
|
|
||||||
f' host: {config.get("host", "-")}:{config.get("port", "-")}',
|
|
||||||
f' root_path: {config.get("rootPath", "-") or "/"}',
|
|
||||||
f' reload: {str(config.get("reload", False)).lower()}',
|
|
||||||
f' workers: {config.get("workers", "-")}',
|
|
||||||
f' disable_swagger: {str(config.get("disableSwagger", False)).lower()}',
|
|
||||||
f' disable_redoc: {str(config.get("disableRedoc", False)).lower()}',
|
|
||||||
'database:',
|
|
||||||
f' type: {config.get("dbType", "-")}',
|
|
||||||
f' host: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
|
|
||||||
f' name: {config.get("dbDatabase", "-")}',
|
|
||||||
'redis:',
|
|
||||||
f' host: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}',
|
|
||||||
'logging:',
|
|
||||||
f' level: {config.get("logLevel", "-")}',
|
|
||||||
'transport_crypto:',
|
|
||||||
f' enabled: {str(config.get("transportCryptoEnabled", False)).lower()}',
|
|
||||||
f' mode: {config.get("transportCryptoMode", "-")}',
|
|
||||||
]
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
def build_app_env_text(self, payload: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
将应用环境解析结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 应用环境结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
runtime = payload.get('runtime')
|
|
||||||
if not isinstance(runtime, dict):
|
|
||||||
return '\n'.join(
|
|
||||||
[
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'env: {payload.get("env", "")}',
|
|
||||||
'runtime: none',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return '\n'.join(
|
|
||||||
[
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'env: {payload.get("env", "")}',
|
|
||||||
'runtime:',
|
|
||||||
f' cli_env: {runtime.get("cliEnv", "-")}',
|
|
||||||
f' config_env: {runtime.get("configEnv", "-")}',
|
|
||||||
f' app_env: {runtime.get("appEnv", "-")}',
|
|
||||||
f' env_file: {runtime.get("envFile", "-")}',
|
|
||||||
f' env_file_exists: {str(runtime.get("envFileExists", False)).lower()}',
|
|
||||||
f' backend_dir: {runtime.get("backendDir", "-")}',
|
|
||||||
f' python_executable: {runtime.get("pythonExecutable", "-")}',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def build_doctor_text(self, payload: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
将应用启动前检查结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 启动前检查结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
return '\n'.join(
|
|
||||||
[
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'env: {payload.get("env", "")}',
|
|
||||||
'checks:',
|
|
||||||
self._build_check_status_line('database', payload.get('database')),
|
|
||||||
self._build_check_status_line('redis', payload.get('redis')),
|
|
||||||
self._build_check_status_line('crypto', payload.get('crypto')),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_route_filter_lines(filters: dict[str, object]) -> list[str]:
|
|
||||||
"""
|
|
||||||
构建路由过滤条件文本行。
|
|
||||||
|
|
||||||
:param filters: 过滤条件字典
|
|
||||||
:return: 过滤条件文本行列表
|
|
||||||
"""
|
|
||||||
active_filters = []
|
|
||||||
for key, value in filters.items():
|
|
||||||
if value in (None, '', False, 'none'):
|
|
||||||
continue
|
|
||||||
active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}')
|
|
||||||
if not active_filters:
|
|
||||||
return ['filters: none']
|
|
||||||
return ['filters:', *[f' - {item}' for item in active_filters]]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_route_item_lines(route_item: dict[str, Any]) -> list[str]:
|
|
||||||
"""
|
|
||||||
构建单条路由记录的文本行。
|
|
||||||
|
|
||||||
:param route_item: 单条路由记录
|
|
||||||
:return: 文本行列表
|
|
||||||
"""
|
|
||||||
methods = ','.join(route_item.get('methods', [])) or '-'
|
|
||||||
path = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('path', ''), 72)
|
|
||||||
tags = ','.join(route_item.get('tags', [])) or '-'
|
|
||||||
summary = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('summary', ''), 60)
|
|
||||||
name = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('name', ''), 40)
|
|
||||||
return [
|
|
||||||
f'- [{methods}] {path}',
|
|
||||||
f' name: {name or "-"} | tags: {tags}',
|
|
||||||
f' summary: {summary or "-"}',
|
|
||||||
]
|
|
||||||
|
|
||||||
def _build_grouped_routes_text(self, grouped_routes: dict[str, Any]) -> list[str]:
|
|
||||||
"""
|
|
||||||
将按标签分组后的路由结果渲染为文本行。
|
|
||||||
|
|
||||||
:param grouped_routes: 按标签分组后的路由映射
|
|
||||||
:return: 文本行列表
|
|
||||||
"""
|
|
||||||
if not grouped_routes:
|
|
||||||
return ['groups: none']
|
|
||||||
|
|
||||||
lines = [f'groups: {len(grouped_routes)}']
|
|
||||||
for tag, routes in grouped_routes.items():
|
|
||||||
route_items = routes if isinstance(routes, list) else []
|
|
||||||
lines.append(f' {tag}: {len(route_items)}')
|
|
||||||
for route in route_items:
|
|
||||||
if isinstance(route, dict):
|
|
||||||
lines.extend([f' {line}' for line in self._build_route_item_lines(route)])
|
|
||||||
return lines
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_check_status_line(name: str, status_payload: dict[str, Any] | None) -> str:
|
|
||||||
"""
|
|
||||||
构建单项检查结果摘要行。
|
|
||||||
|
|
||||||
:param name: 检查项名称
|
|
||||||
:param status_payload: 检查结果字典
|
|
||||||
:return: 摘要行文本
|
|
||||||
"""
|
|
||||||
if not isinstance(status_payload, dict):
|
|
||||||
return f' {name}: unknown'
|
|
||||||
|
|
||||||
ok = str(status_payload.get('ok', False)).lower()
|
|
||||||
message = status_payload.get('message', '-') or '-'
|
|
||||||
error = status_payload.get('error')
|
|
||||||
if error:
|
|
||||||
return f' {name}: {ok} | {message} | error: {SHELL_TEXT_FORMATTER.truncate_text(error, 120)}'
|
|
||||||
return f' {name}: {ok} | {message}'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .command import app
|
|
||||||
|
|
||||||
__all__ = ['app']
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import typer
|
|
||||||
|
|
||||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
|
||||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
|
||||||
|
|
||||||
from .controller import CacheCommandController
|
|
||||||
|
|
||||||
app = typer.Typer(
|
|
||||||
help='缓存相关命令',
|
|
||||||
no_args_is_help=True,
|
|
||||||
context_settings={'help_option_names': ['-h', '--help']},
|
|
||||||
)
|
|
||||||
_CACHE_COMMAND_CONTROLLER = CacheCommandController()
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('stats', help='查看缓存统计信息')
|
|
||||||
def stats(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
查看缓存统计信息。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_CACHE_COMMAND_CONTROLLER.stats(env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('keys', help='查看指定缓存名称下的键名列表')
|
|
||||||
def keys(
|
|
||||||
cache_name: str = typer.Argument(
|
|
||||||
...,
|
|
||||||
help='缓存名称',
|
|
||||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
|
||||||
),
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
查看指定缓存名称下的键名列表。
|
|
||||||
|
|
||||||
:param cache_name: 缓存名称
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_CACHE_COMMAND_CONTROLLER.keys(cache_name, env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('get', help='查看指定缓存内容')
|
|
||||||
def get(
|
|
||||||
cache_name: str = typer.Argument(
|
|
||||||
...,
|
|
||||||
help='缓存名称',
|
|
||||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
|
||||||
),
|
|
||||||
cache_key: str = typer.Argument(
|
|
||||||
...,
|
|
||||||
help='缓存键名',
|
|
||||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys,
|
|
||||||
),
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
查看指定缓存内容。
|
|
||||||
|
|
||||||
:param cache_name: 缓存名称
|
|
||||||
:param cache_key: 缓存键名
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_CACHE_COMMAND_CONTROLLER.get(cache_name, cache_key, env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('clear', help='执行缓存清理')
|
|
||||||
def clear(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
allow_prod: AllowProdOption = False,
|
|
||||||
yes: YesOption = False,
|
|
||||||
dry_run: DryRunOption = False,
|
|
||||||
cache_name: str = typer.Option(
|
|
||||||
'',
|
|
||||||
'--cache-name',
|
|
||||||
help='按缓存名称前缀清理',
|
|
||||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
|
||||||
),
|
|
||||||
cache_key: str = typer.Option('', '--cache-key', help='按缓存键名模糊清理'),
|
|
||||||
clear_all: bool = typer.Option(False, '--all', help='清理全部缓存并重建系统基础缓存'),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
清理缓存。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过确认
|
|
||||||
:param dry_run: 是否演练执行
|
|
||||||
:param cache_name: 缓存名称前缀
|
|
||||||
:param cache_key: 缓存键名
|
|
||||||
:param clear_all: 是否清理全部缓存
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_CACHE_COMMAND_CONTROLLER.clear(
|
|
||||||
env,
|
|
||||||
output,
|
|
||||||
allow_prod,
|
|
||||||
yes,
|
|
||||||
dry_run,
|
|
||||||
cache_name=cache_name,
|
|
||||||
cache_key=cache_key,
|
|
||||||
clear_all=clear_all,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('ttl', help='查看指定缓存键的剩余过期时间')
|
|
||||||
def ttl(
|
|
||||||
cache_name: str = typer.Argument(
|
|
||||||
...,
|
|
||||||
help='缓存名称',
|
|
||||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
|
||||||
),
|
|
||||||
cache_key: str = typer.Argument(
|
|
||||||
...,
|
|
||||||
help='缓存键名',
|
|
||||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys,
|
|
||||||
),
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
查看指定缓存键的剩余过期时间。
|
|
||||||
|
|
||||||
:param cache_name: 缓存名称
|
|
||||||
:param cache_key: 缓存键名
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_CACHE_COMMAND_CONTROLLER.ttl(cache_name, cache_key, env, output)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command('warmup', help='执行系统缓存预热')
|
|
||||||
def warmup(
|
|
||||||
env: EnvOption = 'dev',
|
|
||||||
output: OutputOption = 'text',
|
|
||||||
allow_prod: AllowProdOption = False,
|
|
||||||
yes: YesOption = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
执行系统缓存预热。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过确认
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
_CACHE_COMMAND_CONTROLLER.warmup(env, output, allow_prod, yes)
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
from cli.core import (
|
|
||||||
DEFAULT_CORE_SERVICES,
|
|
||||||
CliContextFactory,
|
|
||||||
CliExecutionService,
|
|
||||||
)
|
|
||||||
from cli.runtime.cache import CACHE_RUNTIME, CacheRuntimeService
|
|
||||||
|
|
||||||
from .presenter import CacheCommandPresenter
|
|
||||||
|
|
||||||
|
|
||||||
class CacheCommandController:
|
|
||||||
"""
|
|
||||||
缓存命令控制器。
|
|
||||||
|
|
||||||
该控制器负责组织 `cache` 命令组的上下文准备、runtime 调用、
|
|
||||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
|
||||||
|
|
||||||
:param context_factory: CLI 上下文工厂
|
|
||||||
:param execution_service: CLI 执行服务
|
|
||||||
:param presenter: 缓存命令文本渲染器
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
context_factory: CliContextFactory | None = None,
|
|
||||||
execution_service: CliExecutionService | None = None,
|
|
||||||
presenter: CacheCommandPresenter | None = None,
|
|
||||||
runtime_service: CacheRuntimeService | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
初始化缓存命令控制器。
|
|
||||||
|
|
||||||
:param context_factory: CLI 上下文工厂
|
|
||||||
:param execution_service: CLI 执行服务
|
|
||||||
:param presenter: 缓存命令文本渲染器
|
|
||||||
:param runtime_service: 缓存运行时服务
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
|
||||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
|
||||||
self.presenter = presenter or CacheCommandPresenter()
|
|
||||||
self.runtime_service = runtime_service or CACHE_RUNTIME
|
|
||||||
|
|
||||||
def stats(self, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
查看缓存统计信息。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
payload = self.execution_service.run_async(self.runtime_service.get_cache_stats())
|
|
||||||
self.execution_service.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_cache_stats_text,
|
|
||||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def keys(self, cache_name: str, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
查看指定缓存名称下的键名列表。
|
|
||||||
|
|
||||||
:param cache_name: 缓存名称
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
payload = self.execution_service.run_async(self.runtime_service.list_cache_keys(cache_name))
|
|
||||||
self.execution_service.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_cache_keys_text,
|
|
||||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, cache_name: str, cache_key: str, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
查看指定缓存内容。
|
|
||||||
|
|
||||||
:param cache_name: 缓存名称
|
|
||||||
:param cache_key: 缓存键名
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
payload = self.execution_service.run_async(self.runtime_service.get_cache_value(cache_name, cache_key))
|
|
||||||
self.execution_service.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_cache_value_text,
|
|
||||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def clear(
|
|
||||||
self,
|
|
||||||
env: str,
|
|
||||||
output: str,
|
|
||||||
allow_prod: bool,
|
|
||||||
yes: bool,
|
|
||||||
dry_run: bool,
|
|
||||||
*,
|
|
||||||
cache_name: str,
|
|
||||||
cache_key: str,
|
|
||||||
clear_all: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
清理缓存。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过确认
|
|
||||||
:param dry_run: 是否演练执行
|
|
||||||
:param cache_name: 缓存名称前缀
|
|
||||||
:param cache_key: 缓存键名
|
|
||||||
:param clear_all: 是否清理全部缓存
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_dangerous(
|
|
||||||
env,
|
|
||||||
output,
|
|
||||||
allow_prod,
|
|
||||||
yes,
|
|
||||||
dry_run,
|
|
||||||
command_name='cache clear',
|
|
||||||
)
|
|
||||||
self.execution_service.complete_payload(
|
|
||||||
ctx,
|
|
||||||
self.execution_service.run_async(
|
|
||||||
self.runtime_service.clear_cache(
|
|
||||||
cache_name=cache_name,
|
|
||||||
cache_key=cache_key,
|
|
||||||
clear_all=clear_all,
|
|
||||||
dry_run=dry_run,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def ttl(self, cache_name: str, cache_key: str, env: str, output: str) -> None:
|
|
||||||
"""
|
|
||||||
查看指定缓存键的剩余过期时间。
|
|
||||||
|
|
||||||
:param cache_name: 缓存名称
|
|
||||||
:param cache_key: 缓存键名
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_readonly(env, output)
|
|
||||||
payload = self.execution_service.run_async(self.runtime_service.get_cache_ttl(cache_name, cache_key))
|
|
||||||
self.execution_service.complete_payload_with_text(
|
|
||||||
ctx,
|
|
||||||
payload,
|
|
||||||
text_builder=self.presenter.build_cache_ttl_text,
|
|
||||||
text_condition=lambda result_data: 'error' not in result_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
def warmup(self, env: str, output: str, allow_prod: bool, yes: bool) -> None:
|
|
||||||
"""
|
|
||||||
执行系统缓存预热。
|
|
||||||
|
|
||||||
:param env: 当前命令运行环境
|
|
||||||
:param output: 输出格式
|
|
||||||
:param allow_prod: 是否允许生产环境危险命令
|
|
||||||
:param yes: 是否跳过确认
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
ctx = self.context_factory.build_dangerous(
|
|
||||||
env,
|
|
||||||
output,
|
|
||||||
allow_prod,
|
|
||||||
yes,
|
|
||||||
False,
|
|
||||||
command_name='cache warmup',
|
|
||||||
)
|
|
||||||
self.execution_service.complete_payload(
|
|
||||||
ctx, self.execution_service.run_async(self.runtime_service.warmup_cache())
|
|
||||||
)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
from cli.utils import SHELL_TEXT_FORMATTER
|
|
||||||
|
|
||||||
|
|
||||||
class CacheCommandPresenter:
|
|
||||||
"""
|
|
||||||
缓存命令文本渲染器。
|
|
||||||
|
|
||||||
该渲染器负责将 `cache` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
|
||||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def build_cache_stats_text(self, payload: dict[str, object]) -> str:
|
|
||||||
"""
|
|
||||||
将缓存统计结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 缓存统计结果
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
info = payload.get('info')
|
|
||||||
command_stats = payload.get('commandStats')
|
|
||||||
cache_names = payload.get('cacheNames')
|
|
||||||
|
|
||||||
redis_version = '-'
|
|
||||||
connected_clients = '-'
|
|
||||||
used_memory = '-'
|
|
||||||
uptime_seconds = '-'
|
|
||||||
keyspace_hits = '-'
|
|
||||||
keyspace_misses = '-'
|
|
||||||
if isinstance(info, dict):
|
|
||||||
redis_version = info.get('redis_version', '-')
|
|
||||||
connected_clients = info.get('connected_clients', '-')
|
|
||||||
used_memory = info.get('used_memory_human', info.get('used_memory', '-'))
|
|
||||||
uptime_seconds = info.get('uptime_in_seconds', '-')
|
|
||||||
keyspace_hits = info.get('keyspace_hits', '-')
|
|
||||||
keyspace_misses = info.get('keyspace_misses', '-')
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'db_size: {payload.get("dbSize", 0)}',
|
|
||||||
'redis:',
|
|
||||||
f' version: {redis_version}',
|
|
||||||
f' connected_clients: {connected_clients}',
|
|
||||||
f' used_memory: {used_memory}',
|
|
||||||
f' uptime_seconds: {uptime_seconds}',
|
|
||||||
f' keyspace_hits: {keyspace_hits}',
|
|
||||||
f' keyspace_misses: {keyspace_misses}',
|
|
||||||
]
|
|
||||||
|
|
||||||
if isinstance(command_stats, list) and command_stats:
|
|
||||||
lines.append('command_stats_top10:')
|
|
||||||
lines.extend(
|
|
||||||
f' - {item.get("name", "-")}: {item.get("value", 0)}'
|
|
||||||
for item in command_stats[:10]
|
|
||||||
if isinstance(item, dict)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
lines.append('command_stats_top10: none')
|
|
||||||
|
|
||||||
if isinstance(cache_names, list) and cache_names:
|
|
||||||
lines.append(f'cache_names: {len(cache_names)}')
|
|
||||||
lines.append('cache_name_samples:')
|
|
||||||
lines.extend(
|
|
||||||
f' - {item.get("cacheName", "-")}: {self._build_cache_name_remark(item.get("remark", ""))}'
|
|
||||||
for item in cache_names[:10]
|
|
||||||
if isinstance(item, dict)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
lines.append('cache_names: 0')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
def build_cache_keys_text(self, payload: dict[str, object]) -> str:
|
|
||||||
"""
|
|
||||||
将缓存键列表结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 缓存键列表结果
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
keys = payload.get('keys')
|
|
||||||
lines = [
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'cache_name: {payload.get("cacheName", "")}',
|
|
||||||
f'count: {payload.get("count", 0)}',
|
|
||||||
]
|
|
||||||
if not isinstance(keys, list) or not keys:
|
|
||||||
lines.append('keys: none')
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
lines.append('keys:')
|
|
||||||
lines.extend(f' - {key}' for key in keys)
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
def build_cache_value_text(self, payload: dict[str, object]) -> str:
|
|
||||||
"""
|
|
||||||
将缓存值读取结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 缓存值读取结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
cache_value = '' if payload.get('cacheValue') is None else str(payload.get('cacheValue'))
|
|
||||||
cache_value_lines = cache_value.splitlines() if cache_value else []
|
|
||||||
if not cache_value_lines:
|
|
||||||
rendered_value_lines = [' -']
|
|
||||||
elif len(cache_value_lines) == 1:
|
|
||||||
rendered_value_lines = [f' {SHELL_TEXT_FORMATTER.truncate_text(cache_value_lines[0], 200)}']
|
|
||||||
else:
|
|
||||||
rendered_value_lines = [' |', *[f' {line}' for line in cache_value_lines]]
|
|
||||||
|
|
||||||
return '\n'.join(
|
|
||||||
[
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'cache_name: {payload.get("cacheName", "")}',
|
|
||||||
f'cache_key: {payload.get("cacheKey", "")}',
|
|
||||||
f'full_cache_key: {payload.get("fullCacheKey", "")}',
|
|
||||||
'cache_value:',
|
|
||||||
*rendered_value_lines,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_cache_ttl_text(payload: dict[str, object]) -> str:
|
|
||||||
"""
|
|
||||||
将缓存 TTL 结果渲染为文本摘要。
|
|
||||||
|
|
||||||
:param payload: 缓存 TTL 结果字典
|
|
||||||
:return: 文本摘要
|
|
||||||
"""
|
|
||||||
return '\n'.join(
|
|
||||||
[
|
|
||||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
|
||||||
f'cache_name: {payload.get("cacheName", "")}',
|
|
||||||
f'cache_key: {payload.get("cacheKey", "")}',
|
|
||||||
f'full_cache_key: {payload.get("fullCacheKey", "")}',
|
|
||||||
f'message: {payload.get("message", "-")}',
|
|
||||||
f'ttl_seconds: {payload.get("ttlSeconds", "-")}',
|
|
||||||
f'persistent: {str(payload.get("persistent", False)).lower()}',
|
|
||||||
f'expires: {str(payload.get("expires", False)).lower()}',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_cache_name_remark(remark: object) -> str:
|
|
||||||
"""
|
|
||||||
规范化缓存名称备注文本。
|
|
||||||
|
|
||||||
:param remark: 原始备注
|
|
||||||
:return: 规范化后的备注
|
|
||||||
"""
|
|
||||||
text = '' if remark is None else str(remark).strip()
|
|
||||||
return text or '-'
|
|
||||||
@@ -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