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
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
除 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md) 或具体文件另有说明的
|
||||
第三方代码外,本仓库中的源代码、业务逻辑、界面设计、文档及其他原创内容
|
||||
均为施尔丰国际生物科技有限公司的专有内容。
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
未经版权持有人书面许可,不得复制、修改、分发、再许可或将上述原创内容
|
||||
用于其他项目或商业用途。
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
本文件不改变第三方代码所适用的原始许可证。RuoYi 来源代码的版权声明和
|
||||
MIT License 请参阅 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md)。
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
* 前端采用Vue3、Element Plus。
|
||||
* 移动端采用uni-app、Vue3、Vite,内置tailwindcss。
|
||||
* 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。
|
||||
* 后端采用FastAPI、sqlalchemy、MySQL、Redis、OAuth2 & Jwt。
|
||||
* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。
|
||||
* 支持加载动态权限菜单,多方式轻松权限控制。
|
||||
* 纯Python版本:
|
||||
@@ -43,14 +43,24 @@
|
||||
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
|
||||
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
|
||||
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
|
||||
* Android PDA 可直接通过浏览器使用响应式 Web 页面,并支持 HID 扫码枪扫码后回车提交。
|
||||
* Android PDA 可通过基于 uni-app 的 H5 页面使用,并支持 HID 扫码枪扫码后回车提交。
|
||||
|
||||
本轮交付不是 APP/uni-app 原生端,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./docs/pallet-workflow-deployment.md)。
|
||||
本轮交付包含 PDA H5 页面,但暂不打包 APK 或其他原生安装包,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./shierfeng-fastapi-backend/sql/pallet-workflow-deployment.md)。
|
||||
|
||||
测试数据批次的创建、授权、历史数据纳入和清理流程请阅读[测试数据批次操作说明](./shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。
|
||||
|
||||
数据库初始化与升级入口请阅读 [SQL 使用说明](./shierfeng-fastapi-backend/sql/README.md)。新空库直接使用已包含隧道、物料日期、收货和 FIFO 结构的初始化 SQL;已有生产库不得运行全量初始化,请按[生产 Compose 增量迁移说明](./shierfeng-fastapi-backend/sql/production-compose-material-flow-migration-20261008.md)核对适用条件后执行。
|
||||
|
||||
### Git 跟踪范围
|
||||
|
||||
根目录 `.idea/`、`.pnpm-store/`、`.superpowers/`、`docs/`、`logs/` 及后端 `logs/` 仅保留本地,不再提交;Python 缓存及本地依赖同样忽略。必要的业务部署与数据库迁移说明随后端 `sql/` 目录维护。代码、迁移 SQL、测试和必要部署配置仍正常跟踪。
|
||||
|
||||
其他已有克隆首次拉取本次清理提交前,应先备份这些目录中需要保留的文件:Git 可能删除此前被跟踪的副本。此次清理不重写历史,旧提交中的文件仍然存在。
|
||||
|
||||
## 在线体验
|
||||
|
||||
* *账号:admin*
|
||||
* *密码:admin123*
|
||||
* *密码:请通过授权渠道获取,不在仓库中记录*
|
||||
* 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
|
||||
|
||||
## 项目开发及发布相关
|
||||
@@ -110,17 +120,15 @@ pnpm dev:mp-weixin
|
||||
# 进入后端目录
|
||||
cd shierfeng-fastapi-backend
|
||||
|
||||
# 如果使用的是MySQL数据库,请执行以下命令安装项目依赖环境
|
||||
# 安装项目依赖环境(统一使用 MySQL)
|
||||
pip3 install -r requirements.txt
|
||||
# 如果使用的是PostgreSQL数据库,请执行以下命令安装项目依赖环境
|
||||
pip3 install -r requirements-pg.txt
|
||||
|
||||
# 配置环境
|
||||
在.env.dev文件中配置开发环境的数据库和redis
|
||||
|
||||
# 运行sql文件
|
||||
1.新建数据库shierfeng-fastapi(默认,可修改)
|
||||
2.如果使用的是MySQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi.sql;如果使用的是PostgreSQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi-pg.sql
|
||||
# 运行 SQL 文件
|
||||
1. 新建数据库 shierfeng-fastapi(默认,可修改)
|
||||
2. 使用命令或数据库连接工具运行 sql 文件夹下的 shierfeng-fastapi.sql
|
||||
|
||||
# 运行后端
|
||||
shierfeng app run --env=dev
|
||||
@@ -131,12 +139,12 @@ shierfeng app run --env=dev
|
||||
#### 访问
|
||||
|
||||
```bash
|
||||
# 默认账号密码
|
||||
# 本地账号
|
||||
账号:admin
|
||||
密码:admin123
|
||||
密码:请在目标环境安全配置中设置后手工输入,不在仓库中记录
|
||||
|
||||
# 浏览器访问
|
||||
地址:http://localhost:80
|
||||
地址:http://localhost:8080
|
||||
```
|
||||
|
||||
### 发布
|
||||
@@ -163,16 +171,25 @@ shierfeng app run --env=prod
|
||||
|
||||
### Docker Compose部署方式
|
||||
|
||||
> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化
|
||||
> ⚠️ **说明:** 根目录 Compose 使用 MySQL 和 Redis 命名卷持久化数据。初始化 SQL 只会在 MySQL 数据目录首次为空时执行。
|
||||
|
||||
#### MySQL版本
|
||||
#### MySQL 全栈版本
|
||||
|
||||
```bash
|
||||
docker compose -f shierfeng-fastapi-test/docker-compose.test.my.yml up -d --build
|
||||
# 首次使用时创建本地配置文件
|
||||
cp .env.docker.example .env
|
||||
# 编辑 .env,至少修改 MYSQL_ROOT_PASSWORD、DB_PASSWORD 和 JWT_SECRET_KEY
|
||||
|
||||
# 启动前只做静态配置校验
|
||||
docker compose config --quiet
|
||||
|
||||
# 构建并启动前端、后端、MySQL、Redis
|
||||
docker compose up -d --build
|
||||
|
||||
# 查看服务状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### PostgreSQL版本
|
||||
浏览器访问:http://localhost:8080;后端直连接口为 `http://localhost:9099`;宿主机连接 MySQL 时使用 `127.0.0.1:13307`。需要局域网访问时,将根目录 `.env` 中的 `COMPOSE_BIND_IP` 改为 `0.0.0.0`,再使用宿主机局域网地址访问前端和后端端口。
|
||||
|
||||
```bash
|
||||
docker compose -f shierfeng-fastapi-test/docker-compose.test.pg.yml up -d --build
|
||||
```
|
||||
已有数据库不能通过全量初始化脚本升级。请先备份并核对目标库,再按照 `shierfeng-fastapi-backend/sql/patches/` 下对应的 MySQL 增量补丁和部署文档执行;不要通过删除 Compose 数据卷来代替迁移。
|
||||
|
||||
@@ -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
|
||||
|
||||
### 配置 PDA 后端地址
|
||||
|
||||
PDA 默认请求本机 `http://localhost:9099`,仅适用于后端运行在同一设备的本地开发。部署到独立 PDA 或局域网服务器时,在构建环境注入 `VITE_APP_BASE_API`:
|
||||
|
||||
```bash
|
||||
VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
|
||||
```
|
||||
|
||||
不要把包含真实凭据的环境文件或地址配置提交到仓库。
|
||||
|
||||
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
|
||||
|
||||
### vscode
|
||||
|
||||
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon.ico" />
|
||||
<script>
|
||||
var coverSupport =
|
||||
'CSS' in window &&
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "1.9.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"scripts": {
|
||||
"test:business": "node --test tests/*.test.mjs",
|
||||
"dev": "uni -p mp-weixin",
|
||||
"build": "npm run build:mp-weixin",
|
||||
"dev:app": "uni -p app",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listScanExceptions() {
|
||||
return request({
|
||||
url: "/business/dashboard/scan-exceptions",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listFilling(query) {
|
||||
return request({
|
||||
url: "/business/filling/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getFillingStrainOptions() {
|
||||
return request({
|
||||
url: "/business/filling/strain-options",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function scanFillingPallet(qrToken) {
|
||||
return request({
|
||||
url: "/business/filling/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function getFilling(fillingId) {
|
||||
return request({
|
||||
url: "/business/filling/" + fillingId,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function addFilling(data) {
|
||||
return request({
|
||||
url: "/business/filling",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listInbound(query) {
|
||||
return request({
|
||||
url: "/business/inbound/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getInboundWarehouseOptions() {
|
||||
return request({
|
||||
url: "/business/inbound/warehouse-options",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function scanInboundPallet(qrToken, query) {
|
||||
return request({
|
||||
url: "/business/inbound/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getInbound(inboundId) {
|
||||
return request({
|
||||
url: "/business/inbound/" + inboundId,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function addInbound(data) {
|
||||
return request({
|
||||
url: "/business/inbound",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInbound(data) {
|
||||
return request({
|
||||
url: "/business/inbound",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmInbound(inboundId, data) {
|
||||
return request({
|
||||
url: "/business/inbound/" + inboundId + "/confirm",
|
||||
method: "put",
|
||||
data,
|
||||
headers: { repeatSubmit: false },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listReturn(query) {
|
||||
return request({
|
||||
url: "/business/return/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getReturnWarehouseOptions() {
|
||||
return request({
|
||||
url: "/business/return/warehouse-options",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function scanReturnPallet(qrToken, query) {
|
||||
return request({
|
||||
url: "/business/return/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function requestReturn(data) {
|
||||
return request({
|
||||
url: "/business/return/request",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function getReturn(returnId) {
|
||||
return request({
|
||||
url: "/business/return/" + returnId,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmReturn(returnId, data) {
|
||||
return request({
|
||||
url: "/business/return/" + returnId + "/confirm",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listShipment(query) {
|
||||
return request({
|
||||
url: "/business/shipment/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getShipmentWarehouseOptions() {
|
||||
return request({
|
||||
url: "/business/shipment/warehouse-options",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function getShipmentSiteOptions() {
|
||||
return request({
|
||||
url: "/business/shipment/site-options",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function scanShipmentPallet(qrToken, query) {
|
||||
return request({
|
||||
url: "/business/shipment/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getShipment(shipmentId) {
|
||||
return request({
|
||||
url: "/business/shipment/" + shipmentId,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function addShipment(data) {
|
||||
return request({
|
||||
url: "/business/shipment",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateShipment(data) {
|
||||
return request({
|
||||
url: "/business/shipment",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmShipment(shipmentId, data) {
|
||||
return request({
|
||||
url: "/business/shipment/" + shipmentId + "/confirm",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
|
||||
export function listShipmentReceipts(query) {
|
||||
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getShipmentReceipt(shipmentId) {
|
||||
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
|
||||
}
|
||||
|
||||
export function scanShipmentReceipt(shipmentId, identifier) {
|
||||
return request({
|
||||
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function confirmShipmentReceipt(shipmentId, data) {
|
||||
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listUsage(query) {
|
||||
return request({
|
||||
url: "/business/usage/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUsageLineOptions(siteId) {
|
||||
return request({
|
||||
url: "/business/usage/line-options",
|
||||
method: "get",
|
||||
params: { siteId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getUsageTunnelOptions(siteId) {
|
||||
return request({
|
||||
url: "/business/usage/tunnel-options",
|
||||
method: "get",
|
||||
params: { siteId },
|
||||
});
|
||||
}
|
||||
|
||||
export function enterUsageTunnel(data) {
|
||||
return request({
|
||||
url: "/business/usage/enter-tunnel",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function scanUsagePallet(qrToken) {
|
||||
return request({
|
||||
url: "/business/usage/scan/" + encodeURIComponent(qrToken),
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function getUsageFifoRecommendation(qrToken, tunnelId) {
|
||||
return request({
|
||||
url: "/business/usage-fifo/recommendation",
|
||||
method: "get",
|
||||
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
export function getUsage(usageId) {
|
||||
return request({
|
||||
url: "/business/usage/" + usageId,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function addUsage(data) {
|
||||
return request({
|
||||
url: "/business/usage",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listPalletInventory(query) {
|
||||
return request({
|
||||
url: "/report/pallet-inventory/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function listShipmentReport(query) {
|
||||
return request({
|
||||
url: "/report/shipment/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// PDA 只读取当前登录用户被授权且仍处于 ACTIVE 状态的批次。
|
||||
export function listAvailableTestBatches() {
|
||||
return request({
|
||||
url: "/system/test-batch/options",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<view class="w-full">
|
||||
<view class="flex items-center rounded-xl bg-white p-2 shadow-sm">
|
||||
<input
|
||||
v-model="scanValue"
|
||||
class="h-11 min-w-0 flex-1 px-3 text-sm text-gray-700"
|
||||
type="text"
|
||||
confirm-type="done"
|
||||
maxlength="128"
|
||||
:focus="focusRequested"
|
||||
:disabled="disabled || loading"
|
||||
placeholder="扫描二维码或输入托盘号后按回车"
|
||||
@confirm="handleSubmit"
|
||||
@input="clearError"
|
||||
/>
|
||||
<button
|
||||
class="ml-2 h-10 rounded-lg bg-blue-500 px-3 text-xs text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="disabled || loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? "校验中" : "提交扫描" }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="errorMessage" class="mt-2 block text-xs text-red-500">
|
||||
{{ errorMessage }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from "vue";
|
||||
import { isDuplicateScan, normalizeScanValue } from "@/utils/palletScan";
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
duplicateWindowMs: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["scan", "invalid", "duplicate"]);
|
||||
const scanValue = ref("");
|
||||
const errorMessage = ref("");
|
||||
const focusRequested = ref(false);
|
||||
let lastScanValue = "";
|
||||
let lastScanAt = 0;
|
||||
|
||||
function focusInput() {
|
||||
if (props.disabled || props.loading) return;
|
||||
focusRequested.value = false;
|
||||
nextTick(() => {
|
||||
if (!props.disabled && !props.loading) {
|
||||
focusRequested.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (props.disabled || props.loading) return;
|
||||
|
||||
let normalizedValue;
|
||||
try {
|
||||
normalizedValue = normalizeScanValue(scanValue.value);
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message;
|
||||
emit("invalid", error.message);
|
||||
focusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
const scannedAt = Date.now();
|
||||
if (
|
||||
isDuplicateScan(
|
||||
normalizedValue,
|
||||
lastScanValue,
|
||||
scannedAt,
|
||||
lastScanAt,
|
||||
props.duplicateWindowMs,
|
||||
)
|
||||
) {
|
||||
scanValue.value = "";
|
||||
emit("duplicate", normalizedValue);
|
||||
focusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
lastScanValue = normalizedValue;
|
||||
lastScanAt = scannedAt;
|
||||
scanValue.value = "";
|
||||
errorMessage.value = "";
|
||||
emit("scan", normalizedValue);
|
||||
focusInput();
|
||||
}
|
||||
|
||||
onMounted(focusInput);
|
||||
|
||||
watch(
|
||||
() => [props.disabled, props.loading],
|
||||
([disabled, loading]) => {
|
||||
if (!disabled && !loading) focusInput();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<view v-if="canReceive" class="space-y-4">
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">收货单</text>
|
||||
<picker :range="receiptStateOptions" range-key="label" :value="receiptStateIndex" @change="changeReceiptState">
|
||||
<view class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ receiptStateOptions[receiptStateIndex].label }}</view>
|
||||
</picker>
|
||||
<input v-model="queryParams.shipmentNo" class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm" maxlength="64" placeholder="输入原发货单号" confirm-type="search" @confirm="handleQuery" />
|
||||
<button size="mini" class="rounded-lg bg-blue-50 text-blue-600" :disabled="loading" @click="handleQuery">查询收货</button>
|
||||
<text v-if="listError" class="mt-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<text v-if="loading" class="mt-3 block text-xs text-gray-400">加载中...</text>
|
||||
<text v-else-if="!receiptList.length" class="mt-3 block text-xs text-gray-400">暂无收货单</text>
|
||||
<view v-for="row in receiptList" :key="row.shipmentId" class="mt-3 rounded-xl border border-gray-100 p-3">
|
||||
<view class="flex items-center justify-between gap-2">
|
||||
<text class="text-sm font-medium text-gray-700">{{ row.shipmentNo }}</text>
|
||||
<text class="text-xs text-orange-600">{{ receiptStateLabel(row.receiptState) }}</text>
|
||||
</view>
|
||||
<text class="mt-2 block text-xs text-gray-500">仓库 #{{ row.sourceWarehouseId }} → 现场 #{{ row.targetSiteId }}</text>
|
||||
<text v-if="row.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ row.receivedAt || '未登记' }}</text>
|
||||
<button size="mini" class="mt-3 rounded-lg bg-orange-50 text-orange-600" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</button>
|
||||
</view>
|
||||
<view class="mt-3 flex items-center justify-between gap-2">
|
||||
<button size="mini" :disabled="loading || queryParams.pageNum <= 1" @click="changePage(-1)">上一页</button>
|
||||
<text class="text-xs text-gray-500">第 {{ queryParams.pageNum }} 页 · 共 {{ total }} 单</text>
|
||||
<button size="mini" :disabled="loading || queryParams.pageNum * queryParams.pageSize >= total" @click="changePage(1)">下一页</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeShipmentId !== null" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">收货核对</text>
|
||||
<button size="mini" class="text-xs text-gray-500" @click="closeReceipt">关闭</button>
|
||||
</view>
|
||||
<text v-if="detailLoading" class="block text-xs text-gray-400">加载原单明细...</text>
|
||||
<text v-if="detailError" class="mb-3 block text-xs text-red-500">{{ detailError }}</text>
|
||||
<button v-if="detailError" size="mini" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</button>
|
||||
<template v-if="receiptDetail">
|
||||
<text class="block text-sm font-medium text-gray-700">{{ receiptDetail.shipmentNo }} · {{ receiptStateLabel(receiptDetail.receiptState) }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">目标现场 #{{ receiptDetail.targetSiteId }}</text>
|
||||
<text v-if="receiptDetail.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ receiptDetail.receivedAt || '未登记' }}</text>
|
||||
<text v-if="receiptDetail.receiptState === 'LEGACY'" class="mt-3 block text-xs text-gray-500">历史未登记收货:仅供查询原单明细,不补造收货时间。</text>
|
||||
<view v-if="receiptDetail.receiptState === 'PENDING'" class="mt-3">
|
||||
<PalletScanInput :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
|
||||
<text class="mt-2 block text-xs text-gray-500">逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托,全部核对后才可确认。</text>
|
||||
</view>
|
||||
<text v-if="scanError" class="mt-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||
<text v-if="!receiptItems.length" class="mt-3 block text-xs text-red-500">原单无托盘明细,无法确认收货</text>
|
||||
<view v-for="item in receiptItems" :key="item.palletId" class="mt-3 rounded-xl bg-gray-50 p-3">
|
||||
<view class="flex items-center justify-between gap-2">
|
||||
<text class="text-sm font-medium text-gray-700">{{ item.palletCode }}</text>
|
||||
<text v-if="receiptDetail.receiptState === 'PENDING'" class="text-xs text-orange-600">{{ scannedPalletIds.includes(item.palletId) ? '已核对' : '待扫描' }}</text>
|
||||
</view>
|
||||
<text class="mt-2 block text-xs text-gray-600">品种:{{ item.strainName || '未登记' }} · 原发货 {{ item.packageQty }} 包</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">批号:{{ item.batchNo || '未登记' }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">生产日期:{{ item.productionDate || '未登记' }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">失效日期:{{ item.expiryDate || '未登记' }}</text>
|
||||
<button v-if="receiptDetail.receiptState === 'PENDING' && scannedPalletIds.includes(item.palletId)" size="mini" class="mt-2 text-xs text-red-500" :disabled="!canScan || scanLoading" @click="removeScannedPallet(item.palletId)">撤销核对</button>
|
||||
</view>
|
||||
<text v-if="confirmError" class="mt-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<button v-if="receiptDetail.receiptState === 'PENDING'" class="mt-4 rounded-xl bg-orange-500 py-3 text-sm text-white disabled:bg-gray-300" :disabled="!canConfirmReceipt" :loading="confirmLoading" @click="confirmReceipt">确认收货</button>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import PalletScanInput from '@/components/business/PalletScanInput.vue'
|
||||
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
|
||||
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const emit = defineEmits(['received'])
|
||||
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
|
||||
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
|
||||
const receiptList = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const listError = ref('')
|
||||
const activeShipmentId = ref(null)
|
||||
const receiptDetail = ref(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const scannedPalletIds = ref([])
|
||||
const scanLoading = ref(false)
|
||||
const scanError = ref('')
|
||||
const confirmLoading = ref(false)
|
||||
const confirmError = ref('')
|
||||
const receiptAccepted = ref(false)
|
||||
let session = 0
|
||||
let listGeneration = 0
|
||||
let suspended = false
|
||||
let idempotencyKey = ''
|
||||
|
||||
const receiptItems = computed(() => receiptDetail.value?.items || [])
|
||||
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
|
||||
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
|
||||
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
|
||||
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
|
||||
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
|
||||
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
|
||||
|
||||
function receiptStateLabel(state) {
|
||||
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
|
||||
}
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback
|
||||
}
|
||||
|
||||
function isCurrent(currentSession) {
|
||||
return currentSession === session && !suspended && canReceive.value
|
||||
}
|
||||
|
||||
async function loadReceipts() {
|
||||
if (!canReceive.value || suspended) return
|
||||
const generation = ++listGeneration
|
||||
loading.value = true
|
||||
listError.value = ''
|
||||
try {
|
||||
const response = await listShipmentReceipts({ ...queryParams })
|
||||
if (generation !== listGeneration || suspended || !canReceive.value) return
|
||||
receiptList.value = Array.isArray(response.rows) ? response.rows : []
|
||||
total.value = response.total || 0
|
||||
} catch (error) {
|
||||
if (generation !== listGeneration || suspended || !canReceive.value) return
|
||||
receiptList.value = []
|
||||
total.value = 0
|
||||
listError.value = errorMessage(error, '收货列表加载失败,请重试')
|
||||
} finally {
|
||||
if (generation === listGeneration) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParams.pageNum = 1
|
||||
return loadReceipts()
|
||||
}
|
||||
|
||||
function closeReceipt() {
|
||||
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
|
||||
session += 1
|
||||
activeShipmentId.value = null
|
||||
receiptDetail.value = null
|
||||
scannedPalletIds.value = []
|
||||
detailLoading.value = false
|
||||
scanLoading.value = false
|
||||
confirmLoading.value = false
|
||||
receiptAccepted.value = false
|
||||
detailError.value = ''
|
||||
scanError.value = ''
|
||||
confirmError.value = ''
|
||||
idempotencyKey = ''
|
||||
}
|
||||
|
||||
async function loadReceiptDetail(shipmentId, currentSession) {
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
try {
|
||||
const response = await getShipmentReceipt(shipmentId)
|
||||
if (!isCurrent(currentSession)) return
|
||||
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
|
||||
throw new Error('收货详情与当前单据不一致,请重新打开')
|
||||
}
|
||||
receiptDetail.value = response.data
|
||||
} catch (error) {
|
||||
if (!isCurrent(currentSession)) return
|
||||
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
|
||||
} finally {
|
||||
if (isCurrent(currentSession)) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openReceipt(row) {
|
||||
if (!canReceive.value || suspended || row.shipmentId == null) return
|
||||
closeReceipt()
|
||||
activeShipmentId.value = row.shipmentId
|
||||
await loadReceiptDetail(row.shipmentId, session)
|
||||
}
|
||||
|
||||
function retryDetail() {
|
||||
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
|
||||
return loadReceiptDetail(activeShipmentId.value, session)
|
||||
}
|
||||
|
||||
async function handleReceiptScan(identifier) {
|
||||
if (!canScan.value || scanLoading.value) return
|
||||
const value = String(identifier ?? '').trim()
|
||||
if (!value) {
|
||||
scanError.value = '请扫描二维码或输入托盘号'
|
||||
return
|
||||
}
|
||||
const currentSession = session
|
||||
scanLoading.value = true
|
||||
scanError.value = ''
|
||||
try {
|
||||
const response = await scanShipmentReceipt(activeShipmentId.value, value)
|
||||
if (!isCurrent(currentSession)) return
|
||||
const scanned = response.data
|
||||
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
|
||||
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
|
||||
scanError.value = '该托盘或装填记录不属于当前发货单'
|
||||
return
|
||||
}
|
||||
if (scannedPalletIds.value.includes(original.palletId)) {
|
||||
scanError.value = '该托盘已核对,请勿重复扫描'
|
||||
return
|
||||
}
|
||||
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
|
||||
scannedPalletIds.value.push(original.palletId)
|
||||
idempotencyKey = ''
|
||||
confirmError.value = ''
|
||||
} catch (error) {
|
||||
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
|
||||
} finally {
|
||||
if (isCurrent(currentSession)) scanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeScannedPallet(palletId) {
|
||||
if (!canScan.value || scanLoading.value) return
|
||||
if (!scannedPalletIds.value.includes(palletId)) return
|
||||
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
|
||||
idempotencyKey = ''
|
||||
confirmError.value = ''
|
||||
}
|
||||
|
||||
async function confirmReceipt() {
|
||||
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
|
||||
if (!canConfirmReceipt.value) {
|
||||
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
|
||||
return
|
||||
}
|
||||
const currentSession = session
|
||||
const shipmentId = activeShipmentId.value
|
||||
confirmLoading.value = true
|
||||
confirmError.value = ''
|
||||
try {
|
||||
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
|
||||
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
|
||||
const response = await confirmShipmentReceipt(shipmentId, {
|
||||
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
|
||||
})
|
||||
if (!isCurrent(currentSession)) return
|
||||
receiptAccepted.value = true
|
||||
proxy.$modal.msgSuccess(response.msg || '收货成功')
|
||||
emit('received')
|
||||
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
|
||||
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
|
||||
} catch (error) {
|
||||
if (isCurrent(currentSession)) {
|
||||
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent(currentSession)) confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function suspendPanel() {
|
||||
suspended = true
|
||||
listGeneration += 1
|
||||
loading.value = false
|
||||
closeReceipt()
|
||||
}
|
||||
|
||||
function resumePanel() {
|
||||
if (!suspended) return
|
||||
suspended = false
|
||||
loadReceipts()
|
||||
}
|
||||
|
||||
onMounted(loadReceipts)
|
||||
onBeforeUnmount(suspendPanel)
|
||||
|
||||
const receiptStateOptions = [
|
||||
{ label: '待收货', value: 'PENDING' },
|
||||
{ label: '已收货', value: 'RECEIVED' },
|
||||
{ label: '历史未登记收货', value: 'LEGACY' },
|
||||
{ label: '全部', value: undefined }
|
||||
]
|
||||
const receiptStateIndex = computed(() => receiptStateOptions.findIndex(item => item.value === queryParams.receiptState))
|
||||
|
||||
function changeReceiptState(event) {
|
||||
const option = receiptStateOptions[Number(event.detail.value)]
|
||||
if (!option) return
|
||||
queryParams.receiptState = option.value
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function changePage(offset) {
|
||||
const page = queryParams.pageNum + offset
|
||||
if (loading.value || page < 1 || (page > 1 && (page - 1) * queryParams.pageSize >= total.value)) return
|
||||
queryParams.pageNum = page
|
||||
loadReceipts()
|
||||
}
|
||||
|
||||
onShow(resumePanel)
|
||||
onHide(suspendPanel)
|
||||
onUnload(suspendPanel)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<view v-if="testBatchStore.isTestMode" class="test-batch-banner">
|
||||
<view class="test-batch-icon">
|
||||
<view class="i-mdi-flask-outline test-batch-icon-glyph"></view>
|
||||
</view>
|
||||
<view class="test-batch-copy">
|
||||
<text class="test-batch-title">测试数据</text>
|
||||
<text class="test-batch-value">
|
||||
当前批次:{{ testBatchStore.currentBatchName || "已选测试批次" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useTestBatchStore } from "@/store";
|
||||
|
||||
const testBatchStore = useTestBatchStore();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.test-batch-banner {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
border: 1rpx solid #f1b8b4;
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx;
|
||||
background: #fff5f4;
|
||||
}
|
||||
|
||||
.test-batch-icon {
|
||||
display: flex;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
flex: 0 0 52rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #d9534f;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.test-batch-icon-glyph {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.test-batch-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.test-batch-title,
|
||||
.test-batch-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.test-batch-title {
|
||||
color: #b42318;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.test-batch-value {
|
||||
margin-top: 5rpx;
|
||||
color: #c2413b;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<view class="test-batch-switch">
|
||||
<view class="mode-switch-header">
|
||||
<view class="mode-switch-copy">
|
||||
<text class="mode-switch-title">数据模式</text>
|
||||
<text class="mode-switch-description">
|
||||
{{ testBatchStore.isTestMode ? "当前操作隔离在测试批次" : "当前使用正式数据" }}
|
||||
</text>
|
||||
</view>
|
||||
<text
|
||||
class="mode-switch-status"
|
||||
:class="testBatchStore.isTestMode ? 'is-test' : 'is-formal'"
|
||||
>
|
||||
{{ testBatchStore.isTestMode ? "测试数据" : "正式数据" }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mode-switch-controls">
|
||||
<button
|
||||
class="mode-switch-button"
|
||||
:class="{ active: !testBatchStore.isTestMode }"
|
||||
:disabled="loading"
|
||||
size="mini"
|
||||
@click="handleModeClick('formal')"
|
||||
>
|
||||
正式
|
||||
</button>
|
||||
<button
|
||||
class="mode-switch-button"
|
||||
:class="{ 'active-test': testBatchStore.isTestMode }"
|
||||
:disabled="loading || !activeOptions.length"
|
||||
size="mini"
|
||||
@click="handleModeClick('test')"
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="testBatchStore.isTestMode || activeOptions.length"
|
||||
class="test-batch-picker-row"
|
||||
>
|
||||
<text class="test-batch-picker-label-title">测试批次</text>
|
||||
<picker
|
||||
:range="activeOptions"
|
||||
range-key="batchName"
|
||||
:value="selectedBatchIndex"
|
||||
:disabled="loading || !activeOptions.length"
|
||||
@change="handleBatchChange"
|
||||
>
|
||||
<view class="test-batch-picker">
|
||||
<text class="test-batch-picker-label">
|
||||
{{ selectedBatch?.batchName || (loading ? "加载中..." : "暂无可用测试批次") }}
|
||||
</text>
|
||||
<view class="i-mdi-chevron-down test-batch-picker-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="loadError" class="mode-switch-error">
|
||||
<text class="mode-switch-error-text">{{ loadError }}</text>
|
||||
<button
|
||||
class="mode-switch-retry"
|
||||
size="mini"
|
||||
:disabled="loading"
|
||||
@click="loadOptions"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useTestBatchStore } from "@/store";
|
||||
|
||||
const testBatchStore = useTestBatchStore();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const activeOptions = computed(() =>
|
||||
testBatchStore.options.filter((item) => item.status === "ACTIVE"),
|
||||
);
|
||||
const selectedBatch = computed(() =>
|
||||
activeOptions.value.find(
|
||||
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
|
||||
),
|
||||
);
|
||||
const selectedBatchIndex = computed(() => {
|
||||
const index = activeOptions.value.findIndex(
|
||||
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
function errorMessage(error) {
|
||||
return error?.response?.data?.msg || error?.message || "测试批次加载失败,请稍后重试";
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
await testBatchStore.refreshOptions();
|
||||
} catch (error) {
|
||||
if (error?.reason !== "TEST_BATCH_UNAVAILABLE") {
|
||||
loadError.value = errorMessage(error);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmModeSwitch() {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "切换数据模式",
|
||||
content: "切换后会清空当前页面未提交的扫码和表单内容,确定继续吗?",
|
||||
cancelText: "取消",
|
||||
confirmText: "确定",
|
||||
success: (result) => resolve(result.confirm === true),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleModeClick(mode) {
|
||||
if (loading.value) return;
|
||||
if (mode === "formal") {
|
||||
if (!testBatchStore.isTestMode || !(await confirmModeSwitch())) return;
|
||||
testBatchStore.leaveTestMode();
|
||||
uni.reLaunch({ url: "/pages/work/index" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (testBatchStore.isTestMode) return;
|
||||
const batch = activeOptions.value[0];
|
||||
if (!batch) {
|
||||
uni.showToast({ title: "暂无可用测试批次", icon: "none" });
|
||||
return;
|
||||
}
|
||||
if (!(await confirmModeSwitch())) return;
|
||||
try {
|
||||
testBatchStore.selectBatch(batch);
|
||||
uni.reLaunch({ url: "/pages/work/index" });
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchChange(event) {
|
||||
const batch = activeOptions.value[Number(event.detail.value)];
|
||||
if (!batch || String(batch.batchId) === String(testBatchStore.currentBatchId)) return;
|
||||
if (!(await confirmModeSwitch())) return;
|
||||
try {
|
||||
testBatchStore.selectBatch(batch);
|
||||
uni.reLaunch({ url: "/pages/work/index" });
|
||||
} catch (error) {
|
||||
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.test-batch-switch {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid #e5eeee;
|
||||
border-radius: 20rpx;
|
||||
padding: 22rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
|
||||
}
|
||||
|
||||
.mode-switch-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.mode-switch-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mode-switch-title,
|
||||
.mode-switch-description,
|
||||
.test-batch-picker-label-title,
|
||||
.test-batch-picker-label,
|
||||
.mode-switch-error-text {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mode-switch-title {
|
||||
color: #28225c;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mode-switch-description {
|
||||
margin-top: 5rpx;
|
||||
color: #8b969d;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mode-switch-status {
|
||||
flex: 0 0 auto;
|
||||
border: 1rpx solid #dce7e7;
|
||||
border-radius: 999rpx;
|
||||
padding: 7rpx 12rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mode-switch-status.is-formal {
|
||||
border-color: #dce7e7;
|
||||
background: #f5f8f8;
|
||||
color: #64727a;
|
||||
}
|
||||
|
||||
.mode-switch-status.is-test {
|
||||
border-color: #f1b8b4;
|
||||
background: #fff1f0;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.mode-switch-controls {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 10rpx;
|
||||
margin-top: 18rpx;
|
||||
border-radius: 12rpx;
|
||||
padding: 5rpx;
|
||||
background: #f1f5f5;
|
||||
}
|
||||
|
||||
.mode-switch-button {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 64rpx;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 9rpx;
|
||||
background: transparent;
|
||||
color: #748087;
|
||||
font-size: 23rpx;
|
||||
line-height: 64rpx;
|
||||
}
|
||||
|
||||
.mode-switch-button::after,
|
||||
.mode-switch-retry::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mode-switch-button.active {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4rpx 10rpx rgba(40, 34, 92, 0.08);
|
||||
color: #28225c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mode-switch-button.active-test {
|
||||
background: #d9534f;
|
||||
box-shadow: 0 4rpx 10rpx rgba(217, 83, 79, 0.18);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.test-batch-picker-row {
|
||||
min-width: 0;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.test-batch-picker-label-title {
|
||||
color: #6c7786;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.test-batch-picker {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
margin-top: 8rpx;
|
||||
border: 1rpx solid #e0eaea;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx;
|
||||
background: #fbfdfd;
|
||||
}
|
||||
|
||||
.test-batch-picker-label {
|
||||
flex: 1;
|
||||
color: #47545c;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.test-batch-picker-arrow {
|
||||
flex: 0 0 30rpx;
|
||||
color: #96a5a9;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.mode-switch-error {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
border-radius: 10rpx;
|
||||
padding: 10rpx 12rpx;
|
||||
background: #fff8ed;
|
||||
}
|
||||
|
||||
.mode-switch-error-text {
|
||||
flex: 1;
|
||||
color: #a15c00;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mode-switch-retry {
|
||||
width: auto;
|
||||
min-width: 74rpx;
|
||||
height: 52rpx;
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0 12rpx;
|
||||
border: 0;
|
||||
border-radius: 8rpx;
|
||||
background: #fff0d6;
|
||||
color: #a15c00;
|
||||
font-size: 20rpx;
|
||||
line-height: 52rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
// 应用全局配置
|
||||
export default {
|
||||
baseUrl: "http://localhost:9099",
|
||||
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
|
||||
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
|
||||
// 应用信息
|
||||
appInfo: {
|
||||
// 应用名称
|
||||
@@ -8,7 +9,7 @@ export default {
|
||||
// 应用版本
|
||||
version: "1.9.0",
|
||||
// 应用logo
|
||||
logo: "/static/logo.png",
|
||||
logo: "/static/sylvan-logo.png",
|
||||
// 官方网站
|
||||
site_url: "https://vfadmin.insistence.tech",
|
||||
// 政策协议
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export const businessMenuEntries = [
|
||||
{
|
||||
path: "/pages/business/inbound/index",
|
||||
title: "仓库入库",
|
||||
description: "扫描托盘登记入库",
|
||||
icon: "i-mdi-package-down",
|
||||
iconClass: "bg-blue-50 text-blue-600",
|
||||
permission: "business:inbound:list",
|
||||
},
|
||||
{
|
||||
path: "/pages/business/filling/index",
|
||||
title: "工厂装填",
|
||||
description: "录入菌种和包数",
|
||||
icon: "i-mdi-package-variant-closed-plus",
|
||||
iconClass: "bg-green-50 text-green-600",
|
||||
permission: "business:filling:list",
|
||||
},
|
||||
{
|
||||
path: "/pages/business/shipment/index",
|
||||
title: "发货出库",
|
||||
description: "扫码创建发货单",
|
||||
icon: "i-mdi-truck-delivery",
|
||||
iconClass: "bg-orange-50 text-orange-600",
|
||||
permission: "business:shipment:list",
|
||||
permissions: ["business:shipment:list", "business:shipment:receive"],
|
||||
},
|
||||
{
|
||||
path: "/pages/business/usage/index",
|
||||
title: "客户现场使用",
|
||||
description: "绑定产线记录消耗",
|
||||
icon: "i-mdi-factory",
|
||||
iconClass: "bg-purple-50 text-purple-600",
|
||||
permission: "business:usage:list",
|
||||
},
|
||||
{
|
||||
path: "/pages/business/return/index",
|
||||
title: "退回仓库",
|
||||
description: "申请和确认退回",
|
||||
icon: "i-mdi-undo-variant",
|
||||
iconClass: "bg-red-50 text-red-600",
|
||||
permission: "business:return:list",
|
||||
},
|
||||
];
|
||||
@@ -5,10 +5,24 @@ import store from "./store"; // store
|
||||
import { install } from "./plugins"; // plugins
|
||||
import "./permission"; // permission
|
||||
import { useDict } from "@/utils/dict";
|
||||
import { useTestBatchStore } from "./store";
|
||||
|
||||
let testBatchUnavailableListenerRegistered = false;
|
||||
|
||||
function registerTestBatchUnavailableListener() {
|
||||
if (testBatchUnavailableListenerRegistered || typeof uni.$on !== "function") {
|
||||
return;
|
||||
}
|
||||
testBatchUnavailableListenerRegistered = true;
|
||||
uni.$on("test-batch-unavailable", () => {
|
||||
useTestBatchStore().leaveTestMode();
|
||||
});
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
app.use(store);
|
||||
registerTestBatchUnavailableListener();
|
||||
app.config.globalProperties.useDict = useDict;
|
||||
install(app);
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name" : "智链云枢管理系统",
|
||||
"appid": "__UNI__25A9D80",
|
||||
"appid" : "__UNI__F8DBA93",
|
||||
"description" : "",
|
||||
"versionName" : "1.9.0",
|
||||
"versionCode" : "100",
|
||||
@@ -66,5 +66,7 @@
|
||||
"mode" : "hash",
|
||||
"base" : "./"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale" : "zh-Hans",
|
||||
"fallbackLocale" : "zh-Hans"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,36 @@
|
||||
"navigationBarTitleText": "工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/inbound/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "仓库入库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/filling/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工厂装填"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/shipment/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "发货出库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/usage/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "现场使用"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/return/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "退回仓库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
@@ -128,5 +158,15 @@
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "智链云枢管理系统",
|
||||
"navigationBarBackgroundColor": "#FFFFFF"
|
||||
},
|
||||
"condition" : { //模式配置,仅开发期间生效
|
||||
"current": 0, //当前激活的模式(list 的索引项)
|
||||
"list": [
|
||||
{
|
||||
"name": "", //模式名称
|
||||
"path": "", //启动页面,必选
|
||||
"query": "" //启动参数,在页面的onLoad函数里面得到
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">工厂装填</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描空托盘,录入菌种、批次、规格和初始包数
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有装填操作权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">扫描待装填托盘</text>
|
||||
<button
|
||||
class="text-xs text-gray-500"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="resetOperation"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="operationPending || optionLoading"
|
||||
:loading="scanLoading"
|
||||
@scan="handleFillingScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 EMPTY_IN_WAREHOUSE 状态托盘。
|
||||
</text>
|
||||
<text v-if="scanError" class="mt-2 block text-xs text-red-500">{{ scanError }}</text>
|
||||
|
||||
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-green-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">
|
||||
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||
</text>
|
||||
<text class="text-xs text-green-600">{{ scannedPallet.flowState }}</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
仓库:{{ scannedPallet.warehouseName || `#${scannedPallet.warehouseId}` }} · 容量:{{ scannedPallet.capacityPackages }} 包
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">装填信息</text>
|
||||
<view class="space-y-3">
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">菌种</text>
|
||||
<picker
|
||||
:range="strainOptions"
|
||||
range-key="name"
|
||||
:value="strainIndex"
|
||||
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||
@change="handleStrainChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedStrainName || "请选择启用菌种" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">生产批次</text>
|
||||
<input
|
||||
v-model="form.batchNo"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
maxlength="64"
|
||||
placeholder="请输入生产批次"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">生产日期</text>
|
||||
<picker mode="date" :value="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('productionDate', $event)">
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.productionDate || '选择来源生产日期' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">有效期至</text>
|
||||
<picker mode="date" :value="form.expiryDate" :start="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('expiryDate', $event)">
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.expiryDate || '选择有效期截止日期' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
|
||||
<input
|
||||
v-model="form.packageSpec"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
maxlength="64"
|
||||
placeholder="例如:500g/包"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">初始包数</text>
|
||||
<input
|
||||
v-model="form.initialPackages"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
type="number"
|
||||
placeholder="固定 100 包"
|
||||
disabled
|
||||
/>
|
||||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="formError" class="mt-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-green-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || !scannedPallet"
|
||||
@click="submitFilling"
|
||||
>
|
||||
{{ submitLoading ? "提交中" : "确认装填" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">最近装填记录</text>
|
||||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadFillingList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!fillingList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无装填记录
|
||||
</view>
|
||||
<view
|
||||
v-for="row in fillingList"
|
||||
v-else
|
||||
:key="row.fillingId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<text class="block font-medium text-gray-700">{{ row.fillingNo || `#${row.fillingId}` }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ row.strainNameSnapshot || row.strainCodeSnapshot }} · {{ row.batchNo }} · {{ row.initialPackages }} 包
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-400">托盘 #{{ row.palletId }} · {{ row.operatedAt || row.createTime || "—" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addFilling,
|
||||
getFillingStrainOptions,
|
||||
listFilling,
|
||||
scanFillingPallet,
|
||||
} from "@/api/business/filling";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const fillingList = ref([]);
|
||||
const strainOptions = ref([]);
|
||||
const scannedPallet = ref(null);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const scanError = ref("");
|
||||
const formError = ref("");
|
||||
const idempotencyKey = ref("");
|
||||
const form = reactive({
|
||||
qrToken: "",
|
||||
warehouseId: null,
|
||||
strainId: null,
|
||||
batchNo: "",
|
||||
productionDate: "",
|
||||
expiryDate: "",
|
||||
packageSpec: "",
|
||||
initialPackages: 100,
|
||||
});
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:filling:list",
|
||||
"business:filling:query",
|
||||
"business:filling:add",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:filling:list"));
|
||||
const canOperate = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:filling:query", "business:filling:add"]),
|
||||
);
|
||||
const operationPending = computed(() => submitLoading.value);
|
||||
const strainIndex = computed(() => {
|
||||
const index = strainOptions.value.findIndex((item) => item.id === form.strainId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedStrainName = computed(
|
||||
() => strainOptions.value.find((item) => item.id === form.strainId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function loadFillingList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listFilling({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
fillingList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
fillingList.value = [];
|
||||
listError.value = errorMessage(error, "装填记录加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadStrainOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:filling:query")) return;
|
||||
optionLoading.value = true;
|
||||
getFillingStrainOptions()
|
||||
.then((response) => {
|
||||
strainOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
strainOptions.value = [];
|
||||
formError.value = errorMessage(error, "菌种选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetOperation() {
|
||||
scanGeneration += 1;
|
||||
scannedPallet.value = null;
|
||||
idempotencyKey.value = "";
|
||||
form.qrToken = "";
|
||||
form.warehouseId = null;
|
||||
form.strainId = null;
|
||||
form.batchNo = "";
|
||||
form.productionDate = "";
|
||||
form.expiryDate = "";
|
||||
form.packageSpec = "";
|
||||
form.initialPackages = 100;
|
||||
scanError.value = "";
|
||||
formError.value = "";
|
||||
scanLoading.value = false;
|
||||
}
|
||||
|
||||
function handleFillingScan(qrToken) {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
resetOperation();
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanFillingPallet(qrToken)
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "EMPTY_IN_WAREHOUSE") {
|
||||
scanError.value = "该托盘当前不可装填";
|
||||
return;
|
||||
}
|
||||
scannedPallet.value = {
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
warehouseId: pallet.warehouseId,
|
||||
warehouseName: pallet.warehouseName,
|
||||
flowState: pallet.flowState,
|
||||
capacityPackages: pallet.capacityPackages,
|
||||
};
|
||||
form.qrToken = pallet.qrToken || qrToken;
|
||||
form.warehouseId = pallet.warehouseId;
|
||||
loadStrainOptions();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
resetOperation();
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function handleStrainChange(event) {
|
||||
const option = strainOptions.value[Number(event.detail.value)];
|
||||
form.strainId = option?.id || null;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function markPayloadChanged() {
|
||||
if (!submitLoading.value) idempotencyKey.value = "";
|
||||
}
|
||||
|
||||
function handleMaterialDateChange(field, event) {
|
||||
if (operationPending.value) return;
|
||||
form[field] = event.detail.value;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function ensureIdempotencyKey() {
|
||||
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||
return idempotencyKey.value;
|
||||
}
|
||||
|
||||
function submitFilling() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
const initialPackages = Number(form.initialPackages);
|
||||
if (!scannedPallet.value || !form.qrToken || !form.warehouseId) {
|
||||
formError.value = "请先扫描待装填托盘";
|
||||
return;
|
||||
}
|
||||
if (!form.strainId || !form.batchNo.trim() || !form.packageSpec.trim()) {
|
||||
formError.value = "请完整填写菌种、批次和包装规格";
|
||||
return;
|
||||
}
|
||||
if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
|
||||
formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
|
||||
return;
|
||||
}
|
||||
if (initialPackages !== 100) {
|
||||
formError.value = "新装填固定为 100 包";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
addFilling({
|
||||
qrToken: form.qrToken,
|
||||
warehouseId: form.warehouseId,
|
||||
strainId: form.strainId,
|
||||
batchNo: form.batchNo.trim(),
|
||||
productionDate: form.productionDate,
|
||||
expiryDate: form.expiryDate,
|
||||
packageSpec: form.packageSpec.trim(),
|
||||
initialPackages,
|
||||
idempotencyKey: ensureIdempotencyKey(),
|
||||
})
|
||||
.then((response) => {
|
||||
idempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess(response.msg || "托盘装填成功");
|
||||
resetOperation();
|
||||
loadFillingList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = `${errorMessage(error, "装填提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadFillingList();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,486 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">仓库入库</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描尚未首次入库的空托盘,保存草稿后确认
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="canDraft"
|
||||
class="rounded-lg bg-blue-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startNewDraft"
|
||||
>
|
||||
新建草稿
|
||||
</button>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有入库操作权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-4 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">
|
||||
{{ editing ? "编辑入库草稿" : "新建入库草稿" }}
|
||||
</text>
|
||||
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">入库仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择授权仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||
<PalletScanInput
|
||||
:disabled="!form.warehouseId || detailLoading || operationPending"
|
||||
:loading="scanLoading"
|
||||
@scan="handleInboundScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 UNSTOCKED 托盘;同一托盘不会重复加入。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
|
||||
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||
<view
|
||||
v-for="pallet in scannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<view>
|
||||
<text class="block text-sm font-medium text-gray-700">
|
||||
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||
</text>
|
||||
<text class="block text-xs text-gray-500">{{ pallet.flowState }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="text-xs text-red-500"
|
||||
size="mini"
|
||||
:disabled="operationPending || detailLoading"
|
||||
@click="removeScannedPallet(pallet.palletId)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||
@click="saveDraft"
|
||||
>
|
||||
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||
:disabled="operationPending"
|
||||
@click="cancelEditor"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">入库单</text>
|
||||
<button
|
||||
class="text-xs text-blue-500"
|
||||
size="mini"
|
||||
:disabled="loading || operationPending"
|
||||
@click="loadInboundList"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!inboundList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无入库单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in inboundList"
|
||||
v-else
|
||||
:key="row.inboundId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.inboundNo || `#${row.inboundId}` }}</text>
|
||||
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
仓库:{{ warehouseName(row.warehouseId) }} · 托盘:{{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startEditDraft(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canConfirm"
|
||||
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="confirmDraft(row)"
|
||||
>
|
||||
{{ confirmLoading ? "确认中" : "确认" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addInbound,
|
||||
confirmInbound,
|
||||
getInbound,
|
||||
getInboundWarehouseOptions,
|
||||
listInbound,
|
||||
scanInboundPallet,
|
||||
updateInbound,
|
||||
} from "@/api/business/inbound";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const inboundList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const scannedPallets = ref([]);
|
||||
const editorVisible = ref(false);
|
||||
const editing = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const confirmLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const formError = ref("");
|
||||
const scanError = ref("");
|
||||
const confirmError = ref("");
|
||||
const form = reactive({ inboundId: null, warehouseId: null });
|
||||
const confirmState = reactive({ inboundId: null, key: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:inbound:list",
|
||||
"business:inbound:query",
|
||||
"business:inbound:add",
|
||||
"business:inbound:edit",
|
||||
"business:inbound:confirm",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:inbound:list"));
|
||||
const canDraft = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:add"]),
|
||||
);
|
||||
const canEdit = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:edit"]),
|
||||
);
|
||||
const canConfirm = computed(() =>
|
||||
proxy.$auth.hasPermi("business:inbound:confirm"),
|
||||
);
|
||||
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||
const operationPending = computed(
|
||||
() =>
|
||||
submitLoading.value ||
|
||||
confirmLoading.value ||
|
||||
detailLoading.value,
|
||||
);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === form.warehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === form.warehouseId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `#${id || "-"}`;
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
scanGeneration += 1;
|
||||
editorVisible.value = false;
|
||||
editing.value = false;
|
||||
form.inboundId = null;
|
||||
form.warehouseId = null;
|
||||
scannedPallets.value = [];
|
||||
detailLoading.value = false;
|
||||
scanLoading.value = false;
|
||||
formError.value = "";
|
||||
scanError.value = "";
|
||||
}
|
||||
|
||||
function loadInboundList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listInbound({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
inboundList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
inboundList.value = [];
|
||||
listError.value = errorMessage(error, "入库单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWarehouseOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:inbound:query")) return;
|
||||
optionLoading.value = true;
|
||||
getInboundWarehouseOptions()
|
||||
.then((response) => {
|
||||
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
formError.value = errorMessage(error, "仓库选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startNewDraft() {
|
||||
if (!canDraft.value || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
loadWarehouseOptions();
|
||||
}
|
||||
|
||||
function startEditDraft(row) {
|
||||
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
editing.value = true;
|
||||
form.inboundId = row.inboundId;
|
||||
loadWarehouseOptions();
|
||||
detailLoading.value = true;
|
||||
getInbound(row.inboundId)
|
||||
.then((response) => {
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "DRAFT") {
|
||||
formError.value = "该入库单已不是草稿状态,不能编辑";
|
||||
return;
|
||||
}
|
||||
form.warehouseId = detail.warehouseId;
|
||||
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
flowState: item.flowState || "UNSTOCKED",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "入库单详情加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
detailLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditor() {
|
||||
if (operationPending.value) return;
|
||||
resetEditor();
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.warehouseId) return;
|
||||
form.warehouseId = option.id;
|
||||
scannedPallets.value = [];
|
||||
scanError.value = "";
|
||||
scanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleInboundScan(qrToken) {
|
||||
if (!form.warehouseId || operationPending.value || scanLoading.value) {
|
||||
scanError.value = "请先选择入库仓库";
|
||||
return;
|
||||
}
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanInboundPallet(qrToken, { warehouseId: form.warehouseId })
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "UNSTOCKED") {
|
||||
scanError.value = "该托盘不是未入库状态,不能加入入库草稿";
|
||||
return;
|
||||
}
|
||||
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在当前草稿中");
|
||||
return;
|
||||
}
|
||||
scannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
flowState: pallet.flowState,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function removeScannedPallet(palletId) {
|
||||
if (operationPending.value) return;
|
||||
scannedPallets.value = scannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||
if (!form.warehouseId) {
|
||||
formError.value = "请选择入库仓库";
|
||||
return;
|
||||
}
|
||||
if (!scannedPallets.value.length) {
|
||||
formError.value = "请至少扫描一个托盘";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
const payload = {
|
||||
warehouseId: form.warehouseId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||
};
|
||||
const submitRequest = editing.value
|
||||
? updateInbound({ ...payload, inboundId: form.inboundId })
|
||||
: addInbound(payload);
|
||||
submitRequest
|
||||
.then((response) => {
|
||||
proxy.$modal.msgSuccess(response.msg || "入库草稿保存成功");
|
||||
resetEditor();
|
||||
loadInboundList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "入库草稿保存失败,请根据提示修正后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirm(content) {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "系统提示",
|
||||
content,
|
||||
cancelText: "取消",
|
||||
confirmText: "确定",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(inboundId) {
|
||||
if (confirmState.inboundId !== inboundId) {
|
||||
confirmState.inboundId = inboundId;
|
||||
confirmState.key = "";
|
||||
}
|
||||
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||
return confirmState.key;
|
||||
}
|
||||
|
||||
function clearConfirmIdempotency() {
|
||||
confirmState.inboundId = null;
|
||||
confirmState.key = "";
|
||||
}
|
||||
|
||||
async function confirmDraft(row) {
|
||||
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
if (!(await askConfirm(`确认入库单“${row.inboundNo || `#${row.inboundId}`}”?`))) return;
|
||||
confirmLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
await confirmInbound(row.inboundId, {
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(row.inboundId),
|
||||
});
|
||||
clearConfirmIdempotency();
|
||||
proxy.$modal.msgSuccess("入库确认成功");
|
||||
loadInboundList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "入库确认失败")};再次确认将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadInboundList();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">退回仓库</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
客户现场发起退回申请,仓库逐托扫描后确认收货
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有退回操作权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="canRequest" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">发起退回申请</text>
|
||||
<button class="text-xs text-gray-500" size="mini" :disabled="requestPending" @click="resetRequestOperation">
|
||||
清空
|
||||
</button>
|
||||
</view>
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">目标仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || requestPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择目标仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="!requestForm.targetWarehouseId || requestPending"
|
||||
:loading="scanLoading.request"
|
||||
@scan="handleRequestScan"
|
||||
@invalid="handleRequestScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
同一申请只能包含同一客户现场的托盘;扫码成功后托盘由服务端冻结。
|
||||
</text>
|
||||
<text v-if="requestError" class="mt-2 block text-xs text-red-500">{{ requestError }}</text>
|
||||
<view v-if="requestScannedPallets.length" class="mt-3 space-y-2">
|
||||
<view
|
||||
v-for="pallet in requestScannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<view>
|
||||
<text class="block text-sm text-gray-700">{{ pallet.palletCode || `#${pallet.palletId}` }}</text>
|
||||
<text class="block text-xs text-gray-500">现场 #{{ pallet.siteId }} · 剩余 {{ pallet.remainingPackages }} 包</text>
|
||||
</view>
|
||||
<button class="text-xs text-red-500" size="mini" :disabled="requestPending" @click="removeRequestPallet(pallet.palletId)">
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="requestForm.sourceSiteId" class="mt-3 block text-xs text-gray-500">
|
||||
来源现场:#{{ requestForm.sourceSiteId }} · {{ requestScannedPallets.length }} 个托盘
|
||||
</text>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!requestScannedPallets.length || requestPending || scanLoading.request"
|
||||
@click="submitReturnRequest"
|
||||
>
|
||||
{{ requestSubmitLoading ? "申请中" : "提交退回申请" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="canConfirm" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">仓库确认退回</text>
|
||||
<text class="text-xs text-gray-400">必须完整扫描单据托盘</text>
|
||||
</view>
|
||||
<picker
|
||||
:range="requestedReturns"
|
||||
range-key="returnNo"
|
||||
:value="requestedReturnIndex"
|
||||
:disabled="confirmPending || detailLoading"
|
||||
@change="handleConfirmSelection"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedReturnLabel || "请选择待确认退回单" }}
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="confirmError" class="mt-2 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="confirmExpectedRows.length" class="mt-3 rounded-xl bg-orange-50 p-3">
|
||||
<text class="block text-xs text-gray-500">
|
||||
预期托盘 {{ confirmExpectedRows.length }} 个,已扫描 {{ confirmScannedPallets.length }} 个
|
||||
</text>
|
||||
<view class="mt-2 space-y-1">
|
||||
<text v-for="row in confirmExpectedRows" :key="row.palletId" class="block text-xs text-gray-600">
|
||||
{{ row.palletCode || `#${row.palletId}` }}
|
||||
<text v-if="isConfirmPalletScanned(row.palletId)" class="text-green-600"> · 已扫描</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
class="mt-3"
|
||||
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || detailLoading"
|
||||
:loading="scanLoading.confirm"
|
||||
@scan="handleConfirmScan"
|
||||
@invalid="handleConfirmScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || scanLoading.confirm || detailLoading"
|
||||
@click="submitReturnConfirm"
|
||||
>
|
||||
{{ confirmSubmitLoading ? "确认中" : "确认退回收货" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">退回单</text>
|
||||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadReturnList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!returnList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无退回单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in returnList"
|
||||
v-else
|
||||
:key="row.returnId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.returnNo || `#${row.returnId}` }}</text>
|
||||
<text :class="row.status === 'REQUESTED' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||
{{ row.status === "REQUESTED" ? "待确认" : "已确认" }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
现场 #{{ row.sourceSiteId }} → {{ warehouseName(row.targetWarehouseId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<button
|
||||
v-if="row.status === 'REQUESTED' && canConfirm"
|
||||
class="mt-3 w-full rounded-lg bg-orange-50 py-2 text-xs text-orange-600"
|
||||
size="mini"
|
||||
:disabled="confirmPending"
|
||||
@click="selectReturnRow(row)"
|
||||
>
|
||||
选择并确认
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
confirmReturn,
|
||||
getReturn,
|
||||
getReturnWarehouseOptions,
|
||||
listReturn,
|
||||
requestReturn,
|
||||
scanReturnPallet,
|
||||
} from "@/api/business/return";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const returnList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const requestScannedPallets = ref([]);
|
||||
const confirmExpectedRows = ref([]);
|
||||
const confirmExpectedPalletIds = ref([]);
|
||||
const confirmScannedPallets = ref([]);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const requestError = ref("");
|
||||
const confirmError = ref("");
|
||||
const scanLoading = reactive({ request: false, confirm: false });
|
||||
const requestSubmitLoading = ref(false);
|
||||
const confirmSubmitLoading = ref(false);
|
||||
const requestIdempotencyKey = ref("");
|
||||
const confirmIdempotencyKey = ref("");
|
||||
const requestForm = reactive({ sourceSiteId: null, targetWarehouseId: null });
|
||||
const confirmForm = reactive({ returnId: null });
|
||||
let listGeneration = 0;
|
||||
let requestScanGeneration = 0;
|
||||
let confirmScanGeneration = 0;
|
||||
let detailGeneration = 0;
|
||||
let requestSession = 0;
|
||||
let confirmSession = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:return:list",
|
||||
"business:return:query",
|
||||
"business:return:request",
|
||||
"business:return:confirm",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:return:list"));
|
||||
const canRequest = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:request"]),
|
||||
);
|
||||
const canConfirm = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:confirm"]),
|
||||
);
|
||||
const requestPending = computed(() => requestSubmitLoading.value);
|
||||
const confirmPending = computed(() => confirmSubmitLoading.value);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === requestForm.targetWarehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === requestForm.targetWarehouseId)?.name || "",
|
||||
);
|
||||
const requestedReturns = computed(() =>
|
||||
returnList.value.filter((item) => item.status === "REQUESTED"),
|
||||
);
|
||||
const requestedReturnIndex = computed(() => {
|
||||
const index = requestedReturns.value.findIndex(
|
||||
(item) => item.returnId === confirmForm.returnId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedReturnLabel = computed(() => {
|
||||
const item = requestedReturns.value.find((row) => row.returnId === confirmForm.returnId);
|
||||
return item ? `${item.returnNo || `#${item.returnId}`} · 现场 #${item.sourceSiteId}` : "";
|
||||
});
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||
}
|
||||
|
||||
function loadReturnList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listReturn({ pageNum: 1, pageSize: 30 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
returnList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
returnList.value = [];
|
||||
listError.value = errorMessage(error, "退回单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWarehouseOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:return:query")) return;
|
||||
optionLoading.value = true;
|
||||
getReturnWarehouseOptions()
|
||||
.then((response) => {
|
||||
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
requestError.value = errorMessage(error, "目标仓库选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetRequestOperation() {
|
||||
requestSession += 1;
|
||||
requestScanGeneration += 1;
|
||||
requestForm.sourceSiteId = null;
|
||||
requestForm.targetWarehouseId = null;
|
||||
requestScannedPallets.value = [];
|
||||
requestIdempotencyKey.value = "";
|
||||
requestError.value = "";
|
||||
scanLoading.request = false;
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === requestForm.targetWarehouseId) return;
|
||||
requestForm.targetWarehouseId = option.id;
|
||||
requestForm.sourceSiteId = null;
|
||||
requestScannedPallets.value = [];
|
||||
requestIdempotencyKey.value = "";
|
||||
requestError.value = "";
|
||||
requestScanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleRequestScan(qrToken) {
|
||||
if (!requestForm.targetWarehouseId || requestPending.value || scanLoading.request) {
|
||||
requestError.value = "请先选择目标仓库";
|
||||
return;
|
||||
}
|
||||
const session = requestSession;
|
||||
const generation = ++requestScanGeneration;
|
||||
scanLoading.request = true;
|
||||
requestError.value = "";
|
||||
scanReturnPallet(qrToken, { mode: "request" })
|
||||
.then((response) => {
|
||||
if (session !== requestSession || generation !== requestScanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (!pallet?.siteId) {
|
||||
requestError.value = "该托盘不在客户现场,不能发起退回";
|
||||
return;
|
||||
}
|
||||
if (requestForm.sourceSiteId && pallet.siteId !== requestForm.sourceSiteId) {
|
||||
requestError.value = "同一退回申请只能包含同一客户现场的托盘";
|
||||
return;
|
||||
}
|
||||
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在退回申请中");
|
||||
return;
|
||||
}
|
||||
requestForm.sourceSiteId = requestForm.sourceSiteId || pallet.siteId;
|
||||
requestScannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
siteId: pallet.siteId,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
});
|
||||
requestIdempotencyKey.value = "";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session === requestSession && generation === requestScanGeneration) {
|
||||
requestError.value = errorMessage(error, "退回托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session === requestSession && generation === requestScanGeneration) {
|
||||
scanLoading.request = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRequestScanInvalid(message) {
|
||||
requestError.value = message;
|
||||
}
|
||||
|
||||
function removeRequestPallet(palletId) {
|
||||
if (requestPending.value) return;
|
||||
requestScannedPallets.value = requestScannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
if (!requestScannedPallets.value.length) requestForm.sourceSiteId = null;
|
||||
requestIdempotencyKey.value = "";
|
||||
}
|
||||
|
||||
function ensureRequestIdempotencyKey() {
|
||||
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey();
|
||||
return requestIdempotencyKey.value;
|
||||
}
|
||||
|
||||
async function submitReturnRequest() {
|
||||
if (requestPending.value || scanLoading.request || !requestScannedPallets.value.length) return;
|
||||
if (!requestForm.sourceSiteId || !requestForm.targetWarehouseId) {
|
||||
requestError.value = "请选择目标仓库并扫描退回托盘";
|
||||
return;
|
||||
}
|
||||
requestSubmitLoading.value = true;
|
||||
requestError.value = "";
|
||||
try {
|
||||
await requestReturn({
|
||||
sourceSiteId: requestForm.sourceSiteId,
|
||||
targetWarehouseId: requestForm.targetWarehouseId,
|
||||
palletIds: requestScannedPallets.value.map((item) => item.palletId),
|
||||
idempotencyKey: ensureRequestIdempotencyKey(),
|
||||
});
|
||||
requestIdempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess("退回申请已提交");
|
||||
resetRequestOperation();
|
||||
loadReturnList();
|
||||
} catch (error) {
|
||||
requestError.value = `${errorMessage(error, "退回申请提交失败")};直接重试将复用本次幂等键`;
|
||||
} finally {
|
||||
requestSubmitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetConfirmOperation() {
|
||||
confirmSession += 1;
|
||||
confirmScanGeneration += 1;
|
||||
detailGeneration += 1;
|
||||
confirmForm.returnId = null;
|
||||
confirmExpectedRows.value = [];
|
||||
confirmExpectedPalletIds.value = [];
|
||||
confirmScannedPallets.value = [];
|
||||
confirmIdempotencyKey.value = "";
|
||||
confirmError.value = "";
|
||||
detailLoading.value = false;
|
||||
scanLoading.confirm = false;
|
||||
}
|
||||
|
||||
function handleConfirmSelection(event) {
|
||||
const row = requestedReturns.value[Number(event.detail.value)];
|
||||
selectReturnRow(row);
|
||||
}
|
||||
|
||||
function selectReturnRow(row) {
|
||||
if (!row || row.status !== "REQUESTED" || confirmPending.value) return;
|
||||
resetConfirmOperation();
|
||||
const session = confirmSession;
|
||||
const generation = ++detailGeneration;
|
||||
confirmForm.returnId = row.returnId;
|
||||
detailLoading.value = true;
|
||||
getReturn(row.returnId)
|
||||
.then((response) => {
|
||||
if (session !== confirmSession || generation !== detailGeneration) return;
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "REQUESTED") {
|
||||
confirmError.value = "该退回单已不是待确认状态";
|
||||
return;
|
||||
}
|
||||
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
}),
|
||||
);
|
||||
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId);
|
||||
if (!confirmExpectedPalletIds.value.length) confirmError.value = "该退回单没有托盘明细";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session === confirmSession && generation === detailGeneration) {
|
||||
confirmError.value = errorMessage(error, "退回单详情加载失败,请稍后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session === confirmSession && generation === detailGeneration) {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmScan(qrToken) {
|
||||
if (!confirmForm.returnId || !confirmExpectedPalletIds.value.length || confirmPending.value || scanLoading.confirm) return;
|
||||
const session = confirmSession;
|
||||
const generation = ++confirmScanGeneration;
|
||||
scanLoading.confirm = true;
|
||||
confirmError.value = "";
|
||||
scanReturnPallet(qrToken, { mode: "confirm", returnId: confirmForm.returnId })
|
||||
.then((response) => {
|
||||
if (session !== confirmSession || generation !== confirmScanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
|
||||
confirmError.value = "该托盘不属于所选退回单";
|
||||
return;
|
||||
}
|
||||
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已完成扫码");
|
||||
return;
|
||||
}
|
||||
confirmScannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
});
|
||||
confirmIdempotencyKey.value = "";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||
confirmError.value = errorMessage(error, "到仓托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||
scanLoading.confirm = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmScanInvalid(message) {
|
||||
confirmError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function isConfirmPalletScanned(palletId) {
|
||||
return confirmScannedPallets.value.some((item) => item.palletId === palletId);
|
||||
}
|
||||
|
||||
function sameIdSet(left, right) {
|
||||
if (left.length !== right.length) return false;
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return (
|
||||
leftSet.size === left.length &&
|
||||
rightSet.size === right.length &&
|
||||
right.every((id) => leftSet.has(id))
|
||||
);
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(returnId) {
|
||||
if (!confirmIdempotencyKey.value) confirmIdempotencyKey.value = createIdempotencyKey();
|
||||
return confirmIdempotencyKey.value;
|
||||
}
|
||||
|
||||
async function submitReturnConfirm() {
|
||||
if (confirmPending.value || detailLoading.value || scanLoading.confirm) return;
|
||||
const returnId = confirmForm.returnId;
|
||||
const expectedIds = [...confirmExpectedPalletIds.value];
|
||||
const scannedIds = confirmScannedPallets.value.map((item) => item.palletId);
|
||||
if (!returnId || !sameIdSet(expectedIds, scannedIds)) {
|
||||
confirmError.value = "必须逐个扫描并完整匹配退回单全部托盘";
|
||||
return;
|
||||
}
|
||||
confirmSubmitLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
const latest = (await getReturn(returnId)).data;
|
||||
const latestIds = (Array.isArray(latest.items) ? latest.items : []).map((item) => item.palletId);
|
||||
if (latest.status !== "REQUESTED" || !sameIdSet(latestIds, scannedIds)) {
|
||||
confirmError.value = "退回单状态或托盘明细已变化,请重新选择并扫码";
|
||||
return;
|
||||
}
|
||||
await confirmReturn(returnId, {
|
||||
scannedPalletIds: scannedIds,
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(returnId),
|
||||
});
|
||||
confirmIdempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess("退回确认成功");
|
||||
resetConfirmOperation();
|
||||
loadReturnList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "退回确认失败")};直接重试将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmSubmitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadReturnList();
|
||||
loadWarehouseOptions();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">{{ activeTab === 'receipt' ? '收货核对' : '发货出库' }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ activeTab === 'receipt' ? '扫描原托盘标签,逐托核对目标现场收货' : '选择来源仓库和目标现场,扫码创建整托发货单' }}
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="canDraft && activeTab === 'shipment'"
|
||||
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startNewDraft"
|
||||
>
|
||||
新建草稿
|
||||
</button>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
<view v-if="canReceive" class="flex gap-3">
|
||||
<button v-if="canShipment" size="mini" :class="activeTab === 'shipment' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'shipment'">发货出库</button>
|
||||
<button size="mini" :disabled="operationPending || editorVisible" :class="activeTab === 'receipt' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'receipt'">收货核对</button>
|
||||
</view>
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有发货或收货权限</text>
|
||||
</view>
|
||||
|
||||
<ShipmentReceiptPanel v-else-if="canReceive && activeTab === 'receipt'" @received="loadShipmentList" />
|
||||
<template v-else-if="canShipment && activeTab === 'shipment'">
|
||||
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-4 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">
|
||||
{{ editing ? "编辑发货草稿" : "新建发货草稿" }}
|
||||
</text>
|
||||
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">来源仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择来源仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">目标客户现场</text>
|
||||
<picker
|
||||
:range="siteOptions"
|
||||
range-key="name"
|
||||
:value="siteIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleSiteChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedSiteName || "请选择目标现场" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||
<PalletScanInput
|
||||
:disabled="!form.sourceWarehouseId || !form.targetSiteId || detailLoading || operationPending"
|
||||
:loading="scanLoading"
|
||||
@scan="handleShipmentScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受来源仓库内有剩余包数的 FILLED_IN_WAREHOUSE 托盘。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
|
||||
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||
<view
|
||||
v-for="pallet in scannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="rounded-xl bg-orange-50 px-3 py-2"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-sm font-medium text-gray-700">
|
||||
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||
</text>
|
||||
<text class="block text-xs text-gray-500">
|
||||
{{ pallet.strainName || pallet.strainCode || "未提供菌种" }} · 剩余 {{ pallet.remainingPackages }} 包
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
class="text-xs text-red-500"
|
||||
size="mini"
|
||||
:disabled="operationPending || detailLoading"
|
||||
@click="removeScannedPallet(pallet.palletId)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-400">
|
||||
批次:{{ pallet.batchNo || "—" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||
@click="saveDraft"
|
||||
>
|
||||
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||
:disabled="operationPending"
|
||||
@click="cancelEditor"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">发货单</text>
|
||||
<button
|
||||
class="text-xs text-blue-500"
|
||||
size="mini"
|
||||
:disabled="loading || operationPending"
|
||||
@click="loadShipmentList"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!shipmentList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无发货单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in shipmentList"
|
||||
v-else
|
||||
:key="row.shipmentId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
|
||||
<text :class="row.status === 'DRAFT' || row.receiptState === 'PENDING' ? 'text-orange-500' : row.receiptState === 'LEGACY' ? 'text-gray-500' : 'text-green-500'" class="text-xs">
|
||||
{{ shipmentStatusLabel(row) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ warehouseName(row.sourceWarehouseId) }} → {{ siteName(row.targetSiteId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startEditDraft(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canConfirm"
|
||||
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="confirmDraft(row)"
|
||||
>
|
||||
{{ confirmLoading ? "确认中" : "确认" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import ShipmentReceiptPanel from "@/components/business/ShipmentReceiptPanel.vue";
|
||||
import {
|
||||
addShipment,
|
||||
confirmShipment,
|
||||
getShipment,
|
||||
getShipmentSiteOptions,
|
||||
getShipmentWarehouseOptions,
|
||||
listShipment,
|
||||
scanShipmentPallet,
|
||||
updateShipment,
|
||||
} from "@/api/business/shipment";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const shipmentList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const siteOptions = ref([]);
|
||||
const scannedPallets = ref([]);
|
||||
const editorVisible = ref(false);
|
||||
const editing = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const confirmLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const formError = ref("");
|
||||
const scanError = ref("");
|
||||
const confirmError = ref("");
|
||||
const form = reactive({ shipmentId: null, sourceWarehouseId: null, targetSiteId: null });
|
||||
const confirmState = reactive({ shipmentId: null, key: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canShipment = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:shipment:list",
|
||||
"business:shipment:query",
|
||||
"business:shipment:add",
|
||||
"business:shipment:edit",
|
||||
"business:shipment:confirm",
|
||||
]),
|
||||
);
|
||||
const canReceive = computed(() => proxy.$auth.hasPermi("business:shipment:receive"));
|
||||
const canPage = computed(() => canShipment.value || canReceive.value);
|
||||
const activeTab = ref(canShipment.value ? "shipment" : "receipt");
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
|
||||
const canDraft = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
|
||||
);
|
||||
const canEdit = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:edit"]),
|
||||
);
|
||||
const canConfirm = computed(() => proxy.$auth.hasPermi("business:shipment:confirm"));
|
||||
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||
const operationPending = computed(
|
||||
() =>
|
||||
submitLoading.value ||
|
||||
confirmLoading.value ||
|
||||
detailLoading.value,
|
||||
);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === form.sourceWarehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const siteIndex = computed(() => {
|
||||
const index = siteOptions.value.findIndex((item) => item.id === form.targetSiteId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === form.sourceWarehouseId)?.name || "",
|
||||
);
|
||||
const selectedSiteName = computed(
|
||||
() => siteOptions.value.find((item) => item.id === form.targetSiteId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function shipmentStatusLabel(row) {
|
||||
if (row.status === "DRAFT") return "草稿";
|
||||
return { PENDING: "待收货", RECEIVED: "已收货", LEGACY: "历史未登记收货" }[row.receiptState] || "已确认";
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||
}
|
||||
|
||||
function siteName(id) {
|
||||
return siteOptions.value.find((item) => item.id === id)?.name || `现场 #${id || "-"}`;
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
scanGeneration += 1;
|
||||
editorVisible.value = false;
|
||||
editing.value = false;
|
||||
form.shipmentId = null;
|
||||
form.sourceWarehouseId = null;
|
||||
form.targetSiteId = null;
|
||||
scannedPallets.value = [];
|
||||
detailLoading.value = false;
|
||||
scanLoading.value = false;
|
||||
formError.value = "";
|
||||
scanError.value = "";
|
||||
}
|
||||
|
||||
function loadShipmentList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listShipment({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
shipmentList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
shipmentList.value = [];
|
||||
listError.value = errorMessage(error, "发货单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:shipment:query")) return;
|
||||
optionLoading.value = true;
|
||||
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()])
|
||||
.then(([warehouseResponse, siteResponse]) => {
|
||||
warehouseOptions.value = Array.isArray(warehouseResponse.data)
|
||||
? warehouseResponse.data
|
||||
: [];
|
||||
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
siteOptions.value = [];
|
||||
formError.value = errorMessage(error, "仓库或现场选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startNewDraft() {
|
||||
if (!canDraft.value || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
loadOptions();
|
||||
}
|
||||
|
||||
function startEditDraft(row) {
|
||||
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
editing.value = true;
|
||||
form.shipmentId = row.shipmentId;
|
||||
loadOptions();
|
||||
detailLoading.value = true;
|
||||
getShipment(row.shipmentId)
|
||||
.then((response) => {
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "DRAFT") {
|
||||
formError.value = "该发货单已不是草稿状态,不能编辑";
|
||||
return;
|
||||
}
|
||||
form.sourceWarehouseId = detail.sourceWarehouseId;
|
||||
form.targetSiteId = detail.targetSiteId;
|
||||
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
strainName: item.strainName || item.strainCode,
|
||||
batchNo: item.batchNo,
|
||||
remainingPackages: item.remainingPackages ?? item.packageQty ?? 0,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "发货单详情加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
detailLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditor() {
|
||||
if (operationPending.value) return;
|
||||
resetEditor();
|
||||
}
|
||||
|
||||
function clearScannedPallets() {
|
||||
scannedPallets.value = [];
|
||||
scanError.value = "";
|
||||
scanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.sourceWarehouseId) return;
|
||||
form.sourceWarehouseId = option.id;
|
||||
clearScannedPallets();
|
||||
}
|
||||
|
||||
function handleSiteChange(event) {
|
||||
const option = siteOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.targetSiteId) return;
|
||||
form.targetSiteId = option.id;
|
||||
clearScannedPallets();
|
||||
}
|
||||
|
||||
function handleShipmentScan(qrToken) {
|
||||
if (
|
||||
!form.sourceWarehouseId ||
|
||||
!form.targetSiteId ||
|
||||
operationPending.value ||
|
||||
scanLoading.value
|
||||
) {
|
||||
scanError.value = "请先选择来源仓库和目标现场";
|
||||
return;
|
||||
}
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanShipmentPallet(qrToken, {
|
||||
sourceWarehouseId: form.sourceWarehouseId,
|
||||
targetSiteId: form.targetSiteId,
|
||||
})
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "FILLED_IN_WAREHOUSE" || pallet.remainingPackages <= 0) {
|
||||
scanError.value = "该托盘不是来源仓库内的有料托盘,不能发货";
|
||||
return;
|
||||
}
|
||||
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在当前发货草稿中");
|
||||
return;
|
||||
}
|
||||
scannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
strainName: pallet.strainName || pallet.strainCode,
|
||||
batchNo: pallet.batchNo,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function removeScannedPallet(palletId) {
|
||||
if (operationPending.value) return;
|
||||
scannedPallets.value = scannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||
if (!form.sourceWarehouseId || !form.targetSiteId) {
|
||||
formError.value = "请选择来源仓库和目标现场";
|
||||
return;
|
||||
}
|
||||
if (!scannedPallets.value.length) {
|
||||
formError.value = "请至少扫描一个有料托盘";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
const payload = {
|
||||
sourceWarehouseId: form.sourceWarehouseId,
|
||||
targetSiteId: form.targetSiteId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||
};
|
||||
const submitRequest = editing.value
|
||||
? updateShipment({ ...payload, shipmentId: form.shipmentId })
|
||||
: addShipment(payload);
|
||||
submitRequest
|
||||
.then((response) => {
|
||||
proxy.$modal.msgSuccess(response.msg || "发货草稿保存成功");
|
||||
resetEditor();
|
||||
loadShipmentList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "发货草稿保存失败,请根据提示修正后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirm(content) {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "系统提示",
|
||||
content,
|
||||
cancelText: "取消",
|
||||
confirmText: "确定",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(shipmentId) {
|
||||
if (confirmState.shipmentId !== shipmentId) {
|
||||
confirmState.shipmentId = shipmentId;
|
||||
confirmState.key = "";
|
||||
}
|
||||
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||
return confirmState.key;
|
||||
}
|
||||
|
||||
function clearConfirmIdempotency() {
|
||||
confirmState.shipmentId = null;
|
||||
confirmState.key = "";
|
||||
}
|
||||
|
||||
async function confirmDraft(row) {
|
||||
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
if (!(await askConfirm(`确认发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`))) return;
|
||||
confirmLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
await confirmShipment(row.shipmentId, {
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(row.shipmentId),
|
||||
});
|
||||
clearConfirmIdempotency();
|
||||
proxy.$modal.msgSuccess("发货确认成功");
|
||||
loadShipmentList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "发货确认失败")};再次确认将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadShipmentList();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,820 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描现场托盘,确认入隧后记录实际使用包数,产线可选
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
|
||||
<text class="block font-bold text-gray-800">提交结果核对</text>
|
||||
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
|
||||
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
|
||||
</text>
|
||||
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
|
||||
<text class="block text-sm text-gray-700">
|
||||
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
|
||||
</text>
|
||||
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
|
||||
恢复重试 / 核对
|
||||
</button>
|
||||
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
|
||||
已人工核对,解除待确认
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
|
||||
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
|
||||
</view>
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">扫描待使用托盘</text>
|
||||
<button class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
|
||||
重新扫描
|
||||
</button>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="operationPending || optionLoading"
|
||||
:loading="scanLoading"
|
||||
@scan="handleUsageScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
|
||||
</text>
|
||||
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
|
||||
|
||||
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">
|
||||
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||
</text>
|
||||
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
生产日期:{{ scannedPallet.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="text-sm text-gray-700">先到先用建议</text>
|
||||
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
|
||||
刷新建议
|
||||
</button>
|
||||
</view>
|
||||
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">托盘入隧</text>
|
||||
<view v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||
{{ selectedTunnelName }}(已入隧,不可切换)
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="tunnelPickerOptions"
|
||||
range-key="label"
|
||||
:value="tunnelIndex"
|
||||
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
|
||||
@change="handleTunnelChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedTunnelName || "请选择使用隧道" }}
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
|
||||
先确认入隧,再提交使用包数。
|
||||
</text>
|
||||
<view class="mt-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
|
||||
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
|
||||
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
|
||||
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="!tunnelLocked"
|
||||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
|
||||
@click="enterTunnel"
|
||||
>
|
||||
{{ entryLoading ? "入隧中" : "确认入隧" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">使用信息</text>
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">使用产线(可选)</text>
|
||||
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||
{{ selectedLineName }}(已由托盘绑定)
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="linePickerOptions"
|
||||
range-key="name"
|
||||
:value="lineIndex"
|
||||
:disabled="operationPending || lineOptionLoading || !scannedPallet"
|
||||
@change="handleLineChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedLineName || "不选择产线" }}
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
|
||||
<input
|
||||
v-model="form.usedPackages"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
type="number"
|
||||
placeholder="请输入正整数"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
|
||||
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
|
||||
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
|
||||
<text class="mt-1 block text-xs text-gray-600">
|
||||
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
|
||||
@click="submitUsage"
|
||||
>
|
||||
{{ submitLoading ? "提交中" : "确认使用" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">最近使用记录</text>
|
||||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无使用记录
|
||||
</view>
|
||||
<view
|
||||
v-for="row in usageList"
|
||||
v-else
|
||||
:key="row.usageId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
|
||||
</text>
|
||||
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
|
||||
产线 #{{ row.lineId }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-400">
|
||||
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addUsage,
|
||||
enterUsageTunnel,
|
||||
getUsageFifoRecommendation,
|
||||
getUsageLineOptions,
|
||||
getUsageTunnelOptions,
|
||||
listUsage,
|
||||
scanUsagePallet,
|
||||
} from "@/api/business/usage";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
import config from "@/config";
|
||||
import constant from "@/utils/constant";
|
||||
import storage from "@/utils/storage";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const usageList = ref([]);
|
||||
const lineOptions = ref([]);
|
||||
const tunnelOptions = ref([]);
|
||||
const scannedPallet = ref(null);
|
||||
const usageResult = ref(null);
|
||||
const lineLocked = ref(false);
|
||||
const tunnelLocked = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const lineOptionLoading = ref(false);
|
||||
const lineOptionError = ref("");
|
||||
const fifoLoading = ref(false);
|
||||
const fifoMessage = ref("");
|
||||
const fifoRequiresSkipReason = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const entryLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const operationError = ref("");
|
||||
const submitError = ref("");
|
||||
const pendingOperations = ref([]);
|
||||
const recoveryStorageKey = ref("");
|
||||
const recoveryLoading = ref(false);
|
||||
const recoveryError = ref("");
|
||||
const recoveryMessage = ref("");
|
||||
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
let optionGeneration = 0;
|
||||
let fifoGeneration = 0;
|
||||
let operationSession = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:usage:list",
|
||||
"business:usage:query",
|
||||
"business:usage:add",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
|
||||
const canOperate = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
|
||||
);
|
||||
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
|
||||
const linePickerOptions = computed(() => [
|
||||
{ id: null, name: "不选择产线" },
|
||||
...lineOptions.value,
|
||||
]);
|
||||
const lineIndex = computed(() => {
|
||||
const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedLineName = computed(
|
||||
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
|
||||
);
|
||||
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
|
||||
...item,
|
||||
label: formatTunnelName(item),
|
||||
})));
|
||||
const tunnelIndex = computed(() => {
|
||||
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedTunnelName = computed(() => {
|
||||
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
|
||||
if (option) return formatTunnelName(option);
|
||||
if (!form.tunnelId) return "";
|
||||
return formatTunnelName({
|
||||
id: form.tunnelId,
|
||||
name: scannedPallet.value?.tunnelName,
|
||||
code: scannedPallet.value?.tunnelCode,
|
||||
});
|
||||
});
|
||||
|
||||
function formatTunnelName(option) {
|
||||
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
|
||||
}
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function currentRecoveryStorageKey() {
|
||||
const userId = storage.get(constant.id);
|
||||
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
|
||||
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
|
||||
}
|
||||
|
||||
function loadPendingOperations() {
|
||||
try {
|
||||
const key = currentRecoveryStorageKey();
|
||||
const records = uni.getStorageSync(key) || [];
|
||||
if (!Array.isArray(records) || records.some((item) =>
|
||||
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
|
||||
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
|
||||
recoveryStorageKey.value = key;
|
||||
pendingOperations.value = records;
|
||||
recoveryError.value = "";
|
||||
return true;
|
||||
} catch (error) {
|
||||
pendingOperations.value = [];
|
||||
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function savePendingOperations(records) {
|
||||
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
|
||||
uni.setStorageSync(recoveryStorageKey.value, records);
|
||||
pendingOperations.value = records;
|
||||
}
|
||||
|
||||
function pendingForPallet() {
|
||||
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
|
||||
}
|
||||
|
||||
function operationSignature(action, payload) {
|
||||
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
|
||||
}
|
||||
|
||||
function prepareOperation(action, payload) {
|
||||
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||||
const pending = pendingForPallet();
|
||||
if (pending) {
|
||||
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
|
||||
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
const operation = {
|
||||
action,
|
||||
palletId: scannedPallet.value.palletId,
|
||||
palletCode: scannedPallet.value.palletCode,
|
||||
tunnelName: selectedTunnelName.value,
|
||||
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
|
||||
};
|
||||
savePendingOperations([...pendingOperations.value, operation]);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function completeOperation(operation, response) {
|
||||
const result = response?.data;
|
||||
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
|
||||
const confirmed = result && (operation.action === "usage"
|
||||
? Number.isInteger(result.usageId) && result.usageId > 0 &&
|
||||
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
|
||||
typeof result.flowState === "string" && result.flowState.trim()
|
||||
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
|
||||
typeof result.traceNo === "string" && result.traceNo.trim());
|
||||
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
|
||||
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||||
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCurrentOperation(session, scopeKey) {
|
||||
try {
|
||||
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validatedFifoSkipReason() {
|
||||
const reason = form.fifoSkipReason.trim();
|
||||
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
|
||||
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
|
||||
return reason;
|
||||
}
|
||||
|
||||
function handleFifoRejection(error, operation) {
|
||||
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
|
||||
const response = error?.response;
|
||||
const body = response?.data && typeof response.data === "object" ? response.data : response;
|
||||
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
|
||||
try {
|
||||
if (!loadPendingOperations()) return true;
|
||||
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
|
||||
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
|
||||
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
|
||||
return true;
|
||||
}
|
||||
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
|
||||
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||
} catch (storageError) {
|
||||
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
|
||||
return true;
|
||||
}
|
||||
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
|
||||
fifoRequiresSkipReason.value = true;
|
||||
loadFifoRecommendation();
|
||||
} else {
|
||||
recoveryLoading.value = false;
|
||||
handleUsageScan(operation.payload.qrToken, operation);
|
||||
}
|
||||
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
|
||||
submitError.value = operationError.value;
|
||||
recoveryMessage.value = operationError.value;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function releasePendingOperation(item) {
|
||||
if (operationPending.value) return;
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
|
||||
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||||
if (!operation) return;
|
||||
recoveryLoading.value = true;
|
||||
try {
|
||||
const confirmed = await new Promise((resolve, reject) => {
|
||||
uni.showModal({
|
||||
title: "解除本机待确认记录",
|
||||
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
|
||||
cancelText: "取消",
|
||||
confirmText: "确认解除",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
|
||||
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
|
||||
if (!loadPendingOperations()) return;
|
||||
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
|
||||
resetUsageOperation();
|
||||
}
|
||||
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPendingOperation(item) {
|
||||
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
|
||||
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||||
if (!operation) return;
|
||||
resetUsageOperation();
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
recoveryLoading.value = true;
|
||||
recoveryMessage.value = "";
|
||||
try {
|
||||
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
const result = completeOperation(operation, response);
|
||||
recoveryMessage.value = operation.action === "usage"
|
||||
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
|
||||
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
|
||||
loadUsageList();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
if (handleFifoRejection(error, operation)) return;
|
||||
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadUsageList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listUsage({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
usageList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
usageList.value = [];
|
||||
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetUsageOperation() {
|
||||
operationSession += 1;
|
||||
scanGeneration += 1;
|
||||
optionGeneration += 1;
|
||||
fifoGeneration += 1;
|
||||
scannedPallet.value = null;
|
||||
usageResult.value = null;
|
||||
lineOptions.value = [];
|
||||
tunnelOptions.value = [];
|
||||
lineLocked.value = false;
|
||||
tunnelLocked.value = false;
|
||||
form.qrToken = "";
|
||||
form.tunnelId = null;
|
||||
form.lineId = null;
|
||||
form.usedPackages = "";
|
||||
form.fifoSkipReason = "";
|
||||
operationError.value = "";
|
||||
submitError.value = "";
|
||||
scanLoading.value = false;
|
||||
optionLoading.value = false;
|
||||
lineOptionLoading.value = false;
|
||||
lineOptionError.value = "";
|
||||
fifoLoading.value = false;
|
||||
fifoMessage.value = "";
|
||||
fifoRequiresSkipReason.value = false;
|
||||
submitLoading.value = false;
|
||||
entryLoading.value = false;
|
||||
recoveryLoading.value = false;
|
||||
}
|
||||
|
||||
function loadUsageOptions(pallet, session, scanRequestGeneration) {
|
||||
const generation = ++optionGeneration;
|
||||
const isCurrent = () => generation === optionGeneration &&
|
||||
session === operationSession && scanRequestGeneration === scanGeneration;
|
||||
optionLoading.value = true;
|
||||
lineOptionLoading.value = true;
|
||||
const lineRequest = pallet.currentLineId == null
|
||||
? getUsageLineOptions(pallet.siteId)
|
||||
: Promise.resolve({ data: lineOptions.value });
|
||||
getUsageTunnelOptions(pallet.siteId)
|
||||
.then((tunnels) => {
|
||||
if (!isCurrent()) return;
|
||||
tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
|
||||
if (!tunnelLocked.value && !tunnelOptions.value.length) {
|
||||
operationError.value = "该现场暂无可用隧道";
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isCurrent()) return;
|
||||
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) optionLoading.value = false;
|
||||
});
|
||||
lineRequest
|
||||
.then((lines) => {
|
||||
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isCurrent()) return;
|
||||
lineOptions.value = [];
|
||||
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) lineOptionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFifoRecommendation() {
|
||||
if (!scannedPallet.value || !form.qrToken) return;
|
||||
const generation = ++fifoGeneration;
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
const { qrToken, tunnelId } = form;
|
||||
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
|
||||
qrToken === form.qrToken && tunnelId === form.tunnelId;
|
||||
fifoLoading.value = true;
|
||||
fifoMessage.value = "";
|
||||
try {
|
||||
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
|
||||
if (!isCurrent()) return;
|
||||
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
|
||||
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
|
||||
} finally {
|
||||
if (isCurrent()) fifoLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUsageScan(qrToken, rejectedOperation = null) {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
resetUsageOperation();
|
||||
if (!loadPendingOperations()) return;
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanUsagePallet(qrToken)
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
|
||||
const pallet = response.data;
|
||||
if (
|
||||
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
|
||||
pallet.remainingPackages <= 0
|
||||
) {
|
||||
operationError.value = "该托盘当前不可记录使用";
|
||||
return;
|
||||
}
|
||||
scannedPallet.value = {
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
siteId: pallet.siteId,
|
||||
currentLineId: pallet.currentLineId,
|
||||
tunnelId: pallet.tunnelId,
|
||||
tunnelName: pallet.tunnelName,
|
||||
tunnelCode: pallet.tunnelCode,
|
||||
strainCode: pallet.strainCode,
|
||||
strainName: pallet.strainName,
|
||||
batchNo: pallet.batchNo,
|
||||
productionDate: pallet.productionDate,
|
||||
expiryDate: pallet.expiryDate,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
flowState: pallet.flowState,
|
||||
};
|
||||
form.qrToken = pallet.qrToken || qrToken;
|
||||
form.tunnelId = pallet.tunnelId ?? null;
|
||||
tunnelLocked.value = pallet.tunnelId != null;
|
||||
if (rejectedOperation) {
|
||||
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
|
||||
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
|
||||
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
|
||||
fifoRequiresSkipReason.value = true;
|
||||
}
|
||||
if (pallet.currentLineId != null) {
|
||||
lineLocked.value = true;
|
||||
form.lineId = pallet.currentLineId;
|
||||
lineOptions.value = [
|
||||
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
|
||||
];
|
||||
} else {
|
||||
lineLocked.value = false;
|
||||
form.lineId = rejectedOperation?.payload.lineId ?? null;
|
||||
}
|
||||
loadUsageOptions(pallet, session, generation);
|
||||
loadFifoRecommendation();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||
scanLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
resetUsageOperation();
|
||||
operationError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function handleLineChange(event) {
|
||||
if (lineLocked.value || operationPending.value) return;
|
||||
const option = linePickerOptions.value[Number(event.detail.value)];
|
||||
form.lineId = option?.id ?? null;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function handleTunnelChange(event) {
|
||||
if (tunnelLocked.value || operationPending.value) return;
|
||||
const option = tunnelOptions.value[Number(event.detail.value)];
|
||||
if (form.tunnelId === (option?.id ?? null)) return;
|
||||
form.tunnelId = option?.id ?? null;
|
||||
markPayloadChanged();
|
||||
fifoRequiresSkipReason.value = false;
|
||||
loadFifoRecommendation();
|
||||
}
|
||||
|
||||
function markPayloadChanged() {
|
||||
if (operationPending.value) return;
|
||||
submitError.value = "";
|
||||
}
|
||||
|
||||
async function enterTunnel() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
|
||||
if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
|
||||
operationError.value = "请先扫描托盘并选择隧道";
|
||||
return;
|
||||
}
|
||||
let fifoSkipReason;
|
||||
try {
|
||||
fifoSkipReason = validatedFifoSkipReason();
|
||||
} catch (error) {
|
||||
operationError.value = error.message;
|
||||
return;
|
||||
}
|
||||
const session = operationSession;
|
||||
entryLoading.value = true;
|
||||
operationError.value = "";
|
||||
let scopeKey = recoveryStorageKey.value;
|
||||
let operation;
|
||||
try {
|
||||
operation = prepareOperation("entry", {
|
||||
qrToken: form.qrToken,
|
||||
tunnelId: form.tunnelId,
|
||||
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||||
});
|
||||
scopeKey = recoveryStorageKey.value;
|
||||
const response = await enterUsageTunnel({ ...operation.payload });
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
const result = completeOperation(operation, response);
|
||||
form.tunnelId = result.tunnelId;
|
||||
scannedPallet.value.tunnelId = result.tunnelId;
|
||||
tunnelLocked.value = true;
|
||||
submitError.value = "";
|
||||
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
|
||||
loadFifoRecommendation();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
if (handleFifoRejection(error, operation)) return;
|
||||
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUsage() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
|
||||
const usedPackages = Number(form.usedPackages);
|
||||
if (!scannedPallet.value || !form.qrToken) {
|
||||
submitError.value = "请先扫描待使用托盘";
|
||||
return;
|
||||
}
|
||||
if (!tunnelLocked.value || !form.tunnelId) {
|
||||
submitError.value = "请先选择隧道并确认入隧";
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||||
submitError.value = "使用包数必须为正整数";
|
||||
return;
|
||||
}
|
||||
let fifoSkipReason;
|
||||
try {
|
||||
fifoSkipReason = validatedFifoSkipReason();
|
||||
} catch (error) {
|
||||
submitError.value = error.message;
|
||||
return;
|
||||
}
|
||||
if (!loadPendingOperations()) {
|
||||
submitError.value = recoveryError.value;
|
||||
return;
|
||||
}
|
||||
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
|
||||
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
|
||||
submitError.value = "使用包数不能超过当前剩余包数";
|
||||
return;
|
||||
}
|
||||
const session = operationSession;
|
||||
submitLoading.value = true;
|
||||
submitError.value = "";
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
let operation;
|
||||
try {
|
||||
operation = prepareOperation("usage", {
|
||||
qrToken: form.qrToken,
|
||||
tunnelId: form.tunnelId,
|
||||
lineId: form.lineId,
|
||||
usedPackages,
|
||||
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||||
});
|
||||
const response = await addUsage({ ...operation.payload });
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
usageResult.value = completeOperation(operation, response);
|
||||
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
|
||||
loadUsageList();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
if (handleFifoRejection(error, operation)) return;
|
||||
submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function leaveUsagePage() {
|
||||
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
|
||||
resetUsageOperation();
|
||||
listGeneration += 1;
|
||||
loading.value = false;
|
||||
usageList.value = [];
|
||||
listError.value = "";
|
||||
recoveryMessage.value = "";
|
||||
}
|
||||
|
||||
onHide(leaveUsagePage);
|
||||
onUnload(leaveUsagePage);
|
||||
onShow(() => {
|
||||
loadPendingOperations();
|
||||
loadUsageList();
|
||||
});
|
||||
loadPendingOperations();
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,94 @@
|
||||
<template>
|
||||
<view
|
||||
class="flex h-full flex-col items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-6 pb-20 overflow-hidden"
|
||||
>
|
||||
<!-- Logo Section -->
|
||||
<view class="mb-6 flex flex-col items-center">
|
||||
<view
|
||||
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
|
||||
>
|
||||
<view class="login-page">
|
||||
<image
|
||||
class="size-10"
|
||||
class="login-background"
|
||||
src="/static/login-background.jpg"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="login-scrim" />
|
||||
|
||||
<view class="login-content">
|
||||
<view class="login-card">
|
||||
<view class="brand-lockup">
|
||||
<view class="brand-logo-frame">
|
||||
<image
|
||||
class="brand-logo"
|
||||
:src="globalConfig.appInfo.logo"
|
||||
mode="widthFix"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||
>智链云枢管理系统登录</text
|
||||
>
|
||||
<text class="brand-title">{{ globalConfig.appInfo.name }}</text>
|
||||
<text class="brand-subtitle">仓储运营管理平台</text>
|
||||
</view>
|
||||
|
||||
<!-- Form Section -->
|
||||
<view class="w-full rounded-3xl bg-white/80 p-6 shadow-xl backdrop-blur-md">
|
||||
<!-- Username -->
|
||||
<view class="group relative mb-5">
|
||||
<view
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||
>
|
||||
<view class="i-mdi-account text-xl"></view>
|
||||
</view>
|
||||
<view class="login-form">
|
||||
<view class="field-control">
|
||||
<view class="field-icon i-mdi-account" />
|
||||
<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"
|
||||
class="field-input"
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
placeholder="请输入您的账号"
|
||||
maxlength="30"
|
||||
confirm-type="next"
|
||||
/>
|
||||
</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>
|
||||
<view class="field-control">
|
||||
<view class="field-icon i-mdi-lock" />
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
class="field-input"
|
||||
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="请输入密码"
|
||||
placeholder="请输入您的密码"
|
||||
maxlength="20"
|
||||
confirm-type="done"
|
||||
@confirm="handleLogin"
|
||||
/>
|
||||
</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 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"
|
||||
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="验证码"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="4"
|
||||
confirm-type="done"
|
||||
@confirm="handleLogin"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
class="h-12 w-28 overflow-hidden rounded-xl bg-gray-100 shadow-sm transition-opacity active:opacity-80"
|
||||
@click="getCode"
|
||||
>
|
||||
<image :src="codeUrl" class="size-full object-cover"></image>
|
||||
<view class="captcha-image-wrap" @click="getCode">
|
||||
<image
|
||||
:src="codeUrl"
|
||||
class="captcha-image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@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"
|
||||
>
|
||||
登 录
|
||||
{{ loading ? "登录中..." : "登 录" }}
|
||||
</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
|
||||
@click="handleUserRegister"
|
||||
class="ml-1 font-medium text-blue-600 active:opacity-70"
|
||||
>立即注册</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="flex flex-wrap items-center justify-center text-xs text-gray-400"
|
||||
>
|
||||
<view v-if="register" class="register-tip">
|
||||
<text>没有账号?</text>
|
||||
<text class="action-link" @click="handleUserRegister">立即注册</text>
|
||||
</view>
|
||||
|
||||
<view class="agreement-tip">
|
||||
<text>登录即代表同意</text>
|
||||
<text
|
||||
@click="handleUserAgrement"
|
||||
class="mx-1 text-blue-500 active:opacity-70"
|
||||
>《用户协议》</text
|
||||
>
|
||||
<text class="action-link" @click="handleUserAgrement">《用户协议》</text>
|
||||
<text>和</text>
|
||||
<text
|
||||
@click="handlePrivacy"
|
||||
class="mx-1 text-blue-500 active:opacity-70"
|
||||
>《隐私协议》</text
|
||||
>
|
||||
<text class="action-link" @click="handlePrivacy">《隐私协议》</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -129,13 +105,14 @@ import { useConfigStore, useUserStore } from "@/store";
|
||||
const { proxy } = getCurrentInstance();
|
||||
const globalConfig = useConfigStore().config;
|
||||
const codeUrl = ref("");
|
||||
const loading = ref(false);
|
||||
// 验证码开关
|
||||
const captchaEnabled = ref(true);
|
||||
// 用户注册开关
|
||||
const register = ref(false);
|
||||
const loginForm = ref({
|
||||
username: "admin",
|
||||
password: "admin123",
|
||||
password: "",
|
||||
code: "",
|
||||
uuid: "",
|
||||
});
|
||||
@@ -160,6 +137,8 @@ function getCode() {
|
||||
getCodeImg().then((res) => {
|
||||
captchaEnabled.value =
|
||||
res.captchaEnabled === undefined ? true : res.captchaEnabled;
|
||||
register.value =
|
||||
res.registerEnabled === undefined ? false : res.registerEnabled;
|
||||
if (captchaEnabled.value) {
|
||||
codeUrl.value = "data:image/gif;base64," + res.img;
|
||||
loginForm.value.uuid = res.uuid;
|
||||
@@ -176,6 +155,7 @@ async function handleLogin() {
|
||||
} else if (loginForm.value.code === "" && captchaEnabled.value) {
|
||||
proxy.$modal.msgError("请输入验证码");
|
||||
} else {
|
||||
loading.value = true;
|
||||
proxy.$modal.loading("登录中,请耐心等待...");
|
||||
pwdLogin();
|
||||
}
|
||||
@@ -183,25 +163,26 @@ async function handleLogin() {
|
||||
|
||||
// 密码登录
|
||||
async function pwdLogin() {
|
||||
useUserStore()
|
||||
.login(loginForm.value)
|
||||
.then(() => {
|
||||
try {
|
||||
await useUserStore().login(loginForm.value);
|
||||
proxy.$modal.closeLoading();
|
||||
loading.value = false;
|
||||
loginSuccess();
|
||||
})
|
||||
.catch(() => {
|
||||
} catch (error) {
|
||||
proxy.$modal.closeLoading();
|
||||
loading.value = false;
|
||||
if (captchaEnabled.value) {
|
||||
getCode();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 登录成功后,处理函数
|
||||
function loginSuccess(result) {
|
||||
function loginSuccess() {
|
||||
// 设置用户信息
|
||||
useUserStore()
|
||||
.getInfo()
|
||||
.then((res) => {
|
||||
.then(() => {
|
||||
proxy.$tab.reLaunch("/pages/index");
|
||||
});
|
||||
}
|
||||
@@ -219,6 +200,239 @@ getCode();
|
||||
|
||||
<style lang="scss" scoped>
|
||||
page {
|
||||
background-color: #ffffff;
|
||||
background-color: #eef8f8;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 48rpx 32rpx;
|
||||
background: #eef8f8;
|
||||
}
|
||||
|
||||
.login-background,
|
||||
.login-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-scrim {
|
||||
z-index: 1;
|
||||
background: rgba(241, 250, 250, 0.22);
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 720rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.88);
|
||||
border-radius: 20rpx;
|
||||
padding: 56rpx 44rpx 42rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 20rpx 60rpx rgba(40, 34, 92, 0.18);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 46rpx;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-logo-frame {
|
||||
display: flex;
|
||||
width: 400rpx;
|
||||
max-width: 100%;
|
||||
height: 136rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
background: #28225c;
|
||||
box-shadow: 0 10rpx 22rpx rgba(40, 34, 92, 0.16);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin-top: 18rpx;
|
||||
color: #28225c;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 10rpx;
|
||||
color: #6c7786;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 96rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border: 1rpx solid #d9e6e7;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #ffffff;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.field-control:focus-within {
|
||||
border-color: #008596;
|
||||
box-shadow: 0 0 0 4rpx rgba(0, 133, 150, 0.1);
|
||||
}
|
||||
|
||||
.field-icon {
|
||||
flex: 0 0 36rpx;
|
||||
margin-right: 18rpx;
|
||||
color: #008596;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 96rpx;
|
||||
color: #28333f;
|
||||
font-size: 28rpx;
|
||||
line-height: 96rpx;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.captcha-image-wrap {
|
||||
flex: 0 0 220rpx;
|
||||
width: 220rpx;
|
||||
height: 96rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid #d9e6e7;
|
||||
border-radius: 10rpx;
|
||||
background: #f4f8f8;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
margin: 8rpx 0 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: #008596;
|
||||
box-shadow: 0 12rpx 24rpx rgba(0, 133, 150, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 96rpx;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.login-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
opacity: 0.84;
|
||||
transform: translateY(1rpx);
|
||||
}
|
||||
|
||||
.login-button[disabled] {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.register-tip,
|
||||
.agreement-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8791;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register-tip {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.agreement-tip {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 22rpx;
|
||||
color: #9aa5ad;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
margin: 0 6rpx;
|
||||
color: #008596;
|
||||
}
|
||||
|
||||
.action-link:active {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
.login-page {
|
||||
padding: 32rpx 24rpx;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 44rpx 28rpx 34rpx;
|
||||
}
|
||||
|
||||
.brand-logo-frame {
|
||||
width: 360rpx;
|
||||
height: 122rpx;
|
||||
padding: 14rpx 20rpx;
|
||||
}
|
||||
|
||||
.captcha-image-wrap {
|
||||
flex-basis: 190rpx;
|
||||
width: 190rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
<view class="flex h-full flex-col bg-gray-50 pb-10">
|
||||
<!-- Header -->
|
||||
<view class="flex flex-col items-center py-10">
|
||||
<view class="flex h-24 w-64 items-center justify-center rounded-xl bg-gray-800 px-4 shadow-md">
|
||||
<image
|
||||
class="size-20 rounded-xl shadow-md"
|
||||
src="/static/logo200.png"
|
||||
mode="widthFix"
|
||||
class="h-20 w-full"
|
||||
src="/static/sylvan-logo.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="mt-4 text-xl font-bold text-gray-800"
|
||||
>智链云枢管理系统</text
|
||||
>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<!-- Logo Section -->
|
||||
<view class="mb-6 flex flex-col items-center">
|
||||
<view
|
||||
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
|
||||
class="mb-4 flex h-20 w-60 items-center justify-center rounded-2xl bg-gray-800 px-4 shadow-lg"
|
||||
>
|
||||
<image
|
||||
class="size-10"
|
||||
class="h-16 w-full"
|
||||
:src="globalConfig.appInfo.logo"
|
||||
mode="widthFix"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||
|
||||
@@ -1,199 +1,62 @@
|
||||
<template>
|
||||
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
|
||||
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
|
||||
<view class="p-4 pb-24 space-y-6">
|
||||
<!-- Banner -->
|
||||
<view
|
||||
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
|
||||
<view class="work-page">
|
||||
<scroll-view
|
||||
class="work-scroll"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<swiper
|
||||
class="h-40 w-full"
|
||||
:current="swiperDotIndex"
|
||||
@change="changeSwiper"
|
||||
autoplay
|
||||
interval="3000"
|
||||
circular
|
||||
>
|
||||
<swiper-item v-for="(item, index) in data" :key="index">
|
||||
<view class="h-full w-full" @click="clickBannerItem(item)">
|
||||
<image
|
||||
:src="item.image"
|
||||
mode="scaleToFill"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<!-- Custom Dots -->
|
||||
<view
|
||||
class="absolute bottom-3 right-0 left-0 flex justify-center space-x-2 pointer-events-none"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in data"
|
||||
:key="index"
|
||||
class="h-1.5 rounded-full transition-all duration-300"
|
||||
:class="current === index ? 'bg-white w-4' : 'bg-white/50 w-1.5'"
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI Tools Section -->
|
||||
<view>
|
||||
<view class="mb-4 flex items-center space-x-2">
|
||||
<view class="h-4 w-1 rounded-full bg-indigo-500"></view>
|
||||
<text class="text-base font-bold text-gray-800">AI 生产力</text>
|
||||
</view>
|
||||
<view class="grid grid-cols-2 gap-4">
|
||||
<view
|
||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
|
||||
@click="handleToAiChat"
|
||||
>
|
||||
<view
|
||||
class="absolute -right-4 -top-4 size-20 rounded-full bg-indigo-50 opacity-50"
|
||||
></view>
|
||||
<view class="relative z-10 flex flex-col">
|
||||
<view
|
||||
class="mb-3 flex size-10 items-center justify-center rounded-xl bg-indigo-500 text-white shadow-md shadow-indigo-200"
|
||||
>
|
||||
<view class="i-mdi-chat text-xl"></view>
|
||||
</view>
|
||||
<text class="font-bold text-gray-800">智能对话</text>
|
||||
<text class="mt-1 text-xs text-gray-500">智能助手</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="absolute -right-4 -top-4 size-20 rounded-full bg-purple-50 opacity-50"
|
||||
></view>
|
||||
<view class="relative z-10 flex flex-col">
|
||||
<view
|
||||
class="mb-3 flex size-10 items-center justify-center rounded-xl bg-purple-500 text-white shadow-md shadow-purple-200"
|
||||
>
|
||||
<view class="i-mdi-image-multiple text-xl"></view>
|
||||
</view>
|
||||
<text class="font-bold text-gray-800">图像生成</text>
|
||||
<text class="mt-1 text-xs text-gray-500">创意工坊</text>
|
||||
</view>
|
||||
<view class="work-content">
|
||||
<view class="work-heading">
|
||||
<view class="work-heading-copy">
|
||||
<text class="work-kicker">业务工作台</text>
|
||||
<text class="work-subtitle">
|
||||
根据当前账号权限,快速进入现场业务流程
|
||||
</text>
|
||||
</view>
|
||||
<view class="work-permission-count">
|
||||
<text class="work-permission-value">{{ visibleBusinessEntries.length }}</text>
|
||||
<text class="work-permission-label">项可用</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- System Management Section -->
|
||||
<view>
|
||||
<view class="mb-4 flex items-center space-x-2">
|
||||
<view class="h-4 w-1 rounded-full bg-blue-500"></view>
|
||||
<text class="text-base font-bold text-gray-800">系统管理</text>
|
||||
<TestBatchModeSwitch />
|
||||
|
||||
<view class="work-section">
|
||||
<view class="work-section-header">
|
||||
<view class="work-section-marker"></view>
|
||||
<view class="work-section-copy">
|
||||
<text class="work-section-title">业务管理</text>
|
||||
<text class="work-section-description">选择需要执行的现场流程</text>
|
||||
</view>
|
||||
<view 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 v-if="visibleBusinessEntries.length" class="work-entry-list">
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
v-for="entry in visibleBusinessEntries"
|
||||
:key="entry.path"
|
||||
class="work-entry"
|
||||
@click="handleBusinessEntry(entry)"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-cyan-50 text-cyan-500 transition-colors group-active:bg-cyan-100"
|
||||
class="work-entry-icon"
|
||||
:class="entry.iconClass"
|
||||
>
|
||||
<view class="i-mdi-book-open-page-variant text-xl"></view>
|
||||
<view :class="entry.icon" class="work-entry-icon-glyph"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>字典管理</text
|
||||
>
|
||||
<view class="work-entry-copy">
|
||||
<text class="work-entry-title">{{ entry.title }}</text>
|
||||
<text class="work-entry-description">{{ entry.description }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="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 class="i-mdi-chevron-right work-entry-arrow"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="work-empty-state">
|
||||
<view class="work-empty-icon">
|
||||
<view class="i-mdi-lock-outline work-empty-icon-glyph"></view>
|
||||
</view>
|
||||
<text class="work-empty-title">暂无已授权业务</text>
|
||||
<text class="work-empty-description">请联系管理员分配业务菜单权限</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -202,34 +65,297 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance } from "vue";
|
||||
import { computed, getCurrentInstance } from "vue";
|
||||
import TestBatchModeSwitch from "@/components/business/TestBatchModeSwitch.vue";
|
||||
import { businessMenuEntries } from "@/config/businessMenu";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const current = ref(0);
|
||||
const swiperDotIndex = ref(0);
|
||||
const data = ref([
|
||||
{ image: "/static/images/banner/banner01.jpg" },
|
||||
{ image: "/static/images/banner/banner02.jpg" },
|
||||
{ image: "/static/images/banner/banner03.jpg" },
|
||||
]);
|
||||
|
||||
function clickBannerItem(item) {
|
||||
console.info(item);
|
||||
const visibleBusinessEntries = computed(() =>
|
||||
businessMenuEntries.filter(canOpenBusinessEntry),
|
||||
);
|
||||
|
||||
function canOpenBusinessEntry(entry) {
|
||||
return entry.permissions
|
||||
? proxy.$auth.hasPermiOr(entry.permissions)
|
||||
: proxy.$auth.hasPermi(entry.permission);
|
||||
}
|
||||
|
||||
function changeSwiper(e) {
|
||||
current.value = e.detail.current;
|
||||
function handleBusinessEntry(entry) {
|
||||
if (!canOpenBusinessEntry(entry)) {
|
||||
proxy.$modal.msgError("当前账号没有该业务菜单权限");
|
||||
return;
|
||||
}
|
||||
|
||||
function handleToAiChat() {
|
||||
handleBuilding();
|
||||
}
|
||||
|
||||
function handleToUserList() {
|
||||
handleBuilding();
|
||||
}
|
||||
|
||||
function handleBuilding() {
|
||||
proxy.$modal.msg("模块建设中~");
|
||||
proxy.$tab.navigateTo(entry.path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.work-page {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
background: #f4f8f8;
|
||||
}
|
||||
|
||||
.work-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 24rpx 28rpx calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.work-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.work-heading-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-kicker {
|
||||
display: block;
|
||||
color: #28225c;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.work-subtitle {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #6c7786;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.work-permission-count {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: baseline;
|
||||
border-radius: 14rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #e9f7f6;
|
||||
color: #008596;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.work-permission-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.work-permission-label {
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.work-section {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
margin-top: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
|
||||
}
|
||||
|
||||
.work-section-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.work-section-marker {
|
||||
width: 8rpx;
|
||||
height: 38rpx;
|
||||
flex: 0 0 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #008596;
|
||||
}
|
||||
|
||||
.work-section-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-section-title {
|
||||
display: block;
|
||||
color: #28225c;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.work-section-description {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: #8b969d;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-entry-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
margin-top: 20rpx;
|
||||
gap: 12rpx;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
border: 1rpx solid #e5eeee;
|
||||
border-radius: 16rpx;
|
||||
padding: 18rpx;
|
||||
background: #fbfdfd;
|
||||
}
|
||||
|
||||
.work-entry:active {
|
||||
border-color: #8ccbc5;
|
||||
background: #f0faf9;
|
||||
}
|
||||
|
||||
.work-entry-icon {
|
||||
display: flex;
|
||||
width: 82rpx;
|
||||
height: 82rpx;
|
||||
flex: 0 0 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.work-entry-icon-glyph {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
|
||||
.work-entry-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-entry-title,
|
||||
.work-entry-description {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.work-entry-title {
|
||||
color: #27313a;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.work-entry-description {
|
||||
margin-top: 7rpx;
|
||||
color: #7b8791;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-entry-arrow {
|
||||
flex: 0 0 34rpx;
|
||||
color: #a9b7bb;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.work-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 70rpx 16rpx 54rpx;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-empty-icon {
|
||||
display: flex;
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f1f5f5;
|
||||
color: #9aa5ad;
|
||||
}
|
||||
|
||||
.work-empty-icon-glyph {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
|
||||
.work-empty-title {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
color: #4b5563;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.work-empty-description {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: #9aa5ad;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
.work-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.work-kicker {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.work-section {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
gap: 12rpx;
|
||||
padding: 14rpx;
|
||||
}
|
||||
|
||||
.work-entry-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex-basis: 72rpx;
|
||||
}
|
||||
|
||||
.work-entry-icon-glyph {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 516.1 194.52">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #008596;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #91cdb9;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #43b69e;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #554d8a;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #50bcba;
|
||||
}
|
||||
|
||||
.cls-7, .cls-8, .cls-9 {
|
||||
fill: #28225c;
|
||||
}
|
||||
|
||||
.cls-10 {
|
||||
fill: #009a91;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.cls-10, .cls-11 {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
|
||||
.cls-11 {
|
||||
fill: #a5d7d7;
|
||||
}
|
||||
|
||||
.cls-8 {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.cls-9 {
|
||||
opacity: .25;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="tekening">
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-2" d="M302.33,61.89l-18.13,63.56c-3.35,11.65-7.77,17.59-23.63,17.59-3.99,0-10.36-.86-13.06-1.73-1.19-.43-2.27-.76-2.27-1.94v-8.74c0-1.19,.97-2.05,2.16-2.05h.22c3.56,.21,9.93,.86,13.49,.86,3.88,0,5.83-1.29,7.12-5.61l.97-3.35h-1.62c-3.99,0-7.34-.65-10.04-10.04l-14.24-48.56c-.11-.32-.11-.54-.11-.76,0-1.08,.65-1.73,2.16-1.73h14.35c1.08,0,1.83,.97,2.05,2.05l9.71,40.58c.32,1.51,.65,2.05,1.29,2.05h.97l10.04-42.63c.21-1.08,1.08-2.05,2.27-2.05h14.35c1.08,0,2.05,.86,2.05,1.94,0,.22-.11,.33-.11,.54Z"/>
|
||||
<path class="cls-2" d="M326.72,121.44h-13.17c-1.19,0-2.27-1.08-2.27-2.27V40.83c0-1.19,1.08-2.16,2.27-2.16h13.17c1.19,0,2.05,.97,2.05,2.16V119.17c0,1.19-.86,2.27-2.05,2.27Z"/>
|
||||
<path class="cls-2" d="M396,61.89l-13.6,48.99c-2.7,9.6-9.28,11.33-15.65,11.33s-12.84-1.73-15.54-11.33l-13.49-48.99c-.11-.21-.11-.43-.11-.65,0-1.08,.76-1.84,1.73-1.84h14.24c1.08,0,1.94,.97,2.27,2.27l9.28,42.3c.33,1.62,.86,2.27,1.62,2.27s1.4-.65,1.73-2.27l9.28-42.3c.32-1.3,1.19-2.27,2.27-2.27h14.24c.97,0,1.84,.76,1.84,1.84,0,.21,0,.43-.11,.65Z"/>
|
||||
<path class="cls-2" d="M418.66,121.3c-9.39,0-18.45-4.53-18.45-18.56v-.97c0-12.63,8.09-18.24,26.55-18.24h5.83v-3.89c0-6.15-6.78-7.47-11.85-7.47,0,0-17.96,1.95-17.96-12.69,0,0,16.18-.15,23.3-.15,15.54,0,23.99,4.55,23.99,20.31v39.49c0,1.19-.97,2.16-2.16,2.16h-29.24Zm13.89-27.41h-6.15c-7.56,0-9.5,2.59-9.5,7.88v.97c0,4.53,2.27,5.39,6.8,5.39,3.24,0,8.68,0,8.68,0l.17-14.25Z"/>
|
||||
<path class="cls-2" d="M497.01,58.61c15.65,0,19.1,10.9,19.1,24.5v35.93c0,1.19-1.08,2.27-2.27,2.27h-13.06c-1.19,0-2.16-1.08-2.16-2.27v-35.93c-.1-6.03,.45-8.65-6.13-8.65-3.22,0-8.33,.12-11.99,2.06v42.52c0,1.19-.97,2.27-2.16,2.27h-13.06c-1.19,0-2.27-1.08-2.27-2.27V61.63c0-1.19,.97-2.16,2.16-2.16l31.83-.86Z"/>
|
||||
<path class="cls-2" d="M184.25,121.47c0-17.9,16.39-18.14,16.39-18.14,8.24,0,17.54-.5,17.98-6.18,.24-3.16-.41-4.44-6.02-7.82l-15.23-8.96c-10.04-6.01-12.73-12.89-12.73-20.87,0-12.02,6.69-21.3,27.63-21.3,1.66,0,23.24,.05,23.26,.07-.13,.14,.12,2.04,.12,3.24,0,14.93-20.16,13.71-20.16,13.71-6.15,0-9.85,2.38-9.85,6.31,0,3.06,1.26,4.7,7.09,7.87l13.81,6.38c11.22,6.12,12.5,12.79,12.5,21.64,0,11.58-6.73,24.27-24.96,24.11l-29.81-.05Z"/>
|
||||
<g>
|
||||
<path class="cls-2" d="M365.04,163.65h-1.67c-.32,0-.58-.29-.58-.64v-22.95c0-.32,.23-.61,.58-.61h1.67c.32,0,.58,.26,.58,.61v22.95c0,.32-.23,.64-.58,.64Z"/>
|
||||
<path class="cls-2" d="M373.88,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M393.77,146.72l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.34-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
|
||||
<path class="cls-2" d="M400.81,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M420.03,163.65h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M440.07,148.1l-1.16,.22c.58,.61,1.09,1.58,1.09,3.32v.26c0,3.54-2.19,5.5-7.15,5.5-1.71,0-2.8-.26-3.73-.64-.55,.26-.8,.61-.8,1.09,0,.55,.26,.84,1,1.03l4.8,1.22c3.15,.8,6.18,1.71,6.18,4.73v.26c0,3.09-2.06,4.99-7.34,4.99s-7.4-1.96-7.4-5.12v-.29c0-1.8,.97-3.28,2.03-4.09l-.23-.1c-1.03-.45-1.48-1.51-1.48-2.51v-.03c0-.87,.42-1.8,1.32-2.32-1-.93-1.48-2.09-1.48-3.73v-.26c0-2.86,1.61-5.7,7.14-5.7h7.05c.39,0,.74,.32,.74,.71v.84c0,.42-.23,.55-.58,.61Zm-2.61,16.74c0-1.26-1.03-1.77-3.93-2.51l-3.64-.93c-.93,.8-1.58,1.61-1.58,3.28v.29c0,1.8,1.06,2.93,4.63,2.93s4.51-1.03,4.51-2.8v-.26Zm-.26-13.2c0-1.93-.87-3.44-4.35-3.44s-4.34,1.64-4.34,3.44v.26c0,1.42,.64,3.28,4.34,3.28,3.48,0,4.35-1.35,4.35-3.28v-.26Z"/>
|
||||
<path class="cls-2" d="M365.07,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M384.28,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M403.17,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M415.71,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
|
||||
<path class="cls-2" d="M440.52,177.36l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.35-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
|
||||
<path class="cls-2" d="M455.97,194.29h-1.48c-.35,0-.64-.29-.64-.64v-.71c-1.54,.9-3.15,1.58-4.96,1.58-2.74,0-5.25-1.19-5.25-5.21v-.39c0-3.48,2.03-5.21,7.76-5.21h2.38v-1.29c0-2.7-.93-3.64-3.44-3.64-2.09,0-3.64,.13-4.92,.26h-.16c-.29,0-.58-.1-.58-.51v-1.13c0-.35,.23-.55,.68-.61,1.32-.23,2.8-.42,4.99-.42,4.47,0,6.28,2.25,6.28,6.05v11.23c0,.35-.29,.64-.64,.64Zm-2.19-8.56h-2.38c-3.9,0-4.93,.87-4.93,3.15v.39c0,2.32,1.09,2.93,2.96,2.93,1.48,0,2.8-.45,4.35-1.29v-5.18Z"/>
|
||||
<path class="cls-2" d="M469.99,194.36c-.64,.1-1.48,.16-2.09,.16-2.96,0-4.38-.77-4.38-4.31v-11.23l-3.12-.35c-.35-.03-.61-.22-.61-.58v-.87c0-.35,.26-.58,.61-.58h3.12v-3.15c0-.32,.22-.58,.58-.64l1.67-.26h.1c.26,0,.48,.22,.48,.55v3.51h3.48c.35,0,.64,.29,.64,.64v1.09c0,.35-.29,.64-.64,.64h-3.48v11.23c0,1.77,.45,2.03,1.74,2.03h1.8c.45,0,.68,.13,.68,.45v1.13c0,.29-.16,.48-.58,.55Z"/>
|
||||
<path class="cls-2" d="M477.14,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M490.01,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
|
||||
<path class="cls-2" d="M514.85,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-11" d="M57.78,67.44c-.03,5.02-.86,9.86-2.35,14.39,0,0,.01,.01,.02,.02,0,0,0,0,.01,0,10.82-31.09,38.25-54.41,71.67-59.3C130.83,15.86,132.95,8.18,132.95,0h0s-46.67,0-46.67,0H46.67C30.78,0,16.76,7.94,8.33,20.07c.88-.04,1.76-.07,2.65-.06,26.02,.17,46.98,21.41,46.8,47.43Z"/>
|
||||
<path class="cls-10" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
|
||||
<path class="cls-6" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
|
||||
<path class="cls-7" d="M55.46,81.85s-.01-.01-.02-.02c0,0,0,0,0,.01,0,0,.02,0,.02,0Z"/>
|
||||
<path class="cls-10" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-8" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-7" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
|
||||
<path class="cls-8" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
|
||||
<path class="cls-10" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
|
||||
<path class="cls-3" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
|
||||
<path class="cls-10" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-8" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-9" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||
<polygon class="cls-7" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||
<polygon class="cls-8" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||
<polygon class="cls-9" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||
<path class="cls-10" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||
<path class="cls-9" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||
<path class="cls-4" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||
<path class="cls-10" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-8" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-9" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-1" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-5" d="M55.47,81.86s0,0,0,0c-.02,0-.03,0-.05,.01,0-.01,0-.02,.01-.03,0,0,0,0,0-.01-9.09-10.29-21.81-17.47-36.5-19.35-5.49-.7-10.89-.6-16.11,.17,2.93,8.02,7.98,15.01,14.5,20.29,5.11,4.14,11.12,7.22,17.69,8.91,2.45,.63,4.96,1.08,7.55,1.3,.24,.02,.24,.34,0,.36-4.74,.41-9.26,1.54-13.49,3.27-8.71,3.55-16.1,9.65-21.25,17.38-4.93,7.4-7.82,16.28-7.82,25.84H0s8.74,0,8.74,0h57.85c1.51-3.77,2.58-7.74,3.11-11.86,2.23-17.44-3.45-34.06-14.23-46.28Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,9 +1,10 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { useUserStore } from "./modules/user";
|
||||
import { useConfigStore } from "./modules/config";
|
||||
import { useTestBatchStore } from "./modules/testBatch";
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
export default pinia;
|
||||
|
||||
export { useUserStore, useConfigStore };
|
||||
export { useUserStore, useConfigStore, useTestBatchStore };
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { listAvailableTestBatches } from "@/api/system/testBatch";
|
||||
import storage from "@/utils/storage";
|
||||
import constant from "@/utils/constant";
|
||||
|
||||
export const useTestBatchStore = defineStore("testBatch", () => {
|
||||
const options = ref([]);
|
||||
const currentBatchId = ref(String(storage.get(constant.testBatchId) || ""));
|
||||
const currentBatchName = ref(storage.get(constant.testBatchName) || "");
|
||||
const scopeVersion = ref(0);
|
||||
const isTestMode = computed(() => Boolean(currentBatchId.value));
|
||||
|
||||
function leaveTestMode() {
|
||||
currentBatchId.value = "";
|
||||
currentBatchName.value = "";
|
||||
storage.remove(constant.testBatchId);
|
||||
storage.remove(constant.testBatchName);
|
||||
scopeVersion.value += 1;
|
||||
}
|
||||
|
||||
async function refreshOptions() {
|
||||
try {
|
||||
const response = await listAvailableTestBatches();
|
||||
const available = (Array.isArray(response.data) ? response.data : []).filter(
|
||||
(item) => item && item.status === "ACTIVE" && item.batchId != null,
|
||||
);
|
||||
options.value = available;
|
||||
|
||||
const current = available.find(
|
||||
(item) => String(item.batchId) === String(currentBatchId.value),
|
||||
);
|
||||
if (current) {
|
||||
currentBatchId.value = String(current.batchId);
|
||||
currentBatchName.value = current.batchName || "";
|
||||
storage.set(constant.testBatchId, currentBatchId.value);
|
||||
storage.set(constant.testBatchName, currentBatchName.value);
|
||||
} else if (currentBatchId.value) {
|
||||
// 本地缓存不能代替后端授权;选项接口未返回当前批次时立即回到正式模式。
|
||||
leaveTestMode();
|
||||
}
|
||||
return available;
|
||||
} catch (error) {
|
||||
options.value = [];
|
||||
leaveTestMode();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function selectBatch(batch) {
|
||||
const allowed = options.value.find(
|
||||
(item) =>
|
||||
String(item.batchId) === String(batch?.batchId) && item.status === "ACTIVE",
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new Error("所选测试批次当前不可用");
|
||||
}
|
||||
currentBatchId.value = String(allowed.batchId);
|
||||
currentBatchName.value = allowed.batchName || "";
|
||||
storage.set(constant.testBatchId, currentBatchId.value);
|
||||
storage.set(constant.testBatchName, currentBatchName.value);
|
||||
scopeVersion.value += 1;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
options.value = [];
|
||||
leaveTestMode();
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
currentBatchId,
|
||||
currentBatchName,
|
||||
scopeVersion,
|
||||
isTestMode,
|
||||
refreshOptions,
|
||||
selectBatch,
|
||||
leaveTestMode,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
||||
export default useTestBatchStore;
|
||||
@@ -15,6 +15,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
const id = ref(storage.get(constant.id));
|
||||
const name = ref(storage.get(constant.name));
|
||||
const avatar = ref(storage.get(constant.avatar));
|
||||
const admin = ref(storage.get(constant.admin) === true || storage.get(constant.admin) === "true");
|
||||
const roles = ref(storage.get(constant.roles));
|
||||
const permissions = ref(storage.get(constant.permissions));
|
||||
|
||||
@@ -33,6 +34,10 @@ export const useUserStore = defineStore("user", () => {
|
||||
avatar.value = val;
|
||||
storage.set(constant.avatar, val);
|
||||
};
|
||||
const SET_ADMIN = (val) => {
|
||||
admin.value = val === true;
|
||||
storage.set(constant.admin, admin.value);
|
||||
};
|
||||
const SET_ROLES = (val) => {
|
||||
roles.value = val;
|
||||
storage.set(constant.roles, val);
|
||||
@@ -81,6 +86,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
} else {
|
||||
SET_ROLES(["ROLE_DEFAULT"]);
|
||||
}
|
||||
SET_ADMIN(user.admin === true);
|
||||
SET_ID(userid);
|
||||
SET_NAME(username);
|
||||
SET_AVATAR(avatar);
|
||||
@@ -115,6 +121,7 @@ export const useUserStore = defineStore("user", () => {
|
||||
id,
|
||||
name,
|
||||
avatar,
|
||||
admin,
|
||||
roles,
|
||||
permissions,
|
||||
SET_AVATAR,
|
||||
|
||||
@@ -2,8 +2,11 @@ const constant = {
|
||||
avatar: "user_avatar",
|
||||
id: "user_id",
|
||||
name: "user_name",
|
||||
admin: "user_admin",
|
||||
roles: "user_roles",
|
||||
permissions: "user_permissions",
|
||||
testBatchId: "test_batch_id",
|
||||
testBatchName: "test_batch_name",
|
||||
};
|
||||
|
||||
export default constant;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export function normalizeScanValue(value) {
|
||||
const normalizedValue = String(value ?? "").trim();
|
||||
if (!normalizedValue) {
|
||||
throw new Error("二维码不能为空");
|
||||
}
|
||||
if (normalizedValue.length > 128) {
|
||||
throw new Error("二维码长度不能超过 128 个字符");
|
||||
}
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
export function isDuplicateScan(
|
||||
value,
|
||||
lastValue,
|
||||
scannedAt,
|
||||
lastScannedAt,
|
||||
duplicateWindowMs,
|
||||
) {
|
||||
const elapsed = scannedAt - lastScannedAt;
|
||||
return (
|
||||
value === lastValue &&
|
||||
elapsed >= 0 &&
|
||||
elapsed <= duplicateWindowMs
|
||||
);
|
||||
}
|
||||
|
||||
export function createIdempotencyKey(
|
||||
cryptoProvider =
|
||||
typeof globalThis === "undefined" ? undefined : globalThis.crypto,
|
||||
) {
|
||||
if (typeof cryptoProvider?.randomUUID === "function") {
|
||||
return cryptoProvider.randomUUID();
|
||||
}
|
||||
if (typeof cryptoProvider?.getRandomValues !== "function") {
|
||||
throw new Error("当前环境不支持安全随机数,请使用 HTTPS 或 localhost 访问系统");
|
||||
}
|
||||
|
||||
const bytes = cryptoProvider.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import config from "@/config";
|
||||
import { getToken } from "@/utils/auth";
|
||||
import errorCode from "@/utils/errorCode";
|
||||
import constant from "@/utils/constant";
|
||||
import storage from "@/utils/storage";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { toast, showConfirm, tansParams } from "@/utils/common";
|
||||
import {
|
||||
@@ -14,6 +16,37 @@ import {
|
||||
|
||||
let timeout = 10000;
|
||||
const baseUrl = config.baseUrl;
|
||||
let testBatchRecoveryStarted = false;
|
||||
|
||||
function getResponseBody(payload) {
|
||||
return payload?.data && typeof payload.data === "object" ? payload.data : payload || {};
|
||||
}
|
||||
|
||||
function getResponseData(payload) {
|
||||
const body = getResponseBody(payload);
|
||||
return body.data && typeof body.data === "object" ? body.data : body;
|
||||
}
|
||||
|
||||
function isTestBatchUnavailable(payload) {
|
||||
return getResponseData(payload)?.reason === "TEST_BATCH_UNAVAILABLE";
|
||||
}
|
||||
|
||||
function recoverUnavailableTestBatch(payload) {
|
||||
if (testBatchRecoveryStarted) return;
|
||||
testBatchRecoveryStarted = true;
|
||||
const body = getResponseBody(payload);
|
||||
const message = body.msg || "当前测试批次不可用,已切回正式数据模式";
|
||||
storage.remove(constant.testBatchId);
|
||||
storage.remove(constant.testBatchName);
|
||||
if (typeof uni.$emit === "function") {
|
||||
uni.$emit("test-batch-unavailable", message);
|
||||
}
|
||||
uni.showToast({ title: message, icon: "none", duration: 3500 });
|
||||
uni.reLaunch({ url: "/pages/work/index" });
|
||||
setTimeout(() => {
|
||||
testBatchRecoveryStarted = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const request = async (config) => {
|
||||
// 是否需要设置 token
|
||||
@@ -23,6 +56,10 @@ const request = async (config) => {
|
||||
if (getToken() && !isToken) {
|
||||
config.header["Authorization"] = "Bearer " + getToken();
|
||||
}
|
||||
const testBatchId = storage.get(constant.testBatchId);
|
||||
if (getToken() && testBatchId) {
|
||||
config.header["X-Test-Batch-Id"] = String(testBatchId);
|
||||
}
|
||||
|
||||
try {
|
||||
config = await encryptTransportRequest(config);
|
||||
@@ -55,8 +92,18 @@ const request = async (config) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = res.data.code || 200;
|
||||
const msg = errorCode[code] || res.data.msg || errorCode["default"];
|
||||
if (isTestBatchUnavailable(res)) {
|
||||
recoverUnavailableTestBatch(res);
|
||||
const error = new Error(getResponseBody(res).msg || "当前测试批次不可用");
|
||||
error.response = res;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = getResponseBody(res);
|
||||
const status = res.statusCode ?? res.status;
|
||||
const code = body.code || 200;
|
||||
const msg = errorCode[code] || body.msg || errorCode["default"];
|
||||
if (code === 401) {
|
||||
showConfirm(
|
||||
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
|
||||
@@ -80,8 +127,13 @@ const request = async (config) => {
|
||||
const error = new Error(msg);
|
||||
error.response = res;
|
||||
reject(error);
|
||||
} else if (status != null && (status < 200 || status >= 300)) {
|
||||
// uni.request 的 success 仅表示收到 HTTP 响应,不能据此确认业务成功。
|
||||
const error = new Error(body.msg || `Request failed with status code ${status}`);
|
||||
error.response = res;
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(res.data);
|
||||
resolve(body);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
@@ -100,10 +152,15 @@ const request = async (config) => {
|
||||
return request(config);
|
||||
}
|
||||
|
||||
if (isTestBatchUnavailable(error?.response)) {
|
||||
recoverUnavailableTestBatch(error.response);
|
||||
}
|
||||
|
||||
const response = error.response;
|
||||
const responseStatus = response?.status ?? response?.statusCode;
|
||||
const responseCode = response?.data?.code;
|
||||
const responseMsg = response?.data?.msg;
|
||||
const responseBody = getResponseBody(response);
|
||||
const responseCode = responseBody?.code;
|
||||
const responseMsg = responseBody?.msg;
|
||||
if (responseMsg) {
|
||||
uni.showToast({
|
||||
title: responseMsg,
|
||||
|
||||
@@ -8,8 +8,11 @@ let storageNodeKeys = [
|
||||
constant.avatar,
|
||||
constant.id,
|
||||
constant.name,
|
||||
constant.admin,
|
||||
constant.roles,
|
||||
constant.permissions,
|
||||
constant.testBatchId,
|
||||
constant.testBatchName,
|
||||
];
|
||||
|
||||
const storage = {
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import JSON5 from "json5";
|
||||
|
||||
const appRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
test("PDA 的 H5、App 和小程序统一使用 Sylvan 品牌标识", async () => {
|
||||
const [config, loginPage, registerPage, aboutPage, indexHtml, fullLogo, markPng] =
|
||||
await Promise.all([
|
||||
readFile(path.join(appRoot, "src/config.js"), "utf8"),
|
||||
readFile(path.join(appRoot, "src/pages/login.vue"), "utf8"),
|
||||
readFile(path.join(appRoot, "src/pages/register.vue"), "utf8"),
|
||||
readFile(path.join(appRoot, "src/pages/mine/about/index.vue"), "utf8"),
|
||||
readFile(path.join(appRoot, "index.html"), "utf8"),
|
||||
readFile(path.join(appRoot, "src/static/sylvan-logo.svg"), "utf8"),
|
||||
readFile(path.join(appRoot, "src/static/sylvan-mark.png")),
|
||||
]);
|
||||
|
||||
assert.match(config, /logo:\s*["']\/static\/sylvan-logo\.png["']/);
|
||||
assert.match(loginPage, /globalConfig\.appInfo\.logo/);
|
||||
assert.match(registerPage, /globalConfig\.appInfo\.logo/);
|
||||
assert.match(aboutPage, /src=["']\/static\/sylvan-logo\.png["']/);
|
||||
assert.match(indexHtml, /href=["']\/static\/favicon\.ico["']/);
|
||||
assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/);
|
||||
assert.equal(markPng.subarray(1, 4).toString("ascii"), "PNG");
|
||||
assert.doesNotMatch(`${config}\n${aboutPage}`, /\/static\/logo(?:200)?\.png/);
|
||||
assert.doesNotMatch(fullLogo, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i);
|
||||
});
|
||||
|
||||
const apiContracts = {
|
||||
"src/api/business/inbound.js": [
|
||||
["listInbound", "'/business/inbound/list'", "get"],
|
||||
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
|
||||
["scanInboundPallet", "'/business/inbound/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getInbound", "'/business/inbound/' + inboundId", "get"],
|
||||
["addInbound", "'/business/inbound'", "post"],
|
||||
["updateInbound", "'/business/inbound'", "put"],
|
||||
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
|
||||
],
|
||||
"src/api/business/filling.js": [
|
||||
["listFilling", "'/business/filling/list'", "get"],
|
||||
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
|
||||
["scanFillingPallet", "'/business/filling/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getFilling", "'/business/filling/' + fillingId", "get"],
|
||||
["addFilling", "'/business/filling'", "post"],
|
||||
],
|
||||
"src/api/business/shipment.js": [
|
||||
["listShipment", "'/business/shipment/list'", "get"],
|
||||
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
|
||||
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
|
||||
["scanShipmentPallet", "'/business/shipment/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getShipment", "'/business/shipment/' + shipmentId", "get"],
|
||||
["addShipment", "'/business/shipment'", "post"],
|
||||
["updateShipment", "'/business/shipment'", "put"],
|
||||
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
|
||||
],
|
||||
"src/api/business/usage.js": [
|
||||
["listUsage", "'/business/usage/list'", "get"],
|
||||
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
|
||||
["scanUsagePallet", "'/business/usage/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["getUsage", "'/business/usage/' + usageId", "get"],
|
||||
["addUsage", "'/business/usage'", "post"],
|
||||
],
|
||||
"src/api/business/return.js": [
|
||||
["listReturn", "'/business/return/list'", "get"],
|
||||
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
|
||||
["scanReturnPallet", "'/business/return/scan/' + encodeURIComponent(qrToken)", "get"],
|
||||
["requestReturn", "'/business/return/request'", "post"],
|
||||
["getReturn", "'/business/return/' + returnId", "get"],
|
||||
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
|
||||
],
|
||||
};
|
||||
|
||||
function sourceForFunction(source, functionName) {
|
||||
const match = source.match(
|
||||
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
|
||||
);
|
||||
const start = match?.index ?? -1;
|
||||
assert.notEqual(start, -1, `${functionName} 导出不存在`);
|
||||
const end = source.indexOf("\n}", start);
|
||||
return source.slice(start, end === -1 ? source.length : end + 2);
|
||||
}
|
||||
|
||||
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
|
||||
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
|
||||
await t.test(relativePath, async () => {
|
||||
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
||||
for (const [functionName, url, method] of contracts) {
|
||||
const functionSource = sourceForFunction(source, functionName);
|
||||
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
|
||||
const normalizedUrl = url.replaceAll("'", '"');
|
||||
assert.match(
|
||||
normalizedFunctionSource,
|
||||
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
|
||||
);
|
||||
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("扫码值只去除首尾空白并拒绝空值", async () => {
|
||||
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
|
||||
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
|
||||
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
|
||||
});
|
||||
|
||||
test("同一扫码值只在短时窗口内被识别为重复", async () => {
|
||||
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
|
||||
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
|
||||
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
|
||||
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
|
||||
});
|
||||
|
||||
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
|
||||
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
|
||||
const expected = "12345678-1234-4123-8123-123456789abc";
|
||||
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
|
||||
assert.equal(
|
||||
createIdempotencyKey({
|
||||
getRandomValues(bytes) {
|
||||
bytes.fill(0);
|
||||
return bytes;
|
||||
},
|
||||
}),
|
||||
"00000000-0000-4000-8000-000000000000",
|
||||
);
|
||||
});
|
||||
|
||||
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /@confirm=["']handleSubmit["']/);
|
||||
assert.match(source, /normalizeScanValue/);
|
||||
assert.match(source, /isDuplicateScan/);
|
||||
assert.match(source, /emit\(["']scan["']/);
|
||||
assert.match(source, /emit\(["']invalid["']/);
|
||||
assert.match(source, /emit\(["']duplicate["']/);
|
||||
assert.match(source, /loading/);
|
||||
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
|
||||
});
|
||||
|
||||
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
|
||||
// uni-app 的 pages.json 允许开发模式注释,与构建端保持相同解析语义。
|
||||
const pages = JSON5.parse(
|
||||
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
|
||||
);
|
||||
const pagePaths = new Set(pages.pages.map((page) => page.path));
|
||||
for (const pagePath of [
|
||||
"pages/business/inbound/index",
|
||||
"pages/business/filling/index",
|
||||
"pages/business/shipment/index",
|
||||
"pages/business/usage/index",
|
||||
"pages/business/return/index",
|
||||
]) {
|
||||
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
|
||||
}
|
||||
|
||||
const [workSource, menuSource] = await Promise.all([
|
||||
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
|
||||
readFile(path.join(appRoot, "src/config/businessMenu.js"), "utf8"),
|
||||
]);
|
||||
assert.match(workSource, /hasPermi\(entry\.permission\)/);
|
||||
assert.match(menuSource, /business:inbound:list/);
|
||||
assert.match(menuSource, /business:filling:list/);
|
||||
assert.match(menuSource, /business:shipment:list/);
|
||||
assert.match(menuSource, /business:usage:list/);
|
||||
assert.match(menuSource, /business:return:list/);
|
||||
assert.match(menuSource, /pages\/business\/inbound\/index/);
|
||||
assert.match(menuSource, /pages\/business\/filling\/index/);
|
||||
assert.match(menuSource, /pages\/business\/shipment\/index/);
|
||||
assert.match(menuSource, /pages\/business\/usage\/index/);
|
||||
assert.match(menuSource, /pages\/business\/return\/index/);
|
||||
});
|
||||
|
||||
test("首页展示真实运营驾驶舱,不保留模板入口和虚假统计", async () => {
|
||||
const source = await readFile(path.join(appRoot, "src/pages/index.vue"), "utf8");
|
||||
assert.match(source, /listScanExceptions/);
|
||||
assert.match(source, /最近扫码异常/);
|
||||
assert.match(source, /TestBatchBanner/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/快捷入口|新闻|计划|请假申请|周报|工作效率|新通知|待办任务|85%/,
|
||||
);
|
||||
});
|
||||
|
||||
test("工作台只展示真实业务入口并使用后端列表权限", async () => {
|
||||
const [workSource, menuModule] = await Promise.all([
|
||||
readFile(path.join(appRoot, "src/pages/work/index.vue"), "utf8"),
|
||||
importSource("src/config/businessMenu.js"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
menuModule.businessMenuEntries.map(({ title, permission, path: entryPath }) => ({
|
||||
title,
|
||||
permission,
|
||||
path: entryPath,
|
||||
})),
|
||||
[
|
||||
{
|
||||
title: "仓库入库",
|
||||
permission: "business:inbound:list",
|
||||
path: "/pages/business/inbound/index",
|
||||
},
|
||||
{
|
||||
title: "工厂装填",
|
||||
permission: "business:filling:list",
|
||||
path: "/pages/business/filling/index",
|
||||
},
|
||||
{
|
||||
title: "发货出库",
|
||||
permission: "business:shipment:list",
|
||||
path: "/pages/business/shipment/index",
|
||||
},
|
||||
{
|
||||
title: "客户现场使用",
|
||||
permission: "business:usage:list",
|
||||
path: "/pages/business/usage/index",
|
||||
},
|
||||
{
|
||||
title: "退回仓库",
|
||||
permission: "business:return:list",
|
||||
path: "/pages/business/return/index",
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.match(workSource, /businessMenuEntries/);
|
||||
assert.match(workSource, /hasPermi\(entry\.permission\)/);
|
||||
assert.match(workSource, /navigateTo\(entry\.path\)/);
|
||||
assert.doesNotMatch(workSource, /AI 生产力|智能对话|图像生成|系统管理|handleBuilding|模块建设中/);
|
||||
});
|
||||
|
||||
test("运营驾驶舱 API 对接现有扫码异常接口", async () => {
|
||||
const source = await readFile(path.join(appRoot, "src/api/business/dashboard.js"), "utf8");
|
||||
assert.match(source, /export function listScanExceptions\(/);
|
||||
assert.match(source, /url:\s*["']\/business\/dashboard\/scan-exceptions["']/);
|
||||
assert.match(source, /method:\s*["']get["']/);
|
||||
});
|
||||
|
||||
test("APP 页面标题与后端业务菜单名称一致", async () => {
|
||||
const [pages, fillingSource] = await Promise.all([
|
||||
JSON5.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
|
||||
readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"),
|
||||
]);
|
||||
const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index");
|
||||
assert.equal(fillingPage?.style?.navigationBarTitleText, "工厂装填");
|
||||
assert.match(fillingSource, />工厂装填</);
|
||||
});
|
||||
|
||||
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
|
||||
const requestSource = await readFile(
|
||||
path.join(appRoot, "src/utils/request.js"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(requestSource, /X-Test-Batch-Id/);
|
||||
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
|
||||
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
|
||||
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
|
||||
|
||||
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /TestBatchBanner/);
|
||||
}
|
||||
});
|
||||
|
||||
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
|
||||
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
|
||||
const source = await readFile(path.join(appRoot, file), "utf8");
|
||||
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
|
||||
}
|
||||
for (const page of ["inbound", "shipment"]) {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
|
||||
}
|
||||
});
|
||||
|
||||
test("登录页只预填账号,不把管理员密码写入源码", async () => {
|
||||
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
|
||||
assert.match(source, /username:\s*["']admin["']/);
|
||||
assert.match(source, /password:\s*["']["']/);
|
||||
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
|
||||
});
|
||||
|
||||
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
|
||||
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
|
||||
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
|
||||
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
|
||||
});
|
||||
|
||||
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, "src/pages/business/inbound/index.vue"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /PalletScanInput/);
|
||||
assert.match(source, /scanInboundPallet/);
|
||||
assert.match(source, /UNSTOCKED/);
|
||||
assert.match(source, /warehouseId/);
|
||||
assert.match(source, /palletIds/);
|
||||
assert.match(source, /getInbound\(/);
|
||||
assert.match(source, /updateInbound/);
|
||||
assert.match(source, /confirmInbound/);
|
||||
assert.match(source, /idempotencyKey/);
|
||||
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
|
||||
});
|
||||
|
||||
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, "src/pages/business/shipment/index.vue"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /PalletScanInput/);
|
||||
assert.match(source, /scanShipmentPallet/);
|
||||
assert.match(source, /FILLED_IN_WAREHOUSE/);
|
||||
assert.match(source, /remainingPackages/);
|
||||
assert.match(source, /sourceWarehouseId/);
|
||||
assert.match(source, /targetSiteId/);
|
||||
assert.match(source, /palletIds/);
|
||||
assert.match(source, /getShipment\(/);
|
||||
assert.match(source, /updateShipment/);
|
||||
assert.match(source, /confirmShipment/);
|
||||
assert.match(source, /idempotencyKey/);
|
||||
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
|
||||
});
|
||||
|
||||
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, "src/pages/business/filling/index.vue"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /PalletScanInput/);
|
||||
assert.match(source, /scanFillingPallet/);
|
||||
assert.match(source, /EMPTY_IN_WAREHOUSE/);
|
||||
for (const field of [
|
||||
"qrToken",
|
||||
"warehouseId",
|
||||
"strainId",
|
||||
"batchNo",
|
||||
"packageSpec",
|
||||
"initialPackages",
|
||||
"idempotencyKey",
|
||||
]) {
|
||||
assert.match(source, new RegExp(field));
|
||||
}
|
||||
assert.match(source, /addFilling/);
|
||||
assert.match(source, /business:filling:(?:query|add)/);
|
||||
});
|
||||
|
||||
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, "src/pages/business/usage/index.vue"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /PalletScanInput/);
|
||||
assert.match(source, /scanUsagePallet/);
|
||||
assert.match(source, /AT_SITE_UNUSED/);
|
||||
assert.match(source, /AT_SITE_PARTIAL/);
|
||||
assert.match(source, /currentLineId/);
|
||||
assert.match(source, /getUsageLineOptions/);
|
||||
assert.match(source, /usedPackages/);
|
||||
assert.match(source, /remainingPackages/);
|
||||
assert.match(source, /flowState/);
|
||||
assert.match(source, /addUsage/);
|
||||
assert.match(source, /idempotencyKey/);
|
||||
assert.match(source, /business:usage:(?:query|add)/);
|
||||
});
|
||||
|
||||
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
|
||||
const source = await readFile(
|
||||
path.join(appRoot, "src/pages/business/return/index.vue"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /PalletScanInput/);
|
||||
assert.match(source, /scanReturnPallet/);
|
||||
assert.match(source, /mode:\s*["']request["']/);
|
||||
assert.match(source, /mode:\s*["']confirm["']/);
|
||||
assert.match(source, /sourceSiteId/);
|
||||
assert.match(source, /targetWarehouseId/);
|
||||
assert.match(source, /palletIds/);
|
||||
assert.match(source, /scannedPalletIds/);
|
||||
assert.match(source, /requestReturn/);
|
||||
assert.match(source, /getReturn\(/);
|
||||
assert.match(source, /confirmReturn/);
|
||||
assert.match(source, /sameIdSet/);
|
||||
assert.match(source, /REQUESTED/);
|
||||
assert.match(source, /requestIdempotencyKey/);
|
||||
assert.match(source, /confirmIdempotencyKey/);
|
||||
assert.match(source, /business:return:(?:query|request|confirm)/);
|
||||
});
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function importSource(relativePath) {
|
||||
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
||||
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const readSource = (relativePath) =>
|
||||
readFileSync(resolve(appRoot, relativePath), "utf8");
|
||||
|
||||
test("APP 首页展示 PC 端核心运营统计并保护移动端横向空间", () => {
|
||||
const source = readSource("src/pages/index.vue");
|
||||
|
||||
for (const className of [
|
||||
"dashboard-overview",
|
||||
"dashboard-kpi-grid",
|
||||
"dashboard-trend-card",
|
||||
"dashboard-distribution-card",
|
||||
"dashboard-trend-range",
|
||||
"dashboard-status-list",
|
||||
]) {
|
||||
assert.match(source, new RegExp(`class=\\"[^\\"]*${className}[^\\"]*\\"`));
|
||||
}
|
||||
|
||||
for (const label of [
|
||||
"当前库存托盘",
|
||||
"剩余包数",
|
||||
"今日入库单",
|
||||
"今日出库单",
|
||||
"待确认单据",
|
||||
"入库 / 出库趋势",
|
||||
"托盘流转状态分布",
|
||||
]) {
|
||||
assert.match(source, new RegExp(label));
|
||||
}
|
||||
|
||||
assert.match(source, /listPalletInventory/);
|
||||
assert.match(source, /listShipmentReport/);
|
||||
assert.match(source, /listInbound/);
|
||||
assert.match(source, /listShipment/);
|
||||
assert.match(source, /listReturn/);
|
||||
assert.match(source, /TREND_RANGE_OPTIONS/);
|
||||
assert.match(source, /overflow-x:\s*hidden/);
|
||||
assert.match(source, /min-width:\s*0/);
|
||||
});
|
||||
|
||||
test("APP 首页统计保留加载、空数据和部分失败状态", () => {
|
||||
const source = readSource("src/pages/index.vue");
|
||||
|
||||
assert.match(source, /statsLoading/);
|
||||
assert.match(source, /statsError/);
|
||||
assert.match(source, /dashboard-stat-empty/);
|
||||
assert.match(source, /dashboard-stat-error/);
|
||||
assert.match(source, /formatMetric/);
|
||||
assert.match(source, /--/);
|
||||
});
|
||||
|
||||
test("APP 首页扫码异常支持默认收起并通过标题展开", () => {
|
||||
const source = readSource("src/pages/index.vue");
|
||||
|
||||
assert.match(source, /const exceptionsExpanded = ref\(false\)/);
|
||||
assert.match(source, /function toggleExceptionList\(\)/);
|
||||
assert.match(source, /class="[^"]*exception-header[^"]*"/);
|
||||
assert.match(source, /@click="toggleExceptionList"/);
|
||||
assert.match(source, /exception-toggle-icon/);
|
||||
assert.match(source, /v-else-if="exceptionsExpanded" class="exception-list"/);
|
||||
});
|
||||
|
||||
test("APP 首页报表 API 复用现有只读报表路径", () => {
|
||||
const inventorySource = readSource("src/api/report/palletInventory.js");
|
||||
const shipmentSource = readSource("src/api/report/shipment.js");
|
||||
|
||||
assert.match(inventorySource, /export function listPalletInventory\(/);
|
||||
assert.match(inventorySource, /url:\s*["']\/report\/pallet-inventory\/list["']/);
|
||||
assert.match(inventorySource, /method:\s*["']get["']/);
|
||||
assert.match(shipmentSource, /export function listShipmentReport\(/);
|
||||
assert.match(shipmentSource, /url:\s*["']\/report\/shipment\/list["']/);
|
||||
assert.match(shipmentSource, /method:\s*["']get["']/);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const loginPagePath = resolve(appRoot, "src/pages/login.vue");
|
||||
const loginPageSource = readFileSync(loginPagePath, "utf8");
|
||||
|
||||
test("APP 登录页沿用 PC 端的品牌背景与登录卡片结构", () => {
|
||||
assert.match(loginPageSource, /class="login-page"/);
|
||||
assert.match(loginPageSource, /\/static\/login-background\.jpg/);
|
||||
assert.match(loginPageSource, /class="login-card"/);
|
||||
assert.match(loginPageSource, /globalConfig\.appInfo\.name/);
|
||||
assert.doesNotMatch(
|
||||
loginPageSource,
|
||||
/bg-gradient-to-br from-blue-50 to-indigo-100/,
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(resolve(appRoot, "src/static/login-background.jpg")),
|
||||
"APP 登录页需要包含与 PC 端一致的背景资源",
|
||||
);
|
||||
});
|
||||
|
||||
test("APP 登录页保留验证码刷新与回车登录交互", () => {
|
||||
assert.match(loginPageSource, /@click="getCode"/);
|
||||
assert.match(loginPageSource, /@confirm="handleLogin"/);
|
||||
assert.match(loginPageSource, /captchaEnabled/);
|
||||
});
|
||||
|
||||
test("APP 登录页为白色 Sylvan 字标提供深色承载层", () => {
|
||||
assert.match(loginPageSource, /class="brand-logo-frame"/);
|
||||
assert.match(
|
||||
loginPageSource,
|
||||
/\.brand-logo-frame\s*\{[\s\S]*background:\s*#28225c/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const readSource = (relativePath) =>
|
||||
readFileSync(resolve(appRoot, relativePath), "utf8");
|
||||
|
||||
const homeSource = readSource("src/pages/index.vue");
|
||||
const workSource = readSource("src/pages/work/index.vue");
|
||||
const bannerSource = readSource("src/components/business/TestBatchBanner.vue");
|
||||
const modeSwitchSource = readSource(
|
||||
"src/components/business/TestBatchModeSwitch.vue",
|
||||
);
|
||||
|
||||
test("首页使用受约束的运营布局并保护横向空间", () => {
|
||||
assert.match(homeSource, /class="dashboard-page"/);
|
||||
assert.match(homeSource, /class="dashboard-scroll"/);
|
||||
assert.match(homeSource, /class="dashboard-content"/);
|
||||
assert.match(homeSource, /class="dashboard-hero"/);
|
||||
assert.match(homeSource, /class="dashboard-metric-grid"/);
|
||||
assert.match(homeSource, /class="exception-list"/);
|
||||
assert.match(homeSource, /class="exception-item"/);
|
||||
assert.match(homeSource, /overflow-x:\s*hidden/);
|
||||
assert.match(homeSource, /overflow-wrap:\s*anywhere/);
|
||||
});
|
||||
|
||||
test("工作台入口在窄屏下保持单列和可换行", () => {
|
||||
assert.match(workSource, /class="work-page"/);
|
||||
assert.match(workSource, /class="work-scroll"/);
|
||||
assert.match(workSource, /class="work-content"/);
|
||||
assert.match(workSource, /class="work-entry-list"/);
|
||||
assert.match(workSource, /class="work-entry"/);
|
||||
assert.match(workSource, /class="work-entry-copy"/);
|
||||
assert.match(workSource, /overflow-x:\s*hidden/);
|
||||
assert.match(workSource, /overflow-wrap:\s*anywhere/);
|
||||
});
|
||||
|
||||
test("测试批次提示和切换区不会撑开页面", () => {
|
||||
assert.match(bannerSource, /class="test-batch-banner"/);
|
||||
assert.match(bannerSource, /overflow-wrap:\s*anywhere/);
|
||||
assert.match(modeSwitchSource, /class="test-batch-switch"/);
|
||||
assert.match(modeSwitchSource, /class="test-batch-picker"/);
|
||||
assert.match(modeSwitchSource, /overflow-wrap:\s*anywhere/);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
|
||||
|
||||
|
||||
# -------- 数据库配置 --------
|
||||
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
|
||||
# 数据库类型,固定使用'mysql'
|
||||
DB_TYPE = 'mysql'
|
||||
# 数据库主机
|
||||
DB_HOST = '127.0.0.1'
|
||||
@@ -140,9 +140,15 @@ LOG_WORKER_ID = 'auto'
|
||||
|
||||
# -------- 传输层加解密配置 --------
|
||||
# 是否启用传输层加解密
|
||||
TRANSPORT_CRYPTO_ENABLED = false
|
||||
#TRANSPORT_CRYPTO_ENABLED = false
|
||||
## 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
||||
#TRANSPORT_CRYPTO_MODE = 'off'
|
||||
|
||||
TRANSPORT_CRYPTO_ENABLED = true
|
||||
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
||||
TRANSPORT_CRYPTO_MODE = 'off'
|
||||
TRANSPORT_CRYPTO_MODE = 'optional'
|
||||
|
||||
|
||||
# 传输层加解密算法标识
|
||||
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
|
||||
# 当前启用的密钥版本标识
|
||||
@@ -171,3 +177,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
||||
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
|
||||
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
|
||||
|
||||
@@ -42,7 +42,7 @@ JWT_REDIS_EXPIRE_MINUTES = 30
|
||||
|
||||
|
||||
# -------- 数据库配置 --------
|
||||
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
|
||||
# 数据库类型,固定使用'mysql'
|
||||
DB_TYPE = '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_HOST = '127.0.0.1'
|
||||
@@ -171,3 +171,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
||||
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
|
||||
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
|
||||
|
||||
@@ -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