Compare commits
123
Commits
b5b4721456
...
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 | ||
|
|
3edba99d2d | ||
|
|
acddc8a237 | ||
|
|
b4822fef12 | ||
|
|
9ef214f040 | ||
|
|
b608a166c3 | ||
|
|
a1613f8780 | ||
|
|
db5755586d | ||
|
|
619f3d5d63 | ||
|
|
9a9c57578d | ||
|
|
23b3cc3821 | ||
|
|
8a5a8d976e | ||
|
|
64ed087f5c | ||
|
|
9a32f1a897 | ||
|
|
b6f3b1e1f0 | ||
|
|
4a321098f8 | ||
|
|
9b1a807098 | ||
|
|
684dd451bb | ||
|
|
37c94b6713 | ||
|
|
9bdafd3bac | ||
|
|
6f751486e4 | ||
|
|
33d73ca0ef | ||
|
|
9b5f479f0d | ||
|
|
3d1b64971f | ||
|
|
7a786800bd | ||
|
|
b858340890 | ||
|
|
626e15fe89 | ||
|
|
2ab075f095 | ||
|
|
8361c11c4c | ||
|
|
85faef213e | ||
|
|
4340d03a70 | ||
|
|
a223971a3c | ||
|
|
e4477f321c | ||
|
|
d5da207206 | ||
|
|
e5006c6e43 | ||
|
|
d0fa20afe8 | ||
|
|
2d2b3cf6c6 | ||
|
|
137b2934f7 | ||
|
|
246e69f5c8 | ||
|
|
33f29df40e | ||
|
|
d1f0b9d48b | ||
|
|
1f23d92ffb | ||
|
|
c08b5a5d3d | ||
|
|
dc190f1162 | ||
|
|
6b97bb9f49 | ||
|
|
dfd720359a | ||
|
|
c9180b3832 | ||
|
|
80c2288122 | ||
|
|
12260fdf83 | ||
|
|
e58e110dd1 | ||
|
|
cda87a4d65 | ||
|
|
33228faffc | ||
|
|
bc774c74e6 | ||
|
|
3076194328 | ||
|
|
dc28cf6df0 | ||
|
|
7317631a13 | ||
|
|
66890ca63c | ||
|
|
f6e994d95f | ||
|
|
18abe861f9 | ||
|
|
2e616ab6e0 | ||
|
|
6f22cf0282 | ||
|
|
317a0fbf68 | ||
|
|
6c2a5fa344 | ||
|
|
85905d85ca | ||
|
|
2e35da13cc | ||
|
|
aba8a96cb8 | ||
|
|
6851499be2 | ||
|
|
f3eda856b0 | ||
|
|
f5d47ad2bb | ||
|
|
83a834f87e | ||
|
|
36e60af46b | ||
|
|
f747a80a2a | ||
|
|
eab725a541 | ||
|
|
f814f916a5 | ||
|
|
f6e9566f70 | ||
|
|
cb3bf5bf4c | ||
|
|
bab09f4eb3 | ||
|
|
5209911dd8 | ||
|
|
a509dada79 | ||
|
|
ce6846f920 |
@@ -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>
|
||||
-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>
|
||||
@@ -12,6 +12,7 @@
|
||||
2.新增移动端模块 (#73)。
|
||||
3.新增多worker运行支持 (#76)。
|
||||
4.应用新增演示模式 (#78)。
|
||||
5.新增托盘流转业务,覆盖基础资料、仓库入库至退回仓库的全流程、只读报表及 Android PDA 浏览器扫码操作。
|
||||
|
||||
### BUG修复
|
||||
|
||||
@@ -23,6 +24,7 @@
|
||||
### 代码重构
|
||||
|
||||
1.移除对python3.9的支持 (#67)。
|
||||
2.移除AI管理模块。
|
||||
|
||||
### 代码优化
|
||||
|
||||
|
||||
@@ -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版本:
|
||||
@@ -35,12 +35,32 @@
|
||||
16. 在线构建器:拖动表单元素生成相应的HTML代码。
|
||||
17. 系统接口:根据业务代码自动生成相关的api接口文档。
|
||||
18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。
|
||||
19. AI管理:提供AI模型管理和AI对话功能。
|
||||
|
||||
## 托盘流转业务
|
||||
|
||||
本轮新增托盘全生命周期流转管理:
|
||||
|
||||
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
|
||||
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
|
||||
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
|
||||
* Android PDA 可通过基于 uni-app 的 H5 页面使用,并支持 HID 扫码枪扫码后回车提交。
|
||||
|
||||
本轮交付包含 PDA H5 页面,但暂不打包 APK 或其他原生安装包,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./shierfeng-fastapi-backend/sql/pallet-workflow-deployment.md)。
|
||||
|
||||
测试数据批次的创建、授权、历史数据纳入和清理流程请阅读[测试数据批次操作说明](./shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。
|
||||
|
||||
数据库初始化与升级入口请阅读 [SQL 使用说明](./shierfeng-fastapi-backend/sql/README.md)。新空库直接使用已包含隧道、物料日期、收货和 FIFO 结构的初始化 SQL;已有生产库不得运行全量初始化,请按[生产 Compose 增量迁移说明](./shierfeng-fastapi-backend/sql/production-compose-material-flow-migration-20261008.md)核对适用条件后执行。
|
||||
|
||||
### Git 跟踪范围
|
||||
|
||||
根目录 `.idea/`、`.pnpm-store/`、`.superpowers/`、`docs/`、`logs/` 及后端 `logs/` 仅保留本地,不再提交;Python 缓存及本地依赖同样忽略。必要的业务部署与数据库迁移说明随后端 `sql/` 目录维护。代码、迁移 SQL、测试和必要部署配置仍正常跟踪。
|
||||
|
||||
其他已有克隆首次拉取本次清理提交前,应先备份这些目录中需要保留的文件:Git 可能删除此前被跟踪的副本。此次清理不重写历史,旧提交中的文件仍然存在。
|
||||
|
||||
## 在线体验
|
||||
|
||||
* *账号:admin*
|
||||
* *密码:admin123*
|
||||
* *密码:请通过授权渠道获取,不在仓库中记录*
|
||||
* 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
|
||||
|
||||
## 项目开发及发布相关
|
||||
@@ -100,17 +120,15 @@ pnpm dev:mp-weixin
|
||||
# 进入后端目录
|
||||
cd shierfeng-fastapi-backend
|
||||
|
||||
# 如果使用的是MySQL数据库,请执行以下命令安装项目依赖环境
|
||||
# 安装项目依赖环境(统一使用 MySQL)
|
||||
pip3 install -r requirements.txt
|
||||
# 如果使用的是PostgreSQL数据库,请执行以下命令安装项目依赖环境
|
||||
pip3 install -r requirements-pg.txt
|
||||
|
||||
# 配置环境
|
||||
在.env.dev文件中配置开发环境的数据库和redis
|
||||
|
||||
# 运行sql文件
|
||||
1.新建数据库shierfeng-fastapi(默认,可修改)
|
||||
2.如果使用的是MySQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi.sql;如果使用的是PostgreSQL数据库,使用命令或数据库连接工具运行sql文件夹下的shierfeng-fastapi-pg.sql
|
||||
# 运行 SQL 文件
|
||||
1. 新建数据库 shierfeng-fastapi(默认,可修改)
|
||||
2. 使用命令或数据库连接工具运行 sql 文件夹下的 shierfeng-fastapi.sql
|
||||
|
||||
# 运行后端
|
||||
shierfeng app run --env=dev
|
||||
@@ -121,12 +139,12 @@ shierfeng app run --env=dev
|
||||
#### 访问
|
||||
|
||||
```bash
|
||||
# 默认账号密码
|
||||
# 本地账号
|
||||
账号:admin
|
||||
密码:admin123
|
||||
密码:请在目标环境安全配置中设置后手工输入,不在仓库中记录
|
||||
|
||||
# 浏览器访问
|
||||
地址:http://localhost:80
|
||||
地址:http://localhost:8080
|
||||
```
|
||||
|
||||
### 发布
|
||||
@@ -153,16 +171,25 @@ shierfeng app run --env=prod
|
||||
|
||||
### Docker Compose部署方式
|
||||
|
||||
> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化
|
||||
> ⚠️ **说明:** 根目录 Compose 使用 MySQL 和 Redis 命名卷持久化数据。初始化 SQL 只会在 MySQL 数据目录首次为空时执行。
|
||||
|
||||
#### MySQL版本
|
||||
#### MySQL 全栈版本
|
||||
|
||||
```bash
|
||||
docker compose -f shierfeng-fastapi-test/docker-compose.test.my.yml up -d --build
|
||||
# 首次使用时创建本地配置文件
|
||||
cp .env.docker.example .env
|
||||
# 编辑 .env,至少修改 MYSQL_ROOT_PASSWORD、DB_PASSWORD 和 JWT_SECRET_KEY
|
||||
|
||||
# 启动前只做静态配置校验
|
||||
docker compose config --quiet
|
||||
|
||||
# 构建并启动前端、后端、MySQL、Redis
|
||||
docker compose up -d --build
|
||||
|
||||
# 查看服务状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### PostgreSQL版本
|
||||
浏览器访问:http://localhost:8080;后端直连接口为 `http://localhost:9099`;宿主机连接 MySQL 时使用 `127.0.0.1:13307`。需要局域网访问时,将根目录 `.env` 中的 `COMPOSE_BIND_IP` 改为 `0.0.0.0`,再使用宿主机局域网地址访问前端和后端端口。
|
||||
|
||||
```bash
|
||||
docker compose -f shierfeng-fastapi-test/docker-compose.test.pg.yml up -d --build
|
||||
```
|
||||
已有数据库不能通过全量初始化脚本升级。请先备份并核对目标库,再按照 `shierfeng-fastapi-backend/sql/patches/` 下对应的 MySQL 增量补丁和部署文档执行;不要通过删除 Compose 数据卷来代替迁移。
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version" : "1.0",
|
||||
"configurations" : [
|
||||
{
|
||||
"customPlaygroundType" : "device",
|
||||
"packageName" : "",
|
||||
"playground" : "standard",
|
||||
"type" : "uni-app:app-android"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# ShiErFeng 移动端
|
||||
# 智链云枢管理系统移动端
|
||||
|
||||
基于 `uni-app` 的 `vite` + `vue3` + `tailwindcss` 开发。
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
>
|
||||
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
|
||||
|
||||
### 配置 PDA 后端地址
|
||||
|
||||
PDA 默认请求本机 `http://localhost:9099`,仅适用于后端运行在同一设备的本地开发。部署到独立 PDA 或局域网服务器时,在构建环境注入 `VITE_APP_BASE_API`:
|
||||
|
||||
```bash
|
||||
VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
|
||||
```
|
||||
|
||||
不要把包含真实凭据的环境文件或地址配置提交到仓库。
|
||||
|
||||
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../shierfeng-fastapi-backend/sql/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
|
||||
|
||||
### vscode
|
||||
|
||||
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
|
||||
|
||||
@@ -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,14 +1,15 @@
|
||||
// 应用全局配置
|
||||
export default {
|
||||
baseUrl: "http://localhost:9099",
|
||||
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
|
||||
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
|
||||
// 应用信息
|
||||
appInfo: {
|
||||
// 应用名称
|
||||
name: "ShiErFeng-FastAPI-APP",
|
||||
name: "智链云枢管理系统",
|
||||
// 应用版本
|
||||
version: "1.9.0",
|
||||
// 应用logo
|
||||
logo: "/static/logo.png",
|
||||
logo: "/static/sylvan-logo.png",
|
||||
// 官方网站
|
||||
site_url: "https://vfadmin.insistence.tech",
|
||||
// 政策协议
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export const businessMenuEntries = [
|
||||
{
|
||||
path: "/pages/business/inbound/index",
|
||||
title: "仓库入库",
|
||||
description: "扫描托盘登记入库",
|
||||
icon: "i-mdi-package-down",
|
||||
iconClass: "bg-blue-50 text-blue-600",
|
||||
permission: "business:inbound:list",
|
||||
},
|
||||
{
|
||||
path: "/pages/business/filling/index",
|
||||
title: "工厂装填",
|
||||
description: "录入菌种和包数",
|
||||
icon: "i-mdi-package-variant-closed-plus",
|
||||
iconClass: "bg-green-50 text-green-600",
|
||||
permission: "business:filling:list",
|
||||
},
|
||||
{
|
||||
path: "/pages/business/shipment/index",
|
||||
title: "发货出库",
|
||||
description: "扫码创建发货单",
|
||||
icon: "i-mdi-truck-delivery",
|
||||
iconClass: "bg-orange-50 text-orange-600",
|
||||
permission: "business:shipment:list",
|
||||
permissions: ["business:shipment:list", "business:shipment:receive"],
|
||||
},
|
||||
{
|
||||
path: "/pages/business/usage/index",
|
||||
title: "客户现场使用",
|
||||
description: "绑定产线记录消耗",
|
||||
icon: "i-mdi-factory",
|
||||
iconClass: "bg-purple-50 text-purple-600",
|
||||
permission: "business:usage:list",
|
||||
},
|
||||
{
|
||||
path: "/pages/business/return/index",
|
||||
title: "退回仓库",
|
||||
description: "申请和确认退回",
|
||||
icon: "i-mdi-undo-variant",
|
||||
iconClass: "bg-red-50 text-red-600",
|
||||
permission: "business:return:list",
|
||||
},
|
||||
];
|
||||
@@ -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,70 +1,72 @@
|
||||
{
|
||||
"name": "ShiErFeng-FastAPI移动端",
|
||||
"appid": "__UNI__25A9D80",
|
||||
"description": "",
|
||||
"versionName": "1.9.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueCompiler": "uni-app",
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
"name" : "智链云枢管理系统",
|
||||
"appid" : "__UNI__F8DBA93",
|
||||
"description" : "",
|
||||
"versionName" : "1.9.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueCompiler" : "uni-app",
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"dSYMs": false
|
||||
},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "wxccd7e2a0911b3397",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": false,
|
||||
"minified": true,
|
||||
"postcss": true
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wxccd7e2a0911b3397",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"es6" : false,
|
||||
"minified" : true,
|
||||
"postcss" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true
|
||||
},
|
||||
"optimization": {
|
||||
"subPackages": true
|
||||
"vueVersion" : "3",
|
||||
"h5" : {
|
||||
"devServer" : {
|
||||
"port" : 9090,
|
||||
"https" : false
|
||||
},
|
||||
"title" : "智链云枢管理系统",
|
||||
"router" : {
|
||||
"mode" : "hash",
|
||||
"base" : "./"
|
||||
}
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"vueVersion": "3",
|
||||
"h5": {
|
||||
"devServer": {
|
||||
"port": 9090,
|
||||
"https": false
|
||||
},
|
||||
"title": "ShiErFeng-FastAPI-APP",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
}
|
||||
}
|
||||
"locale" : "zh-Hans",
|
||||
"fallbackLocale" : "zh-Hans"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "ShiErFeng-FastAPI移动端",
|
||||
"navigationBarTitleText": "智链云枢管理系统",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
@@ -25,6 +25,36 @@
|
||||
"navigationBarTitleText": "工作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/inbound/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "仓库入库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/filling/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工厂装填"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/shipment/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "发货出库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/usage/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "现场使用"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/business/return/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "退回仓库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
@@ -126,7 +156,17 @@
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "ShiErFeng",
|
||||
"navigationBarTitleText": "智链云枢管理系统",
|
||||
"navigationBarBackgroundColor": "#FFFFFF"
|
||||
},
|
||||
"condition" : { //模式配置,仅开发期间生效
|
||||
"current": 0, //当前激活的模式(list 的索引项)
|
||||
"list": [
|
||||
{
|
||||
"name": "", //模式名称
|
||||
"path": "", //启动页面,必选
|
||||
"query": "" //启动参数,在页面的onLoad函数里面得到
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">工厂装填</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描空托盘,录入菌种、批次、规格和初始包数
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有装填操作权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">扫描待装填托盘</text>
|
||||
<button
|
||||
class="text-xs text-gray-500"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="resetOperation"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="operationPending || optionLoading"
|
||||
:loading="scanLoading"
|
||||
@scan="handleFillingScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 EMPTY_IN_WAREHOUSE 状态托盘。
|
||||
</text>
|
||||
<text v-if="scanError" class="mt-2 block text-xs text-red-500">{{ scanError }}</text>
|
||||
|
||||
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-green-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">
|
||||
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||
</text>
|
||||
<text class="text-xs text-green-600">{{ scannedPallet.flowState }}</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
仓库:{{ scannedPallet.warehouseName || `#${scannedPallet.warehouseId}` }} · 容量:{{ scannedPallet.capacityPackages }} 包
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">装填信息</text>
|
||||
<view class="space-y-3">
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">菌种</text>
|
||||
<picker
|
||||
:range="strainOptions"
|
||||
range-key="name"
|
||||
:value="strainIndex"
|
||||
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||
@change="handleStrainChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedStrainName || "请选择启用菌种" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">生产批次</text>
|
||||
<input
|
||||
v-model="form.batchNo"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
maxlength="64"
|
||||
placeholder="请输入生产批次"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">生产日期</text>
|
||||
<picker mode="date" :value="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('productionDate', $event)">
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.productionDate || '选择来源生产日期' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">有效期至</text>
|
||||
<picker mode="date" :value="form.expiryDate" :start="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('expiryDate', $event)">
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.expiryDate || '选择有效期截止日期' }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
|
||||
<input
|
||||
v-model="form.packageSpec"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
maxlength="64"
|
||||
placeholder="例如:500g/包"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">初始包数</text>
|
||||
<input
|
||||
v-model="form.initialPackages"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
type="number"
|
||||
placeholder="固定 100 包"
|
||||
disabled
|
||||
/>
|
||||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="formError" class="mt-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-green-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || !scannedPallet"
|
||||
@click="submitFilling"
|
||||
>
|
||||
{{ submitLoading ? "提交中" : "确认装填" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">最近装填记录</text>
|
||||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadFillingList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!fillingList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无装填记录
|
||||
</view>
|
||||
<view
|
||||
v-for="row in fillingList"
|
||||
v-else
|
||||
:key="row.fillingId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<text class="block font-medium text-gray-700">{{ row.fillingNo || `#${row.fillingId}` }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ row.strainNameSnapshot || row.strainCodeSnapshot }} · {{ row.batchNo }} · {{ row.initialPackages }} 包
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-400">托盘 #{{ row.palletId }} · {{ row.operatedAt || row.createTime || "—" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addFilling,
|
||||
getFillingStrainOptions,
|
||||
listFilling,
|
||||
scanFillingPallet,
|
||||
} from "@/api/business/filling";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const fillingList = ref([]);
|
||||
const strainOptions = ref([]);
|
||||
const scannedPallet = ref(null);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const scanError = ref("");
|
||||
const formError = ref("");
|
||||
const idempotencyKey = ref("");
|
||||
const form = reactive({
|
||||
qrToken: "",
|
||||
warehouseId: null,
|
||||
strainId: null,
|
||||
batchNo: "",
|
||||
productionDate: "",
|
||||
expiryDate: "",
|
||||
packageSpec: "",
|
||||
initialPackages: 100,
|
||||
});
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:filling:list",
|
||||
"business:filling:query",
|
||||
"business:filling:add",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:filling:list"));
|
||||
const canOperate = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:filling:query", "business:filling:add"]),
|
||||
);
|
||||
const operationPending = computed(() => submitLoading.value);
|
||||
const strainIndex = computed(() => {
|
||||
const index = strainOptions.value.findIndex((item) => item.id === form.strainId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedStrainName = computed(
|
||||
() => strainOptions.value.find((item) => item.id === form.strainId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function loadFillingList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listFilling({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
fillingList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
fillingList.value = [];
|
||||
listError.value = errorMessage(error, "装填记录加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadStrainOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:filling:query")) return;
|
||||
optionLoading.value = true;
|
||||
getFillingStrainOptions()
|
||||
.then((response) => {
|
||||
strainOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
strainOptions.value = [];
|
||||
formError.value = errorMessage(error, "菌种选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetOperation() {
|
||||
scanGeneration += 1;
|
||||
scannedPallet.value = null;
|
||||
idempotencyKey.value = "";
|
||||
form.qrToken = "";
|
||||
form.warehouseId = null;
|
||||
form.strainId = null;
|
||||
form.batchNo = "";
|
||||
form.productionDate = "";
|
||||
form.expiryDate = "";
|
||||
form.packageSpec = "";
|
||||
form.initialPackages = 100;
|
||||
scanError.value = "";
|
||||
formError.value = "";
|
||||
scanLoading.value = false;
|
||||
}
|
||||
|
||||
function handleFillingScan(qrToken) {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
resetOperation();
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanFillingPallet(qrToken)
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "EMPTY_IN_WAREHOUSE") {
|
||||
scanError.value = "该托盘当前不可装填";
|
||||
return;
|
||||
}
|
||||
scannedPallet.value = {
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
warehouseId: pallet.warehouseId,
|
||||
warehouseName: pallet.warehouseName,
|
||||
flowState: pallet.flowState,
|
||||
capacityPackages: pallet.capacityPackages,
|
||||
};
|
||||
form.qrToken = pallet.qrToken || qrToken;
|
||||
form.warehouseId = pallet.warehouseId;
|
||||
loadStrainOptions();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
resetOperation();
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function handleStrainChange(event) {
|
||||
const option = strainOptions.value[Number(event.detail.value)];
|
||||
form.strainId = option?.id || null;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function markPayloadChanged() {
|
||||
if (!submitLoading.value) idempotencyKey.value = "";
|
||||
}
|
||||
|
||||
function handleMaterialDateChange(field, event) {
|
||||
if (operationPending.value) return;
|
||||
form[field] = event.detail.value;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function ensureIdempotencyKey() {
|
||||
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||
return idempotencyKey.value;
|
||||
}
|
||||
|
||||
function submitFilling() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
const initialPackages = Number(form.initialPackages);
|
||||
if (!scannedPallet.value || !form.qrToken || !form.warehouseId) {
|
||||
formError.value = "请先扫描待装填托盘";
|
||||
return;
|
||||
}
|
||||
if (!form.strainId || !form.batchNo.trim() || !form.packageSpec.trim()) {
|
||||
formError.value = "请完整填写菌种、批次和包装规格";
|
||||
return;
|
||||
}
|
||||
if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
|
||||
formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
|
||||
return;
|
||||
}
|
||||
if (initialPackages !== 100) {
|
||||
formError.value = "新装填固定为 100 包";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
addFilling({
|
||||
qrToken: form.qrToken,
|
||||
warehouseId: form.warehouseId,
|
||||
strainId: form.strainId,
|
||||
batchNo: form.batchNo.trim(),
|
||||
productionDate: form.productionDate,
|
||||
expiryDate: form.expiryDate,
|
||||
packageSpec: form.packageSpec.trim(),
|
||||
initialPackages,
|
||||
idempotencyKey: ensureIdempotencyKey(),
|
||||
})
|
||||
.then((response) => {
|
||||
idempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess(response.msg || "托盘装填成功");
|
||||
resetOperation();
|
||||
loadFillingList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = `${errorMessage(error, "装填提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadFillingList();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,486 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">仓库入库</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描尚未首次入库的空托盘,保存草稿后确认
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="canDraft"
|
||||
class="rounded-lg bg-blue-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startNewDraft"
|
||||
>
|
||||
新建草稿
|
||||
</button>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有入库操作权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-4 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">
|
||||
{{ editing ? "编辑入库草稿" : "新建入库草稿" }}
|
||||
</text>
|
||||
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">入库仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择授权仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||
<PalletScanInput
|
||||
:disabled="!form.warehouseId || detailLoading || operationPending"
|
||||
:loading="scanLoading"
|
||||
@scan="handleInboundScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 UNSTOCKED 托盘;同一托盘不会重复加入。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
|
||||
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||
<view
|
||||
v-for="pallet in scannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<view>
|
||||
<text class="block text-sm font-medium text-gray-700">
|
||||
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||
</text>
|
||||
<text class="block text-xs text-gray-500">{{ pallet.flowState }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="text-xs text-red-500"
|
||||
size="mini"
|
||||
:disabled="operationPending || detailLoading"
|
||||
@click="removeScannedPallet(pallet.palletId)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||
@click="saveDraft"
|
||||
>
|
||||
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||
:disabled="operationPending"
|
||||
@click="cancelEditor"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">入库单</text>
|
||||
<button
|
||||
class="text-xs text-blue-500"
|
||||
size="mini"
|
||||
:disabled="loading || operationPending"
|
||||
@click="loadInboundList"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!inboundList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无入库单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in inboundList"
|
||||
v-else
|
||||
:key="row.inboundId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.inboundNo || `#${row.inboundId}` }}</text>
|
||||
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
仓库:{{ warehouseName(row.warehouseId) }} · 托盘:{{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startEditDraft(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canConfirm"
|
||||
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="confirmDraft(row)"
|
||||
>
|
||||
{{ confirmLoading ? "确认中" : "确认" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addInbound,
|
||||
confirmInbound,
|
||||
getInbound,
|
||||
getInboundWarehouseOptions,
|
||||
listInbound,
|
||||
scanInboundPallet,
|
||||
updateInbound,
|
||||
} from "@/api/business/inbound";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const inboundList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const scannedPallets = ref([]);
|
||||
const editorVisible = ref(false);
|
||||
const editing = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const confirmLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const formError = ref("");
|
||||
const scanError = ref("");
|
||||
const confirmError = ref("");
|
||||
const form = reactive({ inboundId: null, warehouseId: null });
|
||||
const confirmState = reactive({ inboundId: null, key: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:inbound:list",
|
||||
"business:inbound:query",
|
||||
"business:inbound:add",
|
||||
"business:inbound:edit",
|
||||
"business:inbound:confirm",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:inbound:list"));
|
||||
const canDraft = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:add"]),
|
||||
);
|
||||
const canEdit = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:edit"]),
|
||||
);
|
||||
const canConfirm = computed(() =>
|
||||
proxy.$auth.hasPermi("business:inbound:confirm"),
|
||||
);
|
||||
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||
const operationPending = computed(
|
||||
() =>
|
||||
submitLoading.value ||
|
||||
confirmLoading.value ||
|
||||
detailLoading.value,
|
||||
);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === form.warehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === form.warehouseId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `#${id || "-"}`;
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
scanGeneration += 1;
|
||||
editorVisible.value = false;
|
||||
editing.value = false;
|
||||
form.inboundId = null;
|
||||
form.warehouseId = null;
|
||||
scannedPallets.value = [];
|
||||
detailLoading.value = false;
|
||||
scanLoading.value = false;
|
||||
formError.value = "";
|
||||
scanError.value = "";
|
||||
}
|
||||
|
||||
function loadInboundList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listInbound({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
inboundList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
inboundList.value = [];
|
||||
listError.value = errorMessage(error, "入库单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWarehouseOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:inbound:query")) return;
|
||||
optionLoading.value = true;
|
||||
getInboundWarehouseOptions()
|
||||
.then((response) => {
|
||||
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
formError.value = errorMessage(error, "仓库选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startNewDraft() {
|
||||
if (!canDraft.value || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
loadWarehouseOptions();
|
||||
}
|
||||
|
||||
function startEditDraft(row) {
|
||||
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
editing.value = true;
|
||||
form.inboundId = row.inboundId;
|
||||
loadWarehouseOptions();
|
||||
detailLoading.value = true;
|
||||
getInbound(row.inboundId)
|
||||
.then((response) => {
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "DRAFT") {
|
||||
formError.value = "该入库单已不是草稿状态,不能编辑";
|
||||
return;
|
||||
}
|
||||
form.warehouseId = detail.warehouseId;
|
||||
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
flowState: item.flowState || "UNSTOCKED",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "入库单详情加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
detailLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditor() {
|
||||
if (operationPending.value) return;
|
||||
resetEditor();
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.warehouseId) return;
|
||||
form.warehouseId = option.id;
|
||||
scannedPallets.value = [];
|
||||
scanError.value = "";
|
||||
scanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleInboundScan(qrToken) {
|
||||
if (!form.warehouseId || operationPending.value || scanLoading.value) {
|
||||
scanError.value = "请先选择入库仓库";
|
||||
return;
|
||||
}
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanInboundPallet(qrToken, { warehouseId: form.warehouseId })
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "UNSTOCKED") {
|
||||
scanError.value = "该托盘不是未入库状态,不能加入入库草稿";
|
||||
return;
|
||||
}
|
||||
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在当前草稿中");
|
||||
return;
|
||||
}
|
||||
scannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
flowState: pallet.flowState,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function removeScannedPallet(palletId) {
|
||||
if (operationPending.value) return;
|
||||
scannedPallets.value = scannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||
if (!form.warehouseId) {
|
||||
formError.value = "请选择入库仓库";
|
||||
return;
|
||||
}
|
||||
if (!scannedPallets.value.length) {
|
||||
formError.value = "请至少扫描一个托盘";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
const payload = {
|
||||
warehouseId: form.warehouseId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||
};
|
||||
const submitRequest = editing.value
|
||||
? updateInbound({ ...payload, inboundId: form.inboundId })
|
||||
: addInbound(payload);
|
||||
submitRequest
|
||||
.then((response) => {
|
||||
proxy.$modal.msgSuccess(response.msg || "入库草稿保存成功");
|
||||
resetEditor();
|
||||
loadInboundList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "入库草稿保存失败,请根据提示修正后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirm(content) {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "系统提示",
|
||||
content,
|
||||
cancelText: "取消",
|
||||
confirmText: "确定",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(inboundId) {
|
||||
if (confirmState.inboundId !== inboundId) {
|
||||
confirmState.inboundId = inboundId;
|
||||
confirmState.key = "";
|
||||
}
|
||||
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||
return confirmState.key;
|
||||
}
|
||||
|
||||
function clearConfirmIdempotency() {
|
||||
confirmState.inboundId = null;
|
||||
confirmState.key = "";
|
||||
}
|
||||
|
||||
async function confirmDraft(row) {
|
||||
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
if (!(await askConfirm(`确认入库单“${row.inboundNo || `#${row.inboundId}`}”?`))) return;
|
||||
confirmLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
await confirmInbound(row.inboundId, {
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(row.inboundId),
|
||||
});
|
||||
clearConfirmIdempotency();
|
||||
proxy.$modal.msgSuccess("入库确认成功");
|
||||
loadInboundList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "入库确认失败")};再次确认将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadInboundList();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">退回仓库</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
客户现场发起退回申请,仓库逐托扫描后确认收货
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有退回操作权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="canRequest" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">发起退回申请</text>
|
||||
<button class="text-xs text-gray-500" size="mini" :disabled="requestPending" @click="resetRequestOperation">
|
||||
清空
|
||||
</button>
|
||||
</view>
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">目标仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || requestPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择目标仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="!requestForm.targetWarehouseId || requestPending"
|
||||
:loading="scanLoading.request"
|
||||
@scan="handleRequestScan"
|
||||
@invalid="handleRequestScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
同一申请只能包含同一客户现场的托盘;扫码成功后托盘由服务端冻结。
|
||||
</text>
|
||||
<text v-if="requestError" class="mt-2 block text-xs text-red-500">{{ requestError }}</text>
|
||||
<view v-if="requestScannedPallets.length" class="mt-3 space-y-2">
|
||||
<view
|
||||
v-for="pallet in requestScannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<view>
|
||||
<text class="block text-sm text-gray-700">{{ pallet.palletCode || `#${pallet.palletId}` }}</text>
|
||||
<text class="block text-xs text-gray-500">现场 #{{ pallet.siteId }} · 剩余 {{ pallet.remainingPackages }} 包</text>
|
||||
</view>
|
||||
<button class="text-xs text-red-500" size="mini" :disabled="requestPending" @click="removeRequestPallet(pallet.palletId)">
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="requestForm.sourceSiteId" class="mt-3 block text-xs text-gray-500">
|
||||
来源现场:#{{ requestForm.sourceSiteId }} · {{ requestScannedPallets.length }} 个托盘
|
||||
</text>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!requestScannedPallets.length || requestPending || scanLoading.request"
|
||||
@click="submitReturnRequest"
|
||||
>
|
||||
{{ requestSubmitLoading ? "申请中" : "提交退回申请" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="canConfirm" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">仓库确认退回</text>
|
||||
<text class="text-xs text-gray-400">必须完整扫描单据托盘</text>
|
||||
</view>
|
||||
<picker
|
||||
:range="requestedReturns"
|
||||
range-key="returnNo"
|
||||
:value="requestedReturnIndex"
|
||||
:disabled="confirmPending || detailLoading"
|
||||
@change="handleConfirmSelection"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedReturnLabel || "请选择待确认退回单" }}
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="confirmError" class="mt-2 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="confirmExpectedRows.length" class="mt-3 rounded-xl bg-orange-50 p-3">
|
||||
<text class="block text-xs text-gray-500">
|
||||
预期托盘 {{ confirmExpectedRows.length }} 个,已扫描 {{ confirmScannedPallets.length }} 个
|
||||
</text>
|
||||
<view class="mt-2 space-y-1">
|
||||
<text v-for="row in confirmExpectedRows" :key="row.palletId" class="block text-xs text-gray-600">
|
||||
{{ row.palletCode || `#${row.palletId}` }}
|
||||
<text v-if="isConfirmPalletScanned(row.palletId)" class="text-green-600"> · 已扫描</text>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
class="mt-3"
|
||||
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || detailLoading"
|
||||
:loading="scanLoading.confirm"
|
||||
@scan="handleConfirmScan"
|
||||
@invalid="handleConfirmScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || scanLoading.confirm || detailLoading"
|
||||
@click="submitReturnConfirm"
|
||||
>
|
||||
{{ confirmSubmitLoading ? "确认中" : "确认退回收货" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">退回单</text>
|
||||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadReturnList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!returnList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无退回单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in returnList"
|
||||
v-else
|
||||
:key="row.returnId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.returnNo || `#${row.returnId}` }}</text>
|
||||
<text :class="row.status === 'REQUESTED' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||
{{ row.status === "REQUESTED" ? "待确认" : "已确认" }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
现场 #{{ row.sourceSiteId }} → {{ warehouseName(row.targetWarehouseId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<button
|
||||
v-if="row.status === 'REQUESTED' && canConfirm"
|
||||
class="mt-3 w-full rounded-lg bg-orange-50 py-2 text-xs text-orange-600"
|
||||
size="mini"
|
||||
:disabled="confirmPending"
|
||||
@click="selectReturnRow(row)"
|
||||
>
|
||||
选择并确认
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
confirmReturn,
|
||||
getReturn,
|
||||
getReturnWarehouseOptions,
|
||||
listReturn,
|
||||
requestReturn,
|
||||
scanReturnPallet,
|
||||
} from "@/api/business/return";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const returnList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const requestScannedPallets = ref([]);
|
||||
const confirmExpectedRows = ref([]);
|
||||
const confirmExpectedPalletIds = ref([]);
|
||||
const confirmScannedPallets = ref([]);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const requestError = ref("");
|
||||
const confirmError = ref("");
|
||||
const scanLoading = reactive({ request: false, confirm: false });
|
||||
const requestSubmitLoading = ref(false);
|
||||
const confirmSubmitLoading = ref(false);
|
||||
const requestIdempotencyKey = ref("");
|
||||
const confirmIdempotencyKey = ref("");
|
||||
const requestForm = reactive({ sourceSiteId: null, targetWarehouseId: null });
|
||||
const confirmForm = reactive({ returnId: null });
|
||||
let listGeneration = 0;
|
||||
let requestScanGeneration = 0;
|
||||
let confirmScanGeneration = 0;
|
||||
let detailGeneration = 0;
|
||||
let requestSession = 0;
|
||||
let confirmSession = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:return:list",
|
||||
"business:return:query",
|
||||
"business:return:request",
|
||||
"business:return:confirm",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:return:list"));
|
||||
const canRequest = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:request"]),
|
||||
);
|
||||
const canConfirm = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:confirm"]),
|
||||
);
|
||||
const requestPending = computed(() => requestSubmitLoading.value);
|
||||
const confirmPending = computed(() => confirmSubmitLoading.value);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === requestForm.targetWarehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === requestForm.targetWarehouseId)?.name || "",
|
||||
);
|
||||
const requestedReturns = computed(() =>
|
||||
returnList.value.filter((item) => item.status === "REQUESTED"),
|
||||
);
|
||||
const requestedReturnIndex = computed(() => {
|
||||
const index = requestedReturns.value.findIndex(
|
||||
(item) => item.returnId === confirmForm.returnId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedReturnLabel = computed(() => {
|
||||
const item = requestedReturns.value.find((row) => row.returnId === confirmForm.returnId);
|
||||
return item ? `${item.returnNo || `#${item.returnId}`} · 现场 #${item.sourceSiteId}` : "";
|
||||
});
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||
}
|
||||
|
||||
function loadReturnList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listReturn({ pageNum: 1, pageSize: 30 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
returnList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
returnList.value = [];
|
||||
listError.value = errorMessage(error, "退回单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadWarehouseOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:return:query")) return;
|
||||
optionLoading.value = true;
|
||||
getReturnWarehouseOptions()
|
||||
.then((response) => {
|
||||
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
requestError.value = errorMessage(error, "目标仓库选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetRequestOperation() {
|
||||
requestSession += 1;
|
||||
requestScanGeneration += 1;
|
||||
requestForm.sourceSiteId = null;
|
||||
requestForm.targetWarehouseId = null;
|
||||
requestScannedPallets.value = [];
|
||||
requestIdempotencyKey.value = "";
|
||||
requestError.value = "";
|
||||
scanLoading.request = false;
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === requestForm.targetWarehouseId) return;
|
||||
requestForm.targetWarehouseId = option.id;
|
||||
requestForm.sourceSiteId = null;
|
||||
requestScannedPallets.value = [];
|
||||
requestIdempotencyKey.value = "";
|
||||
requestError.value = "";
|
||||
requestScanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleRequestScan(qrToken) {
|
||||
if (!requestForm.targetWarehouseId || requestPending.value || scanLoading.request) {
|
||||
requestError.value = "请先选择目标仓库";
|
||||
return;
|
||||
}
|
||||
const session = requestSession;
|
||||
const generation = ++requestScanGeneration;
|
||||
scanLoading.request = true;
|
||||
requestError.value = "";
|
||||
scanReturnPallet(qrToken, { mode: "request" })
|
||||
.then((response) => {
|
||||
if (session !== requestSession || generation !== requestScanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (!pallet?.siteId) {
|
||||
requestError.value = "该托盘不在客户现场,不能发起退回";
|
||||
return;
|
||||
}
|
||||
if (requestForm.sourceSiteId && pallet.siteId !== requestForm.sourceSiteId) {
|
||||
requestError.value = "同一退回申请只能包含同一客户现场的托盘";
|
||||
return;
|
||||
}
|
||||
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在退回申请中");
|
||||
return;
|
||||
}
|
||||
requestForm.sourceSiteId = requestForm.sourceSiteId || pallet.siteId;
|
||||
requestScannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
siteId: pallet.siteId,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
});
|
||||
requestIdempotencyKey.value = "";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session === requestSession && generation === requestScanGeneration) {
|
||||
requestError.value = errorMessage(error, "退回托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session === requestSession && generation === requestScanGeneration) {
|
||||
scanLoading.request = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRequestScanInvalid(message) {
|
||||
requestError.value = message;
|
||||
}
|
||||
|
||||
function removeRequestPallet(palletId) {
|
||||
if (requestPending.value) return;
|
||||
requestScannedPallets.value = requestScannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
if (!requestScannedPallets.value.length) requestForm.sourceSiteId = null;
|
||||
requestIdempotencyKey.value = "";
|
||||
}
|
||||
|
||||
function ensureRequestIdempotencyKey() {
|
||||
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey();
|
||||
return requestIdempotencyKey.value;
|
||||
}
|
||||
|
||||
async function submitReturnRequest() {
|
||||
if (requestPending.value || scanLoading.request || !requestScannedPallets.value.length) return;
|
||||
if (!requestForm.sourceSiteId || !requestForm.targetWarehouseId) {
|
||||
requestError.value = "请选择目标仓库并扫描退回托盘";
|
||||
return;
|
||||
}
|
||||
requestSubmitLoading.value = true;
|
||||
requestError.value = "";
|
||||
try {
|
||||
await requestReturn({
|
||||
sourceSiteId: requestForm.sourceSiteId,
|
||||
targetWarehouseId: requestForm.targetWarehouseId,
|
||||
palletIds: requestScannedPallets.value.map((item) => item.palletId),
|
||||
idempotencyKey: ensureRequestIdempotencyKey(),
|
||||
});
|
||||
requestIdempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess("退回申请已提交");
|
||||
resetRequestOperation();
|
||||
loadReturnList();
|
||||
} catch (error) {
|
||||
requestError.value = `${errorMessage(error, "退回申请提交失败")};直接重试将复用本次幂等键`;
|
||||
} finally {
|
||||
requestSubmitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetConfirmOperation() {
|
||||
confirmSession += 1;
|
||||
confirmScanGeneration += 1;
|
||||
detailGeneration += 1;
|
||||
confirmForm.returnId = null;
|
||||
confirmExpectedRows.value = [];
|
||||
confirmExpectedPalletIds.value = [];
|
||||
confirmScannedPallets.value = [];
|
||||
confirmIdempotencyKey.value = "";
|
||||
confirmError.value = "";
|
||||
detailLoading.value = false;
|
||||
scanLoading.confirm = false;
|
||||
}
|
||||
|
||||
function handleConfirmSelection(event) {
|
||||
const row = requestedReturns.value[Number(event.detail.value)];
|
||||
selectReturnRow(row);
|
||||
}
|
||||
|
||||
function selectReturnRow(row) {
|
||||
if (!row || row.status !== "REQUESTED" || confirmPending.value) return;
|
||||
resetConfirmOperation();
|
||||
const session = confirmSession;
|
||||
const generation = ++detailGeneration;
|
||||
confirmForm.returnId = row.returnId;
|
||||
detailLoading.value = true;
|
||||
getReturn(row.returnId)
|
||||
.then((response) => {
|
||||
if (session !== confirmSession || generation !== detailGeneration) return;
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "REQUESTED") {
|
||||
confirmError.value = "该退回单已不是待确认状态";
|
||||
return;
|
||||
}
|
||||
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
}),
|
||||
);
|
||||
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId);
|
||||
if (!confirmExpectedPalletIds.value.length) confirmError.value = "该退回单没有托盘明细";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session === confirmSession && generation === detailGeneration) {
|
||||
confirmError.value = errorMessage(error, "退回单详情加载失败,请稍后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session === confirmSession && generation === detailGeneration) {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmScan(qrToken) {
|
||||
if (!confirmForm.returnId || !confirmExpectedPalletIds.value.length || confirmPending.value || scanLoading.confirm) return;
|
||||
const session = confirmSession;
|
||||
const generation = ++confirmScanGeneration;
|
||||
scanLoading.confirm = true;
|
||||
confirmError.value = "";
|
||||
scanReturnPallet(qrToken, { mode: "confirm", returnId: confirmForm.returnId })
|
||||
.then((response) => {
|
||||
if (session !== confirmSession || generation !== confirmScanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
|
||||
confirmError.value = "该托盘不属于所选退回单";
|
||||
return;
|
||||
}
|
||||
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已完成扫码");
|
||||
return;
|
||||
}
|
||||
confirmScannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
});
|
||||
confirmIdempotencyKey.value = "";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||
confirmError.value = errorMessage(error, "到仓托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||
scanLoading.confirm = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmScanInvalid(message) {
|
||||
confirmError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function isConfirmPalletScanned(palletId) {
|
||||
return confirmScannedPallets.value.some((item) => item.palletId === palletId);
|
||||
}
|
||||
|
||||
function sameIdSet(left, right) {
|
||||
if (left.length !== right.length) return false;
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return (
|
||||
leftSet.size === left.length &&
|
||||
rightSet.size === right.length &&
|
||||
right.every((id) => leftSet.has(id))
|
||||
);
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(returnId) {
|
||||
if (!confirmIdempotencyKey.value) confirmIdempotencyKey.value = createIdempotencyKey();
|
||||
return confirmIdempotencyKey.value;
|
||||
}
|
||||
|
||||
async function submitReturnConfirm() {
|
||||
if (confirmPending.value || detailLoading.value || scanLoading.confirm) return;
|
||||
const returnId = confirmForm.returnId;
|
||||
const expectedIds = [...confirmExpectedPalletIds.value];
|
||||
const scannedIds = confirmScannedPallets.value.map((item) => item.palletId);
|
||||
if (!returnId || !sameIdSet(expectedIds, scannedIds)) {
|
||||
confirmError.value = "必须逐个扫描并完整匹配退回单全部托盘";
|
||||
return;
|
||||
}
|
||||
confirmSubmitLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
const latest = (await getReturn(returnId)).data;
|
||||
const latestIds = (Array.isArray(latest.items) ? latest.items : []).map((item) => item.palletId);
|
||||
if (latest.status !== "REQUESTED" || !sameIdSet(latestIds, scannedIds)) {
|
||||
confirmError.value = "退回单状态或托盘明细已变化,请重新选择并扫码";
|
||||
return;
|
||||
}
|
||||
await confirmReturn(returnId, {
|
||||
scannedPalletIds: scannedIds,
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(returnId),
|
||||
});
|
||||
confirmIdempotencyKey.value = "";
|
||||
proxy.$modal.msgSuccess("退回确认成功");
|
||||
resetConfirmOperation();
|
||||
loadReturnList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "退回确认失败")};直接重试将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmSubmitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadReturnList();
|
||||
loadWarehouseOptions();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">{{ activeTab === 'receipt' ? '收货核对' : '发货出库' }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ activeTab === 'receipt' ? '扫描原托盘标签,逐托核对目标现场收货' : '选择来源仓库和目标现场,扫码创建整托发货单' }}
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="canDraft && activeTab === 'shipment'"
|
||||
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startNewDraft"
|
||||
>
|
||||
新建草稿
|
||||
</button>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
<view v-if="canReceive" class="flex gap-3">
|
||||
<button v-if="canShipment" size="mini" :class="activeTab === 'shipment' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'shipment'">发货出库</button>
|
||||
<button size="mini" :disabled="operationPending || editorVisible" :class="activeTab === 'receipt' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'receipt'">收货核对</button>
|
||||
</view>
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有发货或收货权限</text>
|
||||
</view>
|
||||
|
||||
<ShipmentReceiptPanel v-else-if="canReceive && activeTab === 'receipt'" @received="loadShipmentList" />
|
||||
<template v-else-if="canShipment && activeTab === 'shipment'">
|
||||
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-4 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">
|
||||
{{ editing ? "编辑发货草稿" : "新建发货草稿" }}
|
||||
</text>
|
||||
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">来源仓库</text>
|
||||
<picker
|
||||
:range="warehouseOptions"
|
||||
range-key="name"
|
||||
:value="warehouseIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleWarehouseChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedWarehouseName || "请选择来源仓库" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">目标客户现场</text>
|
||||
<picker
|
||||
:range="siteOptions"
|
||||
range-key="name"
|
||||
:value="siteIndex"
|
||||
:disabled="optionLoading || detailLoading || operationPending"
|
||||
@change="handleSiteChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedSiteName || "请选择目标现场" }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||
<PalletScanInput
|
||||
:disabled="!form.sourceWarehouseId || !form.targetSiteId || detailLoading || operationPending"
|
||||
:loading="scanLoading"
|
||||
@scan="handleShipmentScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受来源仓库内有剩余包数的 FILLED_IN_WAREHOUSE 托盘。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||
|
||||
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||
<view
|
||||
v-for="pallet in scannedPallets"
|
||||
:key="pallet.palletId"
|
||||
class="rounded-xl bg-orange-50 px-3 py-2"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<view>
|
||||
<text class="block text-sm font-medium text-gray-700">
|
||||
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||
</text>
|
||||
<text class="block text-xs text-gray-500">
|
||||
{{ pallet.strainName || pallet.strainCode || "未提供菌种" }} · 剩余 {{ pallet.remainingPackages }} 包
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
class="text-xs text-red-500"
|
||||
size="mini"
|
||||
:disabled="operationPending || detailLoading"
|
||||
@click="removeScannedPallet(pallet.palletId)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-400">
|
||||
批次:{{ pallet.batchNo || "—" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||
@click="saveDraft"
|
||||
>
|
||||
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||
:disabled="operationPending"
|
||||
@click="cancelEditor"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">发货单</text>
|
||||
<button
|
||||
class="text-xs text-blue-500"
|
||||
size="mini"
|
||||
:disabled="loading || operationPending"
|
||||
@click="loadShipmentList"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!shipmentList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无发货单
|
||||
</view>
|
||||
<view
|
||||
v-for="row in shipmentList"
|
||||
v-else
|
||||
:key="row.shipmentId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
|
||||
<text :class="row.status === 'DRAFT' || row.receiptState === 'PENDING' ? 'text-orange-500' : row.receiptState === 'LEGACY' ? 'text-gray-500' : 'text-green-500'" class="text-xs">
|
||||
{{ shipmentStatusLabel(row) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ warehouseName(row.sourceWarehouseId) }} → {{ siteName(row.targetSiteId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||
</text>
|
||||
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="startEditDraft(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canConfirm"
|
||||
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||
size="mini"
|
||||
:disabled="operationPending"
|
||||
@click="confirmDraft(row)"
|
||||
>
|
||||
{{ confirmLoading ? "确认中" : "确认" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import ShipmentReceiptPanel from "@/components/business/ShipmentReceiptPanel.vue";
|
||||
import {
|
||||
addShipment,
|
||||
confirmShipment,
|
||||
getShipment,
|
||||
getShipmentSiteOptions,
|
||||
getShipmentWarehouseOptions,
|
||||
listShipment,
|
||||
scanShipmentPallet,
|
||||
updateShipment,
|
||||
} from "@/api/business/shipment";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const shipmentList = ref([]);
|
||||
const warehouseOptions = ref([]);
|
||||
const siteOptions = ref([]);
|
||||
const scannedPallets = ref([]);
|
||||
const editorVisible = ref(false);
|
||||
const editing = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const confirmLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const formError = ref("");
|
||||
const scanError = ref("");
|
||||
const confirmError = ref("");
|
||||
const form = reactive({ shipmentId: null, sourceWarehouseId: null, targetSiteId: null });
|
||||
const confirmState = reactive({ shipmentId: null, key: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
|
||||
const canShipment = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:shipment:list",
|
||||
"business:shipment:query",
|
||||
"business:shipment:add",
|
||||
"business:shipment:edit",
|
||||
"business:shipment:confirm",
|
||||
]),
|
||||
);
|
||||
const canReceive = computed(() => proxy.$auth.hasPermi("business:shipment:receive"));
|
||||
const canPage = computed(() => canShipment.value || canReceive.value);
|
||||
const activeTab = ref(canShipment.value ? "shipment" : "receipt");
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
|
||||
const canDraft = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
|
||||
);
|
||||
const canEdit = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:edit"]),
|
||||
);
|
||||
const canConfirm = computed(() => proxy.$auth.hasPermi("business:shipment:confirm"));
|
||||
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||
const operationPending = computed(
|
||||
() =>
|
||||
submitLoading.value ||
|
||||
confirmLoading.value ||
|
||||
detailLoading.value,
|
||||
);
|
||||
const warehouseIndex = computed(() => {
|
||||
const index = warehouseOptions.value.findIndex(
|
||||
(item) => item.id === form.sourceWarehouseId,
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const siteIndex = computed(() => {
|
||||
const index = siteOptions.value.findIndex((item) => item.id === form.targetSiteId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedWarehouseName = computed(
|
||||
() => warehouseOptions.value.find((item) => item.id === form.sourceWarehouseId)?.name || "",
|
||||
);
|
||||
const selectedSiteName = computed(
|
||||
() => siteOptions.value.find((item) => item.id === form.targetSiteId)?.name || "",
|
||||
);
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function shipmentStatusLabel(row) {
|
||||
if (row.status === "DRAFT") return "草稿";
|
||||
return { PENDING: "待收货", RECEIVED: "已收货", LEGACY: "历史未登记收货" }[row.receiptState] || "已确认";
|
||||
}
|
||||
|
||||
function warehouseName(id) {
|
||||
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||
}
|
||||
|
||||
function siteName(id) {
|
||||
return siteOptions.value.find((item) => item.id === id)?.name || `现场 #${id || "-"}`;
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
scanGeneration += 1;
|
||||
editorVisible.value = false;
|
||||
editing.value = false;
|
||||
form.shipmentId = null;
|
||||
form.sourceWarehouseId = null;
|
||||
form.targetSiteId = null;
|
||||
scannedPallets.value = [];
|
||||
detailLoading.value = false;
|
||||
scanLoading.value = false;
|
||||
formError.value = "";
|
||||
scanError.value = "";
|
||||
}
|
||||
|
||||
function loadShipmentList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listShipment({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
shipmentList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
shipmentList.value = [];
|
||||
listError.value = errorMessage(error, "发货单列表加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadOptions() {
|
||||
if (!proxy.$auth.hasPermi("business:shipment:query")) return;
|
||||
optionLoading.value = true;
|
||||
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()])
|
||||
.then(([warehouseResponse, siteResponse]) => {
|
||||
warehouseOptions.value = Array.isArray(warehouseResponse.data)
|
||||
? warehouseResponse.data
|
||||
: [];
|
||||
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
warehouseOptions.value = [];
|
||||
siteOptions.value = [];
|
||||
formError.value = errorMessage(error, "仓库或现场选项加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
optionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startNewDraft() {
|
||||
if (!canDraft.value || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
loadOptions();
|
||||
}
|
||||
|
||||
function startEditDraft(row) {
|
||||
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
resetEditor();
|
||||
editorVisible.value = true;
|
||||
editing.value = true;
|
||||
form.shipmentId = row.shipmentId;
|
||||
loadOptions();
|
||||
detailLoading.value = true;
|
||||
getShipment(row.shipmentId)
|
||||
.then((response) => {
|
||||
const detail = response.data;
|
||||
if (!detail || detail.status !== "DRAFT") {
|
||||
formError.value = "该发货单已不是草稿状态,不能编辑";
|
||||
return;
|
||||
}
|
||||
form.sourceWarehouseId = detail.sourceWarehouseId;
|
||||
form.targetSiteId = detail.targetSiteId;
|
||||
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||
(item) => ({
|
||||
palletId: item.palletId,
|
||||
palletCode: item.palletCode || `#${item.palletId}`,
|
||||
strainName: item.strainName || item.strainCode,
|
||||
batchNo: item.batchNo,
|
||||
remainingPackages: item.remainingPackages ?? item.packageQty ?? 0,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "发货单详情加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
detailLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditor() {
|
||||
if (operationPending.value) return;
|
||||
resetEditor();
|
||||
}
|
||||
|
||||
function clearScannedPallets() {
|
||||
scannedPallets.value = [];
|
||||
scanError.value = "";
|
||||
scanGeneration += 1;
|
||||
}
|
||||
|
||||
function handleWarehouseChange(event) {
|
||||
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.sourceWarehouseId) return;
|
||||
form.sourceWarehouseId = option.id;
|
||||
clearScannedPallets();
|
||||
}
|
||||
|
||||
function handleSiteChange(event) {
|
||||
const option = siteOptions.value[Number(event.detail.value)];
|
||||
if (!option || option.id === form.targetSiteId) return;
|
||||
form.targetSiteId = option.id;
|
||||
clearScannedPallets();
|
||||
}
|
||||
|
||||
function handleShipmentScan(qrToken) {
|
||||
if (
|
||||
!form.sourceWarehouseId ||
|
||||
!form.targetSiteId ||
|
||||
operationPending.value ||
|
||||
scanLoading.value
|
||||
) {
|
||||
scanError.value = "请先选择来源仓库和目标现场";
|
||||
return;
|
||||
}
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanError.value = "";
|
||||
scanShipmentPallet(qrToken, {
|
||||
sourceWarehouseId: form.sourceWarehouseId,
|
||||
targetSiteId: form.targetSiteId,
|
||||
})
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration) return;
|
||||
const pallet = response.data;
|
||||
if (pallet?.flowState !== "FILLED_IN_WAREHOUSE" || pallet.remainingPackages <= 0) {
|
||||
scanError.value = "该托盘不是来源仓库内的有料托盘,不能发货";
|
||||
return;
|
||||
}
|
||||
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||
proxy.$modal.msg("该托盘已在当前发货草稿中");
|
||||
return;
|
||||
}
|
||||
scannedPallets.value.push({
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
strainName: pallet.strainName || pallet.strainCode,
|
||||
batchNo: pallet.batchNo,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration) {
|
||||
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration) scanLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
scanError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function removeScannedPallet(palletId) {
|
||||
if (operationPending.value) return;
|
||||
scannedPallets.value = scannedPallets.value.filter(
|
||||
(item) => item.palletId !== palletId,
|
||||
);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||
if (!form.sourceWarehouseId || !form.targetSiteId) {
|
||||
formError.value = "请选择来源仓库和目标现场";
|
||||
return;
|
||||
}
|
||||
if (!scannedPallets.value.length) {
|
||||
formError.value = "请至少扫描一个有料托盘";
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
formError.value = "";
|
||||
const payload = {
|
||||
sourceWarehouseId: form.sourceWarehouseId,
|
||||
targetSiteId: form.targetSiteId,
|
||||
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||
};
|
||||
const submitRequest = editing.value
|
||||
? updateShipment({ ...payload, shipmentId: form.shipmentId })
|
||||
: addShipment(payload);
|
||||
submitRequest
|
||||
.then((response) => {
|
||||
proxy.$modal.msgSuccess(response.msg || "发货草稿保存成功");
|
||||
resetEditor();
|
||||
loadShipmentList();
|
||||
})
|
||||
.catch((error) => {
|
||||
formError.value = errorMessage(error, "发货草稿保存失败,请根据提示修正后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirm(content) {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "系统提示",
|
||||
content,
|
||||
cancelText: "取消",
|
||||
confirmText: "确定",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureConfirmIdempotencyKey(shipmentId) {
|
||||
if (confirmState.shipmentId !== shipmentId) {
|
||||
confirmState.shipmentId = shipmentId;
|
||||
confirmState.key = "";
|
||||
}
|
||||
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||
return confirmState.key;
|
||||
}
|
||||
|
||||
function clearConfirmIdempotency() {
|
||||
confirmState.shipmentId = null;
|
||||
confirmState.key = "";
|
||||
}
|
||||
|
||||
async function confirmDraft(row) {
|
||||
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||
if (!(await askConfirm(`确认发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`))) return;
|
||||
confirmLoading.value = true;
|
||||
confirmError.value = "";
|
||||
try {
|
||||
await confirmShipment(row.shipmentId, {
|
||||
idempotencyKey: ensureConfirmIdempotencyKey(row.shipmentId),
|
||||
});
|
||||
clearConfirmIdempotency();
|
||||
proxy.$modal.msgSuccess("发货确认成功");
|
||||
loadShipmentList();
|
||||
} catch (error) {
|
||||
confirmError.value = `${errorMessage(error, "发货确认失败")};再次确认将复用本次幂等键`;
|
||||
} finally {
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadShipmentList();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,820 @@
|
||||
<template>
|
||||
<view class="min-h-screen bg-gray-50">
|
||||
<scroll-view scroll-y class="h-screen">
|
||||
<view class="space-y-4 p-4 pb-24">
|
||||
<view>
|
||||
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
扫描现场托盘,确认入隧后记录实际使用包数,产线可选
|
||||
</text>
|
||||
</view>
|
||||
<TestBatchBanner />
|
||||
|
||||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
|
||||
<text class="block font-bold text-gray-800">提交结果核对</text>
|
||||
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
|
||||
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
|
||||
</text>
|
||||
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
|
||||
<text class="block text-sm text-gray-700">
|
||||
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
|
||||
</text>
|
||||
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
|
||||
恢复重试 / 核对
|
||||
</button>
|
||||
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
|
||||
已人工核对,解除待确认
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
|
||||
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
|
||||
</view>
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">扫描待使用托盘</text>
|
||||
<button class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
|
||||
重新扫描
|
||||
</button>
|
||||
</view>
|
||||
<PalletScanInput
|
||||
:disabled="operationPending || optionLoading"
|
||||
:loading="scanLoading"
|
||||
@scan="handleUsageScan"
|
||||
@invalid="handleScanInvalid"
|
||||
@duplicate="handleScanDuplicate"
|
||||
/>
|
||||
<text class="mt-2 block text-xs text-gray-400">
|
||||
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
|
||||
</text>
|
||||
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
|
||||
|
||||
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="font-medium text-gray-700">
|
||||
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||
</text>
|
||||
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
|
||||
</view>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
生产日期:{{ scannedPallet.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="text-sm text-gray-700">先到先用建议</text>
|
||||
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
|
||||
刷新建议
|
||||
</button>
|
||||
</view>
|
||||
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">托盘入隧</text>
|
||||
<view v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||
{{ selectedTunnelName }}(已入隧,不可切换)
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="tunnelPickerOptions"
|
||||
range-key="label"
|
||||
:value="tunnelIndex"
|
||||
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
|
||||
@change="handleTunnelChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedTunnelName || "请选择使用隧道" }}
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
|
||||
先确认入隧,再提交使用包数。
|
||||
</text>
|
||||
<view class="mt-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
|
||||
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
|
||||
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
|
||||
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
|
||||
</view>
|
||||
<button
|
||||
v-if="!tunnelLocked"
|
||||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
|
||||
@click="enterTunnel"
|
||||
>
|
||||
{{ entryLoading ? "入隧中" : "确认入隧" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<text class="mb-3 block font-bold text-gray-800">使用信息</text>
|
||||
<view class="mb-3">
|
||||
<text class="mb-2 block text-xs text-gray-500">使用产线(可选)</text>
|
||||
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||
{{ selectedLineName }}(已由托盘绑定)
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="linePickerOptions"
|
||||
range-key="name"
|
||||
:value="lineIndex"
|
||||
:disabled="operationPending || lineOptionLoading || !scannedPallet"
|
||||
@change="handleLineChange"
|
||||
>
|
||||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||
{{ selectedLineName || "不选择产线" }}
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
|
||||
<input
|
||||
v-model="form.usedPackages"
|
||||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||
type="number"
|
||||
placeholder="请输入正整数"
|
||||
:disabled="operationPending || !scannedPallet"
|
||||
@input="markPayloadChanged"
|
||||
/>
|
||||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
|
||||
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
|
||||
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
|
||||
<text class="mt-1 block text-xs text-gray-600">
|
||||
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
|
||||
</text>
|
||||
</view>
|
||||
<button
|
||||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
|
||||
@click="submitUsage"
|
||||
>
|
||||
{{ submitLoading ? "提交中" : "确认使用" }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<view class="mb-3 flex items-center justify-between">
|
||||
<text class="font-bold text-gray-800">最近使用记录</text>
|
||||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
|
||||
暂无使用记录
|
||||
</view>
|
||||
<view
|
||||
v-for="row in usageList"
|
||||
v-else
|
||||
:key="row.usageId"
|
||||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||
>
|
||||
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">
|
||||
托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
|
||||
</text>
|
||||
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
|
||||
产线 #{{ row.lineId }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-400">
|
||||
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
|
||||
</text>
|
||||
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||||
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||||
import {
|
||||
addUsage,
|
||||
enterUsageTunnel,
|
||||
getUsageFifoRecommendation,
|
||||
getUsageLineOptions,
|
||||
getUsageTunnelOptions,
|
||||
listUsage,
|
||||
scanUsagePallet,
|
||||
} from "@/api/business/usage";
|
||||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||
import config from "@/config";
|
||||
import constant from "@/utils/constant";
|
||||
import storage from "@/utils/storage";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const usageList = ref([]);
|
||||
const lineOptions = ref([]);
|
||||
const tunnelOptions = ref([]);
|
||||
const scannedPallet = ref(null);
|
||||
const usageResult = ref(null);
|
||||
const lineLocked = ref(false);
|
||||
const tunnelLocked = ref(false);
|
||||
const loading = ref(false);
|
||||
const optionLoading = ref(false);
|
||||
const lineOptionLoading = ref(false);
|
||||
const lineOptionError = ref("");
|
||||
const fifoLoading = ref(false);
|
||||
const fifoMessage = ref("");
|
||||
const fifoRequiresSkipReason = ref(false);
|
||||
const scanLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const entryLoading = ref(false);
|
||||
const listError = ref("");
|
||||
const operationError = ref("");
|
||||
const submitError = ref("");
|
||||
const pendingOperations = ref([]);
|
||||
const recoveryStorageKey = ref("");
|
||||
const recoveryLoading = ref(false);
|
||||
const recoveryError = ref("");
|
||||
const recoveryMessage = ref("");
|
||||
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
|
||||
let listGeneration = 0;
|
||||
let scanGeneration = 0;
|
||||
let optionGeneration = 0;
|
||||
let fifoGeneration = 0;
|
||||
let operationSession = 0;
|
||||
|
||||
const canPage = computed(() =>
|
||||
proxy.$auth.hasPermiOr([
|
||||
"business:usage:list",
|
||||
"business:usage:query",
|
||||
"business:usage:add",
|
||||
]),
|
||||
);
|
||||
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
|
||||
const canOperate = computed(() =>
|
||||
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
|
||||
);
|
||||
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
|
||||
const linePickerOptions = computed(() => [
|
||||
{ id: null, name: "不选择产线" },
|
||||
...lineOptions.value,
|
||||
]);
|
||||
const lineIndex = computed(() => {
|
||||
const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedLineName = computed(
|
||||
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
|
||||
);
|
||||
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
|
||||
...item,
|
||||
label: formatTunnelName(item),
|
||||
})));
|
||||
const tunnelIndex = computed(() => {
|
||||
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
const selectedTunnelName = computed(() => {
|
||||
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
|
||||
if (option) return formatTunnelName(option);
|
||||
if (!form.tunnelId) return "";
|
||||
return formatTunnelName({
|
||||
id: form.tunnelId,
|
||||
name: scannedPallet.value?.tunnelName,
|
||||
code: scannedPallet.value?.tunnelCode,
|
||||
});
|
||||
});
|
||||
|
||||
function formatTunnelName(option) {
|
||||
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
|
||||
}
|
||||
|
||||
function errorMessage(error, fallback) {
|
||||
return error?.response?.data?.msg || error?.message || fallback;
|
||||
}
|
||||
|
||||
function currentRecoveryStorageKey() {
|
||||
const userId = storage.get(constant.id);
|
||||
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
|
||||
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
|
||||
}
|
||||
|
||||
function loadPendingOperations() {
|
||||
try {
|
||||
const key = currentRecoveryStorageKey();
|
||||
const records = uni.getStorageSync(key) || [];
|
||||
if (!Array.isArray(records) || records.some((item) =>
|
||||
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
|
||||
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
|
||||
recoveryStorageKey.value = key;
|
||||
pendingOperations.value = records;
|
||||
recoveryError.value = "";
|
||||
return true;
|
||||
} catch (error) {
|
||||
pendingOperations.value = [];
|
||||
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function savePendingOperations(records) {
|
||||
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
|
||||
uni.setStorageSync(recoveryStorageKey.value, records);
|
||||
pendingOperations.value = records;
|
||||
}
|
||||
|
||||
function pendingForPallet() {
|
||||
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
|
||||
}
|
||||
|
||||
function operationSignature(action, payload) {
|
||||
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
|
||||
}
|
||||
|
||||
function prepareOperation(action, payload) {
|
||||
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||||
const pending = pendingForPallet();
|
||||
if (pending) {
|
||||
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
|
||||
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
const operation = {
|
||||
action,
|
||||
palletId: scannedPallet.value.palletId,
|
||||
palletCode: scannedPallet.value.palletCode,
|
||||
tunnelName: selectedTunnelName.value,
|
||||
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
|
||||
};
|
||||
savePendingOperations([...pendingOperations.value, operation]);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function completeOperation(operation, response) {
|
||||
const result = response?.data;
|
||||
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
|
||||
const confirmed = result && (operation.action === "usage"
|
||||
? Number.isInteger(result.usageId) && result.usageId > 0 &&
|
||||
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
|
||||
typeof result.flowState === "string" && result.flowState.trim()
|
||||
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
|
||||
typeof result.traceNo === "string" && result.traceNo.trim());
|
||||
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
|
||||
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||||
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCurrentOperation(session, scopeKey) {
|
||||
try {
|
||||
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validatedFifoSkipReason() {
|
||||
const reason = form.fifoSkipReason.trim();
|
||||
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
|
||||
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
|
||||
return reason;
|
||||
}
|
||||
|
||||
function handleFifoRejection(error, operation) {
|
||||
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
|
||||
const response = error?.response;
|
||||
const body = response?.data && typeof response.data === "object" ? response.data : response;
|
||||
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
|
||||
try {
|
||||
if (!loadPendingOperations()) return true;
|
||||
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
|
||||
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
|
||||
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
|
||||
return true;
|
||||
}
|
||||
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
|
||||
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||
} catch (storageError) {
|
||||
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
|
||||
return true;
|
||||
}
|
||||
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
|
||||
fifoRequiresSkipReason.value = true;
|
||||
loadFifoRecommendation();
|
||||
} else {
|
||||
recoveryLoading.value = false;
|
||||
handleUsageScan(operation.payload.qrToken, operation);
|
||||
}
|
||||
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
|
||||
submitError.value = operationError.value;
|
||||
recoveryMessage.value = operationError.value;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function releasePendingOperation(item) {
|
||||
if (operationPending.value) return;
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
|
||||
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||||
if (!operation) return;
|
||||
recoveryLoading.value = true;
|
||||
try {
|
||||
const confirmed = await new Promise((resolve, reject) => {
|
||||
uni.showModal({
|
||||
title: "解除本机待确认记录",
|
||||
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
|
||||
cancelText: "取消",
|
||||
confirmText: "确认解除",
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
|
||||
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
|
||||
if (!loadPendingOperations()) return;
|
||||
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||||
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
|
||||
resetUsageOperation();
|
||||
}
|
||||
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPendingOperation(item) {
|
||||
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
|
||||
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||||
if (!operation) return;
|
||||
resetUsageOperation();
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
recoveryLoading.value = true;
|
||||
recoveryMessage.value = "";
|
||||
try {
|
||||
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
const result = completeOperation(operation, response);
|
||||
recoveryMessage.value = operation.action === "usage"
|
||||
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
|
||||
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
|
||||
loadUsageList();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
if (handleFifoRejection(error, operation)) return;
|
||||
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadUsageList() {
|
||||
if (!canList.value) return;
|
||||
const generation = ++listGeneration;
|
||||
loading.value = true;
|
||||
listError.value = "";
|
||||
listUsage({ pageNum: 1, pageSize: 20 })
|
||||
.then((response) => {
|
||||
if (generation !== listGeneration) return;
|
||||
usageList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation !== listGeneration) return;
|
||||
usageList.value = [];
|
||||
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === listGeneration) loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetUsageOperation() {
|
||||
operationSession += 1;
|
||||
scanGeneration += 1;
|
||||
optionGeneration += 1;
|
||||
fifoGeneration += 1;
|
||||
scannedPallet.value = null;
|
||||
usageResult.value = null;
|
||||
lineOptions.value = [];
|
||||
tunnelOptions.value = [];
|
||||
lineLocked.value = false;
|
||||
tunnelLocked.value = false;
|
||||
form.qrToken = "";
|
||||
form.tunnelId = null;
|
||||
form.lineId = null;
|
||||
form.usedPackages = "";
|
||||
form.fifoSkipReason = "";
|
||||
operationError.value = "";
|
||||
submitError.value = "";
|
||||
scanLoading.value = false;
|
||||
optionLoading.value = false;
|
||||
lineOptionLoading.value = false;
|
||||
lineOptionError.value = "";
|
||||
fifoLoading.value = false;
|
||||
fifoMessage.value = "";
|
||||
fifoRequiresSkipReason.value = false;
|
||||
submitLoading.value = false;
|
||||
entryLoading.value = false;
|
||||
recoveryLoading.value = false;
|
||||
}
|
||||
|
||||
function loadUsageOptions(pallet, session, scanRequestGeneration) {
|
||||
const generation = ++optionGeneration;
|
||||
const isCurrent = () => generation === optionGeneration &&
|
||||
session === operationSession && scanRequestGeneration === scanGeneration;
|
||||
optionLoading.value = true;
|
||||
lineOptionLoading.value = true;
|
||||
const lineRequest = pallet.currentLineId == null
|
||||
? getUsageLineOptions(pallet.siteId)
|
||||
: Promise.resolve({ data: lineOptions.value });
|
||||
getUsageTunnelOptions(pallet.siteId)
|
||||
.then((tunnels) => {
|
||||
if (!isCurrent()) return;
|
||||
tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
|
||||
if (!tunnelLocked.value && !tunnelOptions.value.length) {
|
||||
operationError.value = "该现场暂无可用隧道";
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isCurrent()) return;
|
||||
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) optionLoading.value = false;
|
||||
});
|
||||
lineRequest
|
||||
.then((lines) => {
|
||||
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isCurrent()) return;
|
||||
lineOptions.value = [];
|
||||
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) lineOptionLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFifoRecommendation() {
|
||||
if (!scannedPallet.value || !form.qrToken) return;
|
||||
const generation = ++fifoGeneration;
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
const { qrToken, tunnelId } = form;
|
||||
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
|
||||
qrToken === form.qrToken && tunnelId === form.tunnelId;
|
||||
fifoLoading.value = true;
|
||||
fifoMessage.value = "";
|
||||
try {
|
||||
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
|
||||
if (!isCurrent()) return;
|
||||
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
|
||||
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
|
||||
} catch {
|
||||
if (!isCurrent()) return;
|
||||
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
|
||||
} finally {
|
||||
if (isCurrent()) fifoLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUsageScan(qrToken, rejectedOperation = null) {
|
||||
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||
resetUsageOperation();
|
||||
if (!loadPendingOperations()) return;
|
||||
const session = operationSession;
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
const generation = ++scanGeneration;
|
||||
scanLoading.value = true;
|
||||
scanUsagePallet(qrToken)
|
||||
.then((response) => {
|
||||
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
|
||||
const pallet = response.data;
|
||||
if (
|
||||
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
|
||||
pallet.remainingPackages <= 0
|
||||
) {
|
||||
operationError.value = "该托盘当前不可记录使用";
|
||||
return;
|
||||
}
|
||||
scannedPallet.value = {
|
||||
palletId: pallet.palletId,
|
||||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||
siteId: pallet.siteId,
|
||||
currentLineId: pallet.currentLineId,
|
||||
tunnelId: pallet.tunnelId,
|
||||
tunnelName: pallet.tunnelName,
|
||||
tunnelCode: pallet.tunnelCode,
|
||||
strainCode: pallet.strainCode,
|
||||
strainName: pallet.strainName,
|
||||
batchNo: pallet.batchNo,
|
||||
productionDate: pallet.productionDate,
|
||||
expiryDate: pallet.expiryDate,
|
||||
remainingPackages: pallet.remainingPackages,
|
||||
flowState: pallet.flowState,
|
||||
};
|
||||
form.qrToken = pallet.qrToken || qrToken;
|
||||
form.tunnelId = pallet.tunnelId ?? null;
|
||||
tunnelLocked.value = pallet.tunnelId != null;
|
||||
if (rejectedOperation) {
|
||||
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
|
||||
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
|
||||
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
|
||||
fifoRequiresSkipReason.value = true;
|
||||
}
|
||||
if (pallet.currentLineId != null) {
|
||||
lineLocked.value = true;
|
||||
form.lineId = pallet.currentLineId;
|
||||
lineOptions.value = [
|
||||
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
|
||||
];
|
||||
} else {
|
||||
lineLocked.value = false;
|
||||
form.lineId = rejectedOperation?.payload.lineId ?? null;
|
||||
}
|
||||
loadUsageOptions(pallet, session, generation);
|
||||
loadFifoRecommendation();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||||
scanLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanInvalid(message) {
|
||||
resetUsageOperation();
|
||||
operationError.value = message;
|
||||
}
|
||||
|
||||
function handleScanDuplicate() {
|
||||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||
}
|
||||
|
||||
function handleLineChange(event) {
|
||||
if (lineLocked.value || operationPending.value) return;
|
||||
const option = linePickerOptions.value[Number(event.detail.value)];
|
||||
form.lineId = option?.id ?? null;
|
||||
markPayloadChanged();
|
||||
}
|
||||
|
||||
function handleTunnelChange(event) {
|
||||
if (tunnelLocked.value || operationPending.value) return;
|
||||
const option = tunnelOptions.value[Number(event.detail.value)];
|
||||
if (form.tunnelId === (option?.id ?? null)) return;
|
||||
form.tunnelId = option?.id ?? null;
|
||||
markPayloadChanged();
|
||||
fifoRequiresSkipReason.value = false;
|
||||
loadFifoRecommendation();
|
||||
}
|
||||
|
||||
function markPayloadChanged() {
|
||||
if (operationPending.value) return;
|
||||
submitError.value = "";
|
||||
}
|
||||
|
||||
async function enterTunnel() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
|
||||
if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
|
||||
operationError.value = "请先扫描托盘并选择隧道";
|
||||
return;
|
||||
}
|
||||
let fifoSkipReason;
|
||||
try {
|
||||
fifoSkipReason = validatedFifoSkipReason();
|
||||
} catch (error) {
|
||||
operationError.value = error.message;
|
||||
return;
|
||||
}
|
||||
const session = operationSession;
|
||||
entryLoading.value = true;
|
||||
operationError.value = "";
|
||||
let scopeKey = recoveryStorageKey.value;
|
||||
let operation;
|
||||
try {
|
||||
operation = prepareOperation("entry", {
|
||||
qrToken: form.qrToken,
|
||||
tunnelId: form.tunnelId,
|
||||
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||||
});
|
||||
scopeKey = recoveryStorageKey.value;
|
||||
const response = await enterUsageTunnel({ ...operation.payload });
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
const result = completeOperation(operation, response);
|
||||
form.tunnelId = result.tunnelId;
|
||||
scannedPallet.value.tunnelId = result.tunnelId;
|
||||
tunnelLocked.value = true;
|
||||
submitError.value = "";
|
||||
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
|
||||
loadFifoRecommendation();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
if (handleFifoRejection(error, operation)) return;
|
||||
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUsage() {
|
||||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
|
||||
const usedPackages = Number(form.usedPackages);
|
||||
if (!scannedPallet.value || !form.qrToken) {
|
||||
submitError.value = "请先扫描待使用托盘";
|
||||
return;
|
||||
}
|
||||
if (!tunnelLocked.value || !form.tunnelId) {
|
||||
submitError.value = "请先选择隧道并确认入隧";
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||||
submitError.value = "使用包数必须为正整数";
|
||||
return;
|
||||
}
|
||||
let fifoSkipReason;
|
||||
try {
|
||||
fifoSkipReason = validatedFifoSkipReason();
|
||||
} catch (error) {
|
||||
submitError.value = error.message;
|
||||
return;
|
||||
}
|
||||
if (!loadPendingOperations()) {
|
||||
submitError.value = recoveryError.value;
|
||||
return;
|
||||
}
|
||||
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
|
||||
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
|
||||
submitError.value = "使用包数不能超过当前剩余包数";
|
||||
return;
|
||||
}
|
||||
const session = operationSession;
|
||||
submitLoading.value = true;
|
||||
submitError.value = "";
|
||||
const scopeKey = recoveryStorageKey.value;
|
||||
let operation;
|
||||
try {
|
||||
operation = prepareOperation("usage", {
|
||||
qrToken: form.qrToken,
|
||||
tunnelId: form.tunnelId,
|
||||
lineId: form.lineId,
|
||||
usedPackages,
|
||||
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||||
});
|
||||
const response = await addUsage({ ...operation.payload });
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
usageResult.value = completeOperation(operation, response);
|
||||
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
|
||||
loadUsageList();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation(session, scopeKey)) return;
|
||||
if (handleFifoRejection(error, operation)) return;
|
||||
submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
|
||||
} finally {
|
||||
if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function leaveUsagePage() {
|
||||
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
|
||||
resetUsageOperation();
|
||||
listGeneration += 1;
|
||||
loading.value = false;
|
||||
usageList.value = [];
|
||||
listError.value = "";
|
||||
recoveryMessage.value = "";
|
||||
}
|
||||
|
||||
onHide(leaveUsagePage);
|
||||
onUnload(leaveUsagePage);
|
||||
onShow(() => {
|
||||
loadPendingOperations();
|
||||
loadUsageList();
|
||||
});
|
||||
loadPendingOperations();
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
>1. 服务条款的确认和接纳</text
|
||||
>
|
||||
<text
|
||||
>欢迎使用ShiErFeng-FastAPI移动端框架(以下简称“本产品”)。本产品的服务所有权和运作权归ShiErFeng-FastAPI所有。用户在使用本产品前,请仔细阅读本协议。当您点击“同意”或实际使用本产品服务时,即视为您已阅读并完全同意本协议的所有条款。</text
|
||||
>欢迎使用智链云枢管理系统(以下简称“本产品”)。本产品的服务所有权和运作权归智链云枢管理系统所有。用户在使用本产品前,请仔细阅读本协议。当您点击“同意”或实际使用本产品服务时,即视为您已阅读并完全同意本协议的所有条款。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<view>
|
||||
<text class="mb-2 block font-bold text-gray-800">5. 知识产权声明</text>
|
||||
<text
|
||||
>本产品包含的所有内容(包括但不限于文字、图片、音频、视频、软件代码等)的知识产权归ShiErFeng-FastAPI或相关权利人所有。未经授权,任何人不得擅自使用、复制、修改或传播。</text
|
||||
>本产品包含的所有内容(包括但不限于文字、图片、音频、视频、软件代码等)的知识产权归智链云枢管理系统或相关权利人所有。未经授权,任何人不得擅自使用、复制、修改或传播。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<view class="space-y-6 text-justify text-sm leading-relaxed text-gray-600">
|
||||
<view>
|
||||
<text
|
||||
>ShiErFeng-FastAPI非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用本产品前仔细阅读本政策。</text
|
||||
>智链云枢管理系统非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用本产品前仔细阅读本政策。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,94 @@
|
||||
<template>
|
||||
<view
|
||||
class="flex h-full flex-col items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-6 pb-20 overflow-hidden"
|
||||
>
|
||||
<!-- Logo Section -->
|
||||
<view class="mb-6 flex flex-col items-center">
|
||||
<view
|
||||
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
|
||||
>
|
||||
<image
|
||||
class="size-10"
|
||||
:src="globalConfig.appInfo.logo"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||
>ShiErFeng-FastAPI移动端登录</text
|
||||
>
|
||||
</view>
|
||||
<view class="login-page">
|
||||
<image
|
||||
class="login-background"
|
||||
src="/static/login-background.jpg"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="login-scrim" />
|
||||
|
||||
<!-- Form Section -->
|
||||
<view class="w-full rounded-3xl bg-white/80 p-6 shadow-xl backdrop-blur-md">
|
||||
<!-- Username -->
|
||||
<view class="group relative mb-5">
|
||||
<view
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||
>
|
||||
<view class="i-mdi-account text-xl"></view>
|
||||
</view>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
class="h-12 w-full rounded-xl bg-gray-50 pl-12 pr-4 text-sm text-gray-700 outline-none transition-all focus:bg-white focus:ring-2 focus:ring-blue-400"
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
maxlength="30"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Password -->
|
||||
<view class="group relative mb-5">
|
||||
<view
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||
>
|
||||
<view class="i-mdi-lock text-xl"></view>
|
||||
</view>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
class="h-12 w-full rounded-xl bg-gray-50 pl-12 pr-4 text-sm text-gray-700 outline-none transition-all focus:bg-white focus:ring-2 focus:ring-blue-400"
|
||||
placeholder="请输入密码"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Captcha -->
|
||||
<view
|
||||
class="mb-8 flex items-center justify-between"
|
||||
v-if="captchaEnabled"
|
||||
>
|
||||
<view class="group relative mr-3 flex-1">
|
||||
<view
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||
>
|
||||
<view class="i-mdi-security text-xl"></view>
|
||||
<view class="login-content">
|
||||
<view class="login-card">
|
||||
<view class="brand-lockup">
|
||||
<view class="brand-logo-frame">
|
||||
<image
|
||||
class="brand-logo"
|
||||
:src="globalConfig.appInfo.logo"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<input
|
||||
v-model="loginForm.code"
|
||||
type="number"
|
||||
class="h-12 w-full rounded-xl bg-gray-50 pl-12 pr-4 text-sm text-gray-700 outline-none transition-all focus:bg-white focus:ring-2 focus:ring-blue-400"
|
||||
placeholder="验证码"
|
||||
maxlength="4"
|
||||
/>
|
||||
<text class="brand-title">{{ globalConfig.appInfo.name }}</text>
|
||||
<text class="brand-subtitle">仓储运营管理平台</text>
|
||||
</view>
|
||||
<view
|
||||
class="h-12 w-28 overflow-hidden rounded-xl bg-gray-100 shadow-sm transition-opacity active:opacity-80"
|
||||
@click="getCode"
|
||||
>
|
||||
<image :src="codeUrl" class="size-full object-cover"></image>
|
||||
|
||||
<view class="login-form">
|
||||
<view class="field-control">
|
||||
<view class="field-icon i-mdi-account" />
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
class="field-input"
|
||||
type="text"
|
||||
placeholder="请输入您的账号"
|
||||
maxlength="30"
|
||||
confirm-type="next"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field-control">
|
||||
<view class="field-icon i-mdi-lock" />
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
class="field-input"
|
||||
type="password"
|
||||
placeholder="请输入您的密码"
|
||||
maxlength="20"
|
||||
confirm-type="done"
|
||||
@confirm="handleLogin"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="captchaEnabled" class="captcha-row">
|
||||
<view class="field-control captcha-input">
|
||||
<view class="field-icon i-mdi-security" />
|
||||
<input
|
||||
v-model="loginForm.code"
|
||||
class="field-input"
|
||||
type="number"
|
||||
placeholder="请输入验证码"
|
||||
maxlength="4"
|
||||
confirm-type="done"
|
||||
@confirm="handleLogin"
|
||||
/>
|
||||
</view>
|
||||
<view class="captcha-image-wrap" @click="getCode">
|
||||
<image
|
||||
:src="codeUrl"
|
||||
class="captcha-image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? "登录中..." : "登 录" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
@click="handleLogin"
|
||||
class="flex h-12 w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-indigo-600 text-base font-semibold text-white shadow-lg shadow-blue-500/30 transition-transform active:scale-95"
|
||||
>
|
||||
登 录
|
||||
</button>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<view class="mt-6 flex flex-col items-center space-y-3">
|
||||
<view class="flex items-center text-sm text-gray-500" v-if="register">
|
||||
<view v-if="register" class="register-tip">
|
||||
<text>没有账号?</text>
|
||||
<text
|
||||
@click="handleUserRegister"
|
||||
class="ml-1 font-medium text-blue-600 active:opacity-70"
|
||||
>立即注册</text
|
||||
>
|
||||
<text class="action-link" @click="handleUserRegister">立即注册</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="flex flex-wrap items-center justify-center text-xs text-gray-400"
|
||||
>
|
||||
<view class="agreement-tip">
|
||||
<text>登录即代表同意</text>
|
||||
<text
|
||||
@click="handleUserAgrement"
|
||||
class="mx-1 text-blue-500 active:opacity-70"
|
||||
>《用户协议》</text
|
||||
>
|
||||
<text class="action-link" @click="handleUserAgrement">《用户协议》</text>
|
||||
<text>和</text>
|
||||
<text
|
||||
@click="handlePrivacy"
|
||||
class="mx-1 text-blue-500 active:opacity-70"
|
||||
>《隐私协议》</text
|
||||
>
|
||||
<text class="action-link" @click="handlePrivacy">《隐私协议》</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -129,13 +105,14 @@ import { useConfigStore, useUserStore } from "@/store";
|
||||
const { proxy } = getCurrentInstance();
|
||||
const globalConfig = useConfigStore().config;
|
||||
const codeUrl = ref("");
|
||||
const loading = ref(false);
|
||||
// 验证码开关
|
||||
const captchaEnabled = ref(true);
|
||||
// 用户注册开关
|
||||
const register = ref(false);
|
||||
const loginForm = ref({
|
||||
username: "admin",
|
||||
password: "admin123",
|
||||
password: "",
|
||||
code: "",
|
||||
uuid: "",
|
||||
});
|
||||
@@ -160,6 +137,8 @@ function getCode() {
|
||||
getCodeImg().then((res) => {
|
||||
captchaEnabled.value =
|
||||
res.captchaEnabled === undefined ? true : res.captchaEnabled;
|
||||
register.value =
|
||||
res.registerEnabled === undefined ? false : res.registerEnabled;
|
||||
if (captchaEnabled.value) {
|
||||
codeUrl.value = "data:image/gif;base64," + res.img;
|
||||
loginForm.value.uuid = res.uuid;
|
||||
@@ -176,6 +155,7 @@ async function handleLogin() {
|
||||
} else if (loginForm.value.code === "" && captchaEnabled.value) {
|
||||
proxy.$modal.msgError("请输入验证码");
|
||||
} else {
|
||||
loading.value = true;
|
||||
proxy.$modal.loading("登录中,请耐心等待...");
|
||||
pwdLogin();
|
||||
}
|
||||
@@ -183,25 +163,26 @@ async function handleLogin() {
|
||||
|
||||
// 密码登录
|
||||
async function pwdLogin() {
|
||||
useUserStore()
|
||||
.login(loginForm.value)
|
||||
.then(() => {
|
||||
proxy.$modal.closeLoading();
|
||||
loginSuccess();
|
||||
})
|
||||
.catch(() => {
|
||||
if (captchaEnabled.value) {
|
||||
getCode();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await useUserStore().login(loginForm.value);
|
||||
proxy.$modal.closeLoading();
|
||||
loading.value = false;
|
||||
loginSuccess();
|
||||
} catch (error) {
|
||||
proxy.$modal.closeLoading();
|
||||
loading.value = false;
|
||||
if (captchaEnabled.value) {
|
||||
getCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 登录成功后,处理函数
|
||||
function loginSuccess(result) {
|
||||
function loginSuccess() {
|
||||
// 设置用户信息
|
||||
useUserStore()
|
||||
.getInfo()
|
||||
.then((res) => {
|
||||
.then(() => {
|
||||
proxy.$tab.reLaunch("/pages/index");
|
||||
});
|
||||
}
|
||||
@@ -219,6 +200,239 @@ getCode();
|
||||
|
||||
<style lang="scss" scoped>
|
||||
page {
|
||||
background-color: #ffffff;
|
||||
background-color: #eef8f8;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 48rpx 32rpx;
|
||||
background: #eef8f8;
|
||||
}
|
||||
|
||||
.login-background,
|
||||
.login-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-background {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-scrim {
|
||||
z-index: 1;
|
||||
background: rgba(241, 250, 250, 0.22);
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 720rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.88);
|
||||
border-radius: 20rpx;
|
||||
padding: 56rpx 44rpx 42rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 20rpx 60rpx rgba(40, 34, 92, 0.18);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 46rpx;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-logo-frame {
|
||||
display: flex;
|
||||
width: 400rpx;
|
||||
max-width: 100%;
|
||||
height: 136rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
background: #28225c;
|
||||
box-shadow: 0 10rpx 22rpx rgba(40, 34, 92, 0.16);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin-top: 18rpx;
|
||||
color: #28225c;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 10rpx;
|
||||
color: #6c7786;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 96rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border: 1rpx solid #d9e6e7;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #ffffff;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.field-control:focus-within {
|
||||
border-color: #008596;
|
||||
box-shadow: 0 0 0 4rpx rgba(0, 133, 150, 0.1);
|
||||
}
|
||||
|
||||
.field-icon {
|
||||
flex: 0 0 36rpx;
|
||||
margin-right: 18rpx;
|
||||
color: #008596;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 96rpx;
|
||||
color: #28333f;
|
||||
font-size: 28rpx;
|
||||
line-height: 96rpx;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.captcha-image-wrap {
|
||||
flex: 0 0 220rpx;
|
||||
width: 220rpx;
|
||||
height: 96rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid #d9e6e7;
|
||||
border-radius: 10rpx;
|
||||
background: #f4f8f8;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
margin: 8rpx 0 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: #008596;
|
||||
box-shadow: 0 12rpx 24rpx rgba(0, 133, 150, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 96rpx;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.login-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
opacity: 0.84;
|
||||
transform: translateY(1rpx);
|
||||
}
|
||||
|
||||
.login-button[disabled] {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.register-tip,
|
||||
.agreement-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8791;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register-tip {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.agreement-tip {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 22rpx;
|
||||
color: #9aa5ad;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
margin: 0 6rpx;
|
||||
color: #008596;
|
||||
}
|
||||
|
||||
.action-link:active {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
.login-page {
|
||||
padding: 32rpx 24rpx;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 44rpx 28rpx 34rpx;
|
||||
}
|
||||
|
||||
.brand-logo-frame {
|
||||
width: 360rpx;
|
||||
height: 122rpx;
|
||||
padding: 14rpx 20rpx;
|
||||
}
|
||||
|
||||
.captcha-image-wrap {
|
||||
flex-basis: 190rpx;
|
||||
width: 190rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
<view class="flex h-full flex-col bg-gray-50 pb-10">
|
||||
<!-- Header -->
|
||||
<view class="flex flex-col items-center py-10">
|
||||
<image
|
||||
class="size-20 rounded-xl shadow-md"
|
||||
src="/static/logo200.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="flex h-24 w-64 items-center justify-center rounded-xl bg-gray-800 px-4 shadow-md">
|
||||
<image
|
||||
class="h-20 w-full"
|
||||
src="/static/sylvan-logo.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="mt-4 text-xl font-bold text-gray-800"
|
||||
>ShiErFeng-FastAPI移动端</text
|
||||
>智链云枢管理系统</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -36,22 +36,22 @@ const { proxy } = getCurrentInstance();
|
||||
const list = ref([
|
||||
{
|
||||
icon: "i-mdi-github",
|
||||
title: "ShiErFeng-FastAPI问题",
|
||||
title: "智链云枢管理系统问题",
|
||||
childList: [
|
||||
{
|
||||
title: "ShiErFeng-FastAPI开源吗?",
|
||||
title: "智链云枢管理系统开源吗?",
|
||||
content: "开源",
|
||||
},
|
||||
{
|
||||
title: "ShiErFeng-FastAPI可以商用吗?",
|
||||
title: "智链云枢管理系统可以商用吗?",
|
||||
content: "可以",
|
||||
},
|
||||
{
|
||||
title: "ShiErFeng-FastAPI官网地址多少?",
|
||||
title: "智链云枢管理系统官网地址多少?",
|
||||
content: "https://vfadmin.insistence.tech",
|
||||
},
|
||||
{
|
||||
title: "ShiErFeng-FastAPI文档地址多少?",
|
||||
title: "智链云枢管理系统文档地址多少?",
|
||||
content: "https://vfadmin.insistence.tech",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<!-- Logo Section -->
|
||||
<view class="mb-6 flex flex-col items-center">
|
||||
<view
|
||||
class="mb-4 flex size-16 items-center justify-center rounded-2xl bg-white shadow-lg"
|
||||
class="mb-4 flex h-20 w-60 items-center justify-center rounded-2xl bg-gray-800 px-4 shadow-lg"
|
||||
>
|
||||
<image
|
||||
class="size-10"
|
||||
class="h-16 w-full"
|
||||
:src="globalConfig.appInfo.logo"
|
||||
mode="widthFix"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||
>ShiErFeng-FastAPI移动端注册</text
|
||||
>智链云枢管理系统注册</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -1,199 +1,62 @@
|
||||
<template>
|
||||
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
|
||||
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
|
||||
<view class="p-4 pb-24 space-y-6">
|
||||
<!-- Banner -->
|
||||
<view
|
||||
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
|
||||
>
|
||||
<swiper
|
||||
class="h-40 w-full"
|
||||
:current="swiperDotIndex"
|
||||
@change="changeSwiper"
|
||||
autoplay
|
||||
interval="3000"
|
||||
circular
|
||||
>
|
||||
<swiper-item v-for="(item, index) in data" :key="index">
|
||||
<view class="h-full w-full" @click="clickBannerItem(item)">
|
||||
<image
|
||||
:src="item.image"
|
||||
mode="scaleToFill"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<!-- Custom Dots -->
|
||||
<view
|
||||
class="absolute bottom-3 right-0 left-0 flex justify-center space-x-2 pointer-events-none"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in data"
|
||||
:key="index"
|
||||
class="h-1.5 rounded-full transition-all duration-300"
|
||||
:class="current === index ? 'bg-white w-4' : 'bg-white/50 w-1.5'"
|
||||
>
|
||||
</view>
|
||||
<view class="work-page">
|
||||
<scroll-view
|
||||
class="work-scroll"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view class="work-content">
|
||||
<view class="work-heading">
|
||||
<view class="work-heading-copy">
|
||||
<text class="work-kicker">业务工作台</text>
|
||||
<text class="work-subtitle">
|
||||
根据当前账号权限,快速进入现场业务流程
|
||||
</text>
|
||||
</view>
|
||||
<view class="work-permission-count">
|
||||
<text class="work-permission-value">{{ visibleBusinessEntries.length }}</text>
|
||||
<text class="work-permission-label">项可用</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI Tools Section -->
|
||||
<view>
|
||||
<view class="mb-4 flex items-center space-x-2">
|
||||
<view class="h-4 w-1 rounded-full bg-indigo-500"></view>
|
||||
<text class="text-base font-bold text-gray-800">AI 生产力</text>
|
||||
<TestBatchModeSwitch />
|
||||
|
||||
<view class="work-section">
|
||||
<view class="work-section-header">
|
||||
<view class="work-section-marker"></view>
|
||||
<view class="work-section-copy">
|
||||
<text class="work-section-title">业务管理</text>
|
||||
<text class="work-section-description">选择需要执行的现场流程</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="grid grid-cols-2 gap-4">
|
||||
|
||||
<view v-if="visibleBusinessEntries.length" class="work-entry-list">
|
||||
<view
|
||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
|
||||
@click="handleToAiChat"
|
||||
v-for="entry in visibleBusinessEntries"
|
||||
:key="entry.path"
|
||||
class="work-entry"
|
||||
@click="handleBusinessEntry(entry)"
|
||||
>
|
||||
<view
|
||||
class="absolute -right-4 -top-4 size-20 rounded-full bg-indigo-50 opacity-50"
|
||||
></view>
|
||||
<view class="relative z-10 flex flex-col">
|
||||
<view
|
||||
class="mb-3 flex size-10 items-center justify-center rounded-xl bg-indigo-500 text-white shadow-md shadow-indigo-200"
|
||||
>
|
||||
<view class="i-mdi-chat text-xl"></view>
|
||||
</view>
|
||||
<text class="font-bold text-gray-800">智能对话</text>
|
||||
<text class="mt-1 text-xs text-gray-500">智能助手</text>
|
||||
class="work-entry-icon"
|
||||
:class="entry.iconClass"
|
||||
>
|
||||
<view :class="entry.icon" class="work-entry-icon-glyph"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 shadow-sm transition-all active:scale-95"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="absolute -right-4 -top-4 size-20 rounded-full bg-purple-50 opacity-50"
|
||||
></view>
|
||||
<view class="relative z-10 flex flex-col">
|
||||
<view
|
||||
class="mb-3 flex size-10 items-center justify-center rounded-xl bg-purple-500 text-white shadow-md shadow-purple-200"
|
||||
>
|
||||
<view class="i-mdi-image-multiple text-xl"></view>
|
||||
</view>
|
||||
<text class="font-bold text-gray-800">图像生成</text>
|
||||
<text class="mt-1 text-xs text-gray-500">创意工坊</text>
|
||||
<view class="work-entry-copy">
|
||||
<text class="work-entry-title">{{ entry.title }}</text>
|
||||
<text class="work-entry-description">{{ entry.description }}</text>
|
||||
</view>
|
||||
<view class="i-mdi-chevron-right work-entry-arrow"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- System Management Section -->
|
||||
<view>
|
||||
<view class="mb-4 flex items-center space-x-2">
|
||||
<view class="h-4 w-1 rounded-full bg-blue-500"></view>
|
||||
<text class="text-base font-bold text-gray-800">系统管理</text>
|
||||
</view>
|
||||
<view class="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<view class="flex flex-wrap justify-between gap-y-4">
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleToUserList"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-blue-50 text-blue-500 transition-colors group-active:bg-blue-100"
|
||||
>
|
||||
<view class="i-mdi-account text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>用户管理</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-500 transition-colors group-active:bg-orange-100"
|
||||
>
|
||||
<view class="i-mdi-badge-account text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>角色管理</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-green-50 text-green-500 transition-colors group-active:bg-green-100"
|
||||
>
|
||||
<view class="i-mdi-office-building text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>部门管理</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-red-50 text-red-500 transition-colors group-active:bg-red-100"
|
||||
>
|
||||
<view class="i-mdi-cog text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>配置管理</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-cyan-50 text-cyan-500 transition-colors group-active:bg-cyan-100"
|
||||
>
|
||||
<view class="i-mdi-book-open-page-variant text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>字典管理</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-yellow-50 text-yellow-500 transition-colors group-active:bg-yellow-100"
|
||||
>
|
||||
<view class="i-mdi-bullhorn text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>通知公告</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-pink-50 text-pink-500 transition-colors group-active:bg-pink-100"
|
||||
>
|
||||
<view class="i-mdi-file-document-outline text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600"
|
||||
>日志管理</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="w-[22%] flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||
@click="handleBuilding"
|
||||
>
|
||||
<view
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-gray-50 text-gray-500 transition-colors group-active:bg-gray-100"
|
||||
>
|
||||
<view class="i-mdi-dots-horizontal text-xl"></view>
|
||||
</view>
|
||||
<text class="text-[11px] font-medium text-gray-600">更多</text>
|
||||
</view>
|
||||
<view v-else class="work-empty-state">
|
||||
<view class="work-empty-icon">
|
||||
<view class="i-mdi-lock-outline work-empty-icon-glyph"></view>
|
||||
</view>
|
||||
<text class="work-empty-title">暂无已授权业务</text>
|
||||
<text class="work-empty-description">请联系管理员分配业务菜单权限</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -202,34 +65,297 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance } from "vue";
|
||||
import { computed, getCurrentInstance } from "vue";
|
||||
import TestBatchModeSwitch from "@/components/business/TestBatchModeSwitch.vue";
|
||||
import { businessMenuEntries } from "@/config/businessMenu";
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
const current = ref(0);
|
||||
const swiperDotIndex = ref(0);
|
||||
const data = ref([
|
||||
{ image: "/static/images/banner/banner01.jpg" },
|
||||
{ image: "/static/images/banner/banner02.jpg" },
|
||||
{ image: "/static/images/banner/banner03.jpg" },
|
||||
]);
|
||||
|
||||
function clickBannerItem(item) {
|
||||
console.info(item);
|
||||
const visibleBusinessEntries = computed(() =>
|
||||
businessMenuEntries.filter(canOpenBusinessEntry),
|
||||
);
|
||||
|
||||
function canOpenBusinessEntry(entry) {
|
||||
return entry.permissions
|
||||
? proxy.$auth.hasPermiOr(entry.permissions)
|
||||
: proxy.$auth.hasPermi(entry.permission);
|
||||
}
|
||||
|
||||
function changeSwiper(e) {
|
||||
current.value = e.detail.current;
|
||||
}
|
||||
|
||||
function handleToAiChat() {
|
||||
handleBuilding();
|
||||
}
|
||||
|
||||
function handleToUserList() {
|
||||
handleBuilding();
|
||||
}
|
||||
|
||||
function handleBuilding() {
|
||||
proxy.$modal.msg("模块建设中~");
|
||||
function handleBusinessEntry(entry) {
|
||||
if (!canOpenBusinessEntry(entry)) {
|
||||
proxy.$modal.msgError("当前账号没有该业务菜单权限");
|
||||
return;
|
||||
}
|
||||
proxy.$tab.navigateTo(entry.path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.work-page {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
background: #f4f8f8;
|
||||
}
|
||||
|
||||
.work-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 24rpx 28rpx calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.work-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.work-heading-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-kicker {
|
||||
display: block;
|
||||
color: #28225c;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.work-subtitle {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #6c7786;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.work-permission-count {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: baseline;
|
||||
border-radius: 14rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #e9f7f6;
|
||||
color: #008596;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.work-permission-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.work-permission-label {
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.work-section {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
margin-top: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(40, 34, 92, 0.06);
|
||||
}
|
||||
|
||||
.work-section-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.work-section-marker {
|
||||
width: 8rpx;
|
||||
height: 38rpx;
|
||||
flex: 0 0 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #008596;
|
||||
}
|
||||
|
||||
.work-section-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-section-title {
|
||||
display: block;
|
||||
color: #28225c;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.work-section-description {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: #8b969d;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-entry-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
margin-top: 20rpx;
|
||||
gap: 12rpx;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
border: 1rpx solid #e5eeee;
|
||||
border-radius: 16rpx;
|
||||
padding: 18rpx;
|
||||
background: #fbfdfd;
|
||||
}
|
||||
|
||||
.work-entry:active {
|
||||
border-color: #8ccbc5;
|
||||
background: #f0faf9;
|
||||
}
|
||||
|
||||
.work-entry-icon {
|
||||
display: flex;
|
||||
width: 82rpx;
|
||||
height: 82rpx;
|
||||
flex: 0 0 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
|
||||
.work-entry-icon-glyph {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
|
||||
.work-entry-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.work-entry-title,
|
||||
.work-entry-description {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.work-entry-title {
|
||||
color: #27313a;
|
||||
font-size: 27rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.work-entry-description {
|
||||
margin-top: 7rpx;
|
||||
color: #7b8791;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-entry-arrow {
|
||||
flex: 0 0 34rpx;
|
||||
color: #a9b7bb;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.work-empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 70rpx 16rpx 54rpx;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-empty-icon {
|
||||
display: flex;
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f1f5f5;
|
||||
color: #9aa5ad;
|
||||
}
|
||||
|
||||
.work-empty-icon-glyph {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
|
||||
.work-empty-title {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
color: #4b5563;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.work-empty-description {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: #9aa5ad;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
.work-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.work-kicker {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.work-section {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.work-entry {
|
||||
gap: 12rpx;
|
||||
padding: 14rpx;
|
||||
}
|
||||
|
||||
.work-entry-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex-basis: 72rpx;
|
||||
}
|
||||
|
||||
.work-entry-icon-glyph {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 516.1 194.52">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #008596;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #91cdb9;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #43b69e;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #554d8a;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #50bcba;
|
||||
}
|
||||
|
||||
.cls-7, .cls-8, .cls-9 {
|
||||
fill: #28225c;
|
||||
}
|
||||
|
||||
.cls-10 {
|
||||
fill: #009a91;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.cls-10, .cls-11 {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
|
||||
.cls-11 {
|
||||
fill: #a5d7d7;
|
||||
}
|
||||
|
||||
.cls-8 {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.cls-9 {
|
||||
opacity: .25;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="tekening">
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-2" d="M302.33,61.89l-18.13,63.56c-3.35,11.65-7.77,17.59-23.63,17.59-3.99,0-10.36-.86-13.06-1.73-1.19-.43-2.27-.76-2.27-1.94v-8.74c0-1.19,.97-2.05,2.16-2.05h.22c3.56,.21,9.93,.86,13.49,.86,3.88,0,5.83-1.29,7.12-5.61l.97-3.35h-1.62c-3.99,0-7.34-.65-10.04-10.04l-14.24-48.56c-.11-.32-.11-.54-.11-.76,0-1.08,.65-1.73,2.16-1.73h14.35c1.08,0,1.83,.97,2.05,2.05l9.71,40.58c.32,1.51,.65,2.05,1.29,2.05h.97l10.04-42.63c.21-1.08,1.08-2.05,2.27-2.05h14.35c1.08,0,2.05,.86,2.05,1.94,0,.22-.11,.33-.11,.54Z"/>
|
||||
<path class="cls-2" d="M326.72,121.44h-13.17c-1.19,0-2.27-1.08-2.27-2.27V40.83c0-1.19,1.08-2.16,2.27-2.16h13.17c1.19,0,2.05,.97,2.05,2.16V119.17c0,1.19-.86,2.27-2.05,2.27Z"/>
|
||||
<path class="cls-2" d="M396,61.89l-13.6,48.99c-2.7,9.6-9.28,11.33-15.65,11.33s-12.84-1.73-15.54-11.33l-13.49-48.99c-.11-.21-.11-.43-.11-.65,0-1.08,.76-1.84,1.73-1.84h14.24c1.08,0,1.94,.97,2.27,2.27l9.28,42.3c.33,1.62,.86,2.27,1.62,2.27s1.4-.65,1.73-2.27l9.28-42.3c.32-1.3,1.19-2.27,2.27-2.27h14.24c.97,0,1.84,.76,1.84,1.84,0,.21,0,.43-.11,.65Z"/>
|
||||
<path class="cls-2" d="M418.66,121.3c-9.39,0-18.45-4.53-18.45-18.56v-.97c0-12.63,8.09-18.24,26.55-18.24h5.83v-3.89c0-6.15-6.78-7.47-11.85-7.47,0,0-17.96,1.95-17.96-12.69,0,0,16.18-.15,23.3-.15,15.54,0,23.99,4.55,23.99,20.31v39.49c0,1.19-.97,2.16-2.16,2.16h-29.24Zm13.89-27.41h-6.15c-7.56,0-9.5,2.59-9.5,7.88v.97c0,4.53,2.27,5.39,6.8,5.39,3.24,0,8.68,0,8.68,0l.17-14.25Z"/>
|
||||
<path class="cls-2" d="M497.01,58.61c15.65,0,19.1,10.9,19.1,24.5v35.93c0,1.19-1.08,2.27-2.27,2.27h-13.06c-1.19,0-2.16-1.08-2.16-2.27v-35.93c-.1-6.03,.45-8.65-6.13-8.65-3.22,0-8.33,.12-11.99,2.06v42.52c0,1.19-.97,2.27-2.16,2.27h-13.06c-1.19,0-2.27-1.08-2.27-2.27V61.63c0-1.19,.97-2.16,2.16-2.16l31.83-.86Z"/>
|
||||
<path class="cls-2" d="M184.25,121.47c0-17.9,16.39-18.14,16.39-18.14,8.24,0,17.54-.5,17.98-6.18,.24-3.16-.41-4.44-6.02-7.82l-15.23-8.96c-10.04-6.01-12.73-12.89-12.73-20.87,0-12.02,6.69-21.3,27.63-21.3,1.66,0,23.24,.05,23.26,.07-.13,.14,.12,2.04,.12,3.24,0,14.93-20.16,13.71-20.16,13.71-6.15,0-9.85,2.38-9.85,6.31,0,3.06,1.26,4.7,7.09,7.87l13.81,6.38c11.22,6.12,12.5,12.79,12.5,21.64,0,11.58-6.73,24.27-24.96,24.11l-29.81-.05Z"/>
|
||||
<g>
|
||||
<path class="cls-2" d="M365.04,163.65h-1.67c-.32,0-.58-.29-.58-.64v-22.95c0-.32,.23-.61,.58-.61h1.67c.32,0,.58,.26,.58,.61v22.95c0,.32-.23,.64-.58,.64Z"/>
|
||||
<path class="cls-2" d="M373.88,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M393.77,146.72l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.34-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
|
||||
<path class="cls-2" d="M400.81,143.11h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M420.03,163.65h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M440.07,148.1l-1.16,.22c.58,.61,1.09,1.58,1.09,3.32v.26c0,3.54-2.19,5.5-7.15,5.5-1.71,0-2.8-.26-3.73-.64-.55,.26-.8,.61-.8,1.09,0,.55,.26,.84,1,1.03l4.8,1.22c3.15,.8,6.18,1.71,6.18,4.73v.26c0,3.09-2.06,4.99-7.34,4.99s-7.4-1.96-7.4-5.12v-.29c0-1.8,.97-3.28,2.03-4.09l-.23-.1c-1.03-.45-1.48-1.51-1.48-2.51v-.03c0-.87,.42-1.8,1.32-2.32-1-.93-1.48-2.09-1.48-3.73v-.26c0-2.86,1.61-5.7,7.14-5.7h7.05c.39,0,.74,.32,.74,.71v.84c0,.42-.23,.55-.58,.61Zm-2.61,16.74c0-1.26-1.03-1.77-3.93-2.51l-3.64-.93c-.93,.8-1.58,1.61-1.58,3.28v.29c0,1.8,1.06,2.93,4.63,2.93s4.51-1.03,4.51-2.8v-.26Zm-.26-13.2c0-1.93-.87-3.44-4.35-3.44s-4.34,1.64-4.34,3.44v.26c0,1.42,.64,3.28,4.34,3.28,3.48,0,4.35-1.35,4.35-3.28v-.26Z"/>
|
||||
<path class="cls-2" d="M365.07,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M384.28,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M403.17,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M415.71,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
|
||||
<path class="cls-2" d="M440.52,177.36l-4.35,15.32c-.42,1.45-1.51,1.84-2.9,1.84s-2.48-.39-2.9-1.84l-4.35-15.32c-.03-.1-.03-.16-.03-.23,0-.32,.19-.55,.51-.55h1.77c.26,0,.48,.19,.61,.61l3.7,14.03c.19,.77,.35,.93,.68,.93,.29,0,.48-.16,.68-.93l3.7-14.03c.13-.42,.35-.61,.61-.61h1.77c.32,0,.51,.23,.51,.55,0,.06,0,.13-.03,.23Z"/>
|
||||
<path class="cls-2" d="M455.97,194.29h-1.48c-.35,0-.64-.29-.64-.64v-.71c-1.54,.9-3.15,1.58-4.96,1.58-2.74,0-5.25-1.19-5.25-5.21v-.39c0-3.48,2.03-5.21,7.76-5.21h2.38v-1.29c0-2.7-.93-3.64-3.44-3.64-2.09,0-3.64,.13-4.92,.26h-.16c-.29,0-.58-.1-.58-.51v-1.13c0-.35,.23-.55,.68-.61,1.32-.23,2.8-.42,4.99-.42,4.47,0,6.28,2.25,6.28,6.05v11.23c0,.35-.29,.64-.64,.64Zm-2.19-8.56h-2.38c-3.9,0-4.93,.87-4.93,3.15v.39c0,2.32,1.09,2.93,2.96,2.93,1.48,0,2.8-.45,4.35-1.29v-5.18Z"/>
|
||||
<path class="cls-2" d="M469.99,194.36c-.64,.1-1.48,.16-2.09,.16-2.96,0-4.38-.77-4.38-4.31v-11.23l-3.12-.35c-.35-.03-.61-.22-.61-.58v-.87c0-.35,.26-.58,.61-.58h3.12v-3.15c0-.32,.22-.58,.58-.64l1.67-.26h.1c.26,0,.48,.22,.48,.55v3.51h3.48c.35,0,.64,.29,.64,.64v1.09c0,.35-.29,.64-.64,.64h-3.48v11.23c0,1.77,.45,2.03,1.74,2.03h1.8c.45,0,.68,.13,.68,.45v1.13c0,.29-.16,.48-.58,.55Z"/>
|
||||
<path class="cls-2" d="M477.14,173.76h-1.64c-.35,0-.64-.29-.64-.64v-2.57c0-.35,.29-.64,.64-.64h1.64c.35,0,.64,.29,.64,.64v2.57c0,.35-.29,.64-.64,.64Zm-.06,20.53h-1.54c-.35,0-.64-.29-.64-.64v-16.42c0-.35,.29-.64,.64-.64h1.54c.35,0,.64,.29,.64,.64v16.42c0,.35-.29,.64-.64,.64Z"/>
|
||||
<path class="cls-2" d="M490.01,194.52c-4.73,0-7.14-3.12-7.14-7.85v-2.45c0-4.73,2.41-7.85,7.14-7.85s7.18,3.12,7.18,7.85v2.45c0,4.73-2.45,7.85-7.18,7.85Zm4.35-10.3c0-3.28-1.32-5.38-4.35-5.38s-4.31,2.06-4.31,5.38v2.45c0,3.28,1.29,5.38,4.31,5.38s4.35-2.06,4.35-5.38v-2.45Z"/>
|
||||
<path class="cls-2" d="M514.85,194.29h-1.54c-.35,0-.64-.29-.64-.64v-10.59c0-3.25-1.09-4.25-3.54-4.25-1.35,0-2.86,.51-4.34,1.26v13.58c0,.32-.29,.64-.64,.64h-1.58c-.32,0-.61-.29-.61-.64v-16.42c0-.35,.26-.64,.61-.64h1.61c.35,0,.61,.29,.61,.64v.61c1.77-1,3.22-1.48,5.12-1.48,3.86,0,5.6,2.41,5.6,6.69v10.59c0,.35-.29,.64-.64,.64Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-11" d="M57.78,67.44c-.03,5.02-.86,9.86-2.35,14.39,0,0,.01,.01,.02,.02,0,0,0,0,.01,0,10.82-31.09,38.25-54.41,71.67-59.3C130.83,15.86,132.95,8.18,132.95,0h0s-46.67,0-46.67,0H46.67C30.78,0,16.76,7.94,8.33,20.07c.88-.04,1.76-.07,2.65-.06,26.02,.17,46.98,21.41,46.8,47.43Z"/>
|
||||
<path class="cls-10" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
|
||||
<path class="cls-6" d="M0,46.67c0,5.62,1,11,2.82,15.99,5.22-.78,10.62-.87,16.11-.17,14.7,1.88,27.41,9.05,36.5,19.35,1.49-4.53,2.31-9.36,2.35-14.39,.17-26.02-20.78-47.26-46.8-47.43-.89,0-1.77,.02-2.65,.06C3.08,27.61,0,36.78,0,46.67H0Z"/>
|
||||
<path class="cls-7" d="M55.46,81.85s-.01-.01-.02-.02c0,0,0,0,0,.01,0,0,.02,0,.02,0Z"/>
|
||||
<path class="cls-10" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-8" d="M55.47,81.85s0,0-.01,0c0,0,0,0,.01,.01,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-7" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
|
||||
<path class="cls-8" d="M55.42,81.88s.03,0,.05-.01c0,0,0,0,0,0,0,0,0,0-.01-.01,0,0-.02,0-.02,0,0,.01,0,.02-.01,.03Z"/>
|
||||
<path class="cls-10" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
|
||||
<path class="cls-3" d="M55.47,81.85s0,0,.01,0c5.58-1.58,11.47-2.44,17.56-2.44,24,0,44.92,13.13,56.01,32.6,2.5-5.72,3.9-12.04,3.9-18.68h0c0-24.43-18.78-44.48-42.69-46.5-.22-.02-.22-.31,0-.33,15.91-1.34,29.53-10.67,36.88-23.95-33.42,4.9-60.85,28.21-71.67,59.3Z"/>
|
||||
<path class="cls-10" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-8" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||
<path class="cls-9" d="M55.47,81.86s.01,0,.02,0c0,0,0,0-.01,0,0,0,0,0,0,0,0,0,0,0,0,0Z"/>
|
||||
<polygon class="cls-7" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||
<polygon class="cls-8" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||
<polygon class="cls-9" points="55.47 81.86 55.47 81.86 55.47 81.86 55.47 81.86"/>
|
||||
<path class="cls-10" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||
<path class="cls-9" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||
<path class="cls-4" d="M73.04,79.42c-6.09,0-11.97,.86-17.56,2.44,24.27,7.47,41.91,30.06,41.91,56.78,0,0,0,.01,0,.02,14.23-3.48,25.89-13.47,31.65-26.64-11.08-19.46-32-32.6-56.01-32.6Z"/>
|
||||
<path class="cls-10" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-8" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-9" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-1" d="M55.48,81.86s-.01,0-.02,0c10.78,12.22,16.46,28.84,14.23,46.28-.53,4.12-1.59,8.08-3.11,11.86h19.69c3.83,0,7.55-.47,11.11-1.34,0,0,0-.01,0-.02,0-26.72-17.64-49.31-41.91-56.78Z"/>
|
||||
<path class="cls-5" d="M55.47,81.86s0,0,0,0c-.02,0-.03,0-.05,.01,0-.01,0-.02,.01-.03,0,0,0,0,0-.01-9.09-10.29-21.81-17.47-36.5-19.35-5.49-.7-10.89-.6-16.11,.17,2.93,8.02,7.98,15.01,14.5,20.29,5.11,4.14,11.12,7.22,17.69,8.91,2.45,.63,4.96,1.08,7.55,1.3,.24,.02,.24,.34,0,.36-4.74,.41-9.26,1.54-13.49,3.27-8.71,3.55-16.1,9.65-21.25,17.38-4.93,7.4-7.82,16.28-7.82,25.84H0s8.74,0,8.74,0h57.85c1.51-3.77,2.58-7.74,3.11-11.86,2.23-17.44-3.45-34.06-14.23-46.28Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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'
|
||||
@@ -51,7 +51,7 @@ DB_PORT = 3306
|
||||
# 数据库用户名
|
||||
DB_USERNAME = 'root'
|
||||
# 数据库密码
|
||||
DB_PASSWORD = 'mysqlroot'
|
||||
DB_PASSWORD = '12345678'
|
||||
# 数据库名称
|
||||
DB_DATABASE = 'shierfeng-fastapi'
|
||||
# 是否开启sqlalchemy日志
|
||||
@@ -140,9 +140,15 @@ LOG_WORKER_ID = 'auto'
|
||||
|
||||
# -------- 传输层加解密配置 --------
|
||||
# 是否启用传输层加解密
|
||||
TRANSPORT_CRYPTO_ENABLED = false
|
||||
#TRANSPORT_CRYPTO_ENABLED = false
|
||||
## 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
||||
#TRANSPORT_CRYPTO_MODE = 'off'
|
||||
|
||||
TRANSPORT_CRYPTO_ENABLED = true
|
||||
# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密
|
||||
TRANSPORT_CRYPTO_MODE = 'off'
|
||||
TRANSPORT_CRYPTO_MODE = 'optional'
|
||||
|
||||
|
||||
# 传输层加解密算法标识
|
||||
TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM'
|
||||
# 当前启用的密钥版本标识
|
||||
@@ -171,3 +177,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
||||
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
|
||||
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
|
||||
|
||||
@@ -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'
|
||||
@@ -51,7 +51,7 @@ DB_PORT = 3306
|
||||
# 数据库用户名
|
||||
DB_USERNAME = 'root'
|
||||
# 数据库密码
|
||||
DB_PASSWORD = 'root'
|
||||
DB_PASSWORD = '12345678'
|
||||
# 数据库名称
|
||||
DB_DATABASE = 'shierfeng-fastapi'
|
||||
# 是否开启sqlalchemy日志
|
||||
@@ -171,3 +171,5 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = ''
|
||||
TRANSPORT_CRYPTO_REQUIRED_PATHS = ''
|
||||
# 排除传输层加密的路径列表,多个值使用逗号分隔
|
||||
TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource'
|
||||
# 测试批次在线清理最大数据行数;0 表示禁止在线清理
|
||||
TEST_BATCH_ONLINE_PURGE_MAX_ROWS = 1000
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM python:3.10
|
||||
WORKDIR /app
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 安装依赖
|
||||
RUN pip install --no-cache-dir -r requirements-pg.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 9099
|
||||
|
||||
# 启动命令
|
||||
CMD ["shierfeng", "app", "run", "--env=dockerpg"]
|
||||
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_ENVIRONMENTS = ('dev', 'prod', 'dockermy', 'dockerpg')
|
||||
DEFAULT_ENVIRONMENTS = ('dev', 'prod', 'dockermy')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -28,7 +28,7 @@ GENERAL_COPY = {
|
||||
'action_confirm_hint': '按键提示 · [Enter] 确认执行 [Esc] 取消返回',
|
||||
'more_detail_hint': '> 进入对应页面查看更多',
|
||||
'tui_command_help': '进入只读巡检工作台',
|
||||
'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`',
|
||||
'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt`',
|
||||
'tui_missing_dependency_message': '当前环境未安装 TUI 可选依赖,无法启动 `shierfeng tui`',
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from user_agents import parse
|
||||
from common.context import RequestContext
|
||||
from common.enums import BusinessType
|
||||
from config.env import AppConfig
|
||||
from exceptions.exception import LoginException, ServiceException, ServiceWarning
|
||||
from exceptions.exception import LoginException, PermissionException, ServiceException, ServiceWarning
|
||||
from module_admin.entity.vo.log_vo import LogininforModel, OperLogModel
|
||||
from module_admin.service.log_service import LogQueueService
|
||||
from utils.client_ip_util import ClientIPUtil
|
||||
@@ -117,6 +117,7 @@ class Log:
|
||||
log_type: Literal['login', 'operation'] | None = 'operation',
|
||||
request_log_mode: Literal['full', 'none', 'summary', 'include', 'exclude'] = 'full',
|
||||
response_log_mode: Literal['full', 'none', 'summary', 'include', 'exclude'] = 'full',
|
||||
oper_url_mode: Literal['full', 'route'] = 'full',
|
||||
request_include_fields: tuple[str, ...] | None = None,
|
||||
response_include_fields: tuple[str, ...] | None = None,
|
||||
request_exclude_fields: tuple[str, ...] | None = None,
|
||||
@@ -130,6 +131,7 @@ class Log:
|
||||
:param log_type: 日志类型;`login`表示登录日志,仅落登录信息,`operation`表示操作日志,会落请求/响应摘要与操作人信息
|
||||
:param request_log_mode: 请求日志记录模式;`full`记录脱敏后的完整请求,`none`不记录请求体,`summary`记录请求摘要,`include`仅记录request_include_fields指定的字段,`exclude`记录完整请求后排除request_exclude_fields指定的字段
|
||||
:param response_log_mode: 响应日志记录模式;`full`记录脱敏后的完整响应,`none`不记录响应体,`summary`记录响应摘要,`include`仅记录response_include_fields指定的字段,`exclude`记录完整响应后排除response_exclude_fields指定的字段
|
||||
:param oper_url_mode: 操作URL记录模式;`full`记录实际请求路径,`route`记录路由模板,适合路径中包含敏感标识的接口
|
||||
:param request_include_fields: 请求日志白名单字段路径,仅在request_log_mode='include'时生效;推荐从path_params/query_params/json_body/form_data/files/raw_body开始,字段名支持 snake_case 与 camelCase 自动兼容
|
||||
:param response_include_fields: 响应日志白名单字段路径,仅在response_log_mode='include'时生效;推荐从code/msg/data/rows/success/time开始,字段名支持 snake_case 与 camelCase 自动兼容
|
||||
:param request_exclude_fields: 请求日志排除字段路径,仅在request_log_mode='exclude'时生效;推荐从path_params/query_params/json_body/form_data/files/raw_body开始,字段名支持 snake_case 与 camelCase 自动兼容
|
||||
@@ -141,6 +143,7 @@ class Log:
|
||||
self.log_type = log_type
|
||||
self.request_log_mode = request_log_mode
|
||||
self.response_log_mode = response_log_mode
|
||||
self.oper_url_mode = oper_url_mode
|
||||
self.request_include_fields = request_include_fields or ()
|
||||
self.response_include_fields = response_include_fields or ()
|
||||
self.request_exclude_fields = request_exclude_fields or ()
|
||||
@@ -166,7 +169,7 @@ class Log:
|
||||
# 获取操作类型
|
||||
operator_type = self._get_oper_type(user_agent)
|
||||
# 获取请求的url
|
||||
oper_url = request.url.path
|
||||
oper_url = self._get_oper_url(request)
|
||||
# 获取请求ip
|
||||
oper_ip = ClientIPUtil.get_client_ip(request)
|
||||
# 获取请求ip归属区域
|
||||
@@ -193,6 +196,8 @@ class Log:
|
||||
except (LoginException, ServiceWarning) as e:
|
||||
logger.warning(e.message)
|
||||
result = ResponseUtil.failure(data=e.data, msg=e.message)
|
||||
except PermissionException as e:
|
||||
result = ResponseUtil.forbidden(data=e.data, msg=e.message)
|
||||
except ServiceException as e:
|
||||
logger.error(e.message)
|
||||
result = ResponseUtil.error(data=e.data, msg=e.message)
|
||||
@@ -279,6 +284,21 @@ class Log:
|
||||
|
||||
return func_path
|
||||
|
||||
def _get_oper_url(self, request: Request) -> str:
|
||||
"""
|
||||
获取操作日志中的请求URL。
|
||||
|
||||
路由模板模式只保留路由结构,不把路径参数(例如二维码固定标识)写入日志。
|
||||
|
||||
:param request: 当前请求
|
||||
:return: 用于操作日志的URL
|
||||
"""
|
||||
if self.oper_url_mode == 'route':
|
||||
route_path = getattr(request.scope.get('route'), 'path', None)
|
||||
if route_path:
|
||||
return route_path
|
||||
return request.url.path
|
||||
|
||||
@staticmethod
|
||||
def _limit_log_text(log_text: str, max_length: int, overflow_text: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -63,7 +63,6 @@ class ApiRateLimitPreset:
|
||||
ANON_AUTH_CAPTCHA: 匿名验证码类接口限流预设
|
||||
ANON_PUBLIC_METADATA: 匿名公开元数据接口限流预设
|
||||
COMMON_UPLOAD: 通用上传接口限流预设
|
||||
USER_INTERACTIVE_HIGH_FREQ: 用户高频交互接口限流预设
|
||||
USER_RESOURCE_EXECUTION: 用户执行类接口限流预设
|
||||
USER_COMMON_MUTATION: 用户普通写操作接口限流预设
|
||||
USER_SECURITY_MUTATION: 用户安全敏感操作接口限流预设
|
||||
@@ -111,12 +110,6 @@ class ApiRateLimitPreset:
|
||||
scope='user_or_ip',
|
||||
)
|
||||
|
||||
USER_INTERACTIVE_HIGH_FREQ = ApiRateLimitPresetConfig(
|
||||
name='USER_INTERACTIVE_HIGH_FREQ',
|
||||
limit=40,
|
||||
window_seconds=60,
|
||||
scope='user',
|
||||
)
|
||||
USER_RESOURCE_EXECUTION = ApiRateLimitPresetConfig(
|
||||
name='USER_RESOURCE_EXECUTION',
|
||||
limit=12,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from config.env import DataBaseConfig
|
||||
|
||||
|
||||
class CommonConstant:
|
||||
@@ -223,13 +222,6 @@ class ApiNamespace:
|
||||
SYSTEM_USER_IMPORT: 用户导入接口命名空间
|
||||
SYSTEM_USER_EXPORT: 用户导出接口命名空间
|
||||
|
||||
AI_MODEL_LIST: AI模型列表接口命名空间
|
||||
AI_MODEL_ALL: AI模型全量列表接口命名空间
|
||||
AI_MODEL_DETAIL: AI模型详情接口命名空间
|
||||
AI_CHAT_CONFIG: AI对话配置接口命名空间
|
||||
AI_CHAT_SEND: AI对话发送接口命名空间
|
||||
AI_CHAT_CANCEL: AI对话取消接口命名空间
|
||||
|
||||
TOOL_GEN_LIST: 代码生成列表接口命名空间
|
||||
TOOL_GEN_DB_LIST: 代码生成数据源列表接口命名空间
|
||||
TOOL_GEN_DETAIL: 代码生成详情接口命名空间
|
||||
@@ -319,13 +311,6 @@ class ApiNamespace:
|
||||
SYSTEM_USER_IMPORT = 'system:user:import'
|
||||
SYSTEM_USER_EXPORT = 'system:user:export'
|
||||
|
||||
AI_MODEL_LIST = 'ai:model:list'
|
||||
AI_MODEL_ALL = 'ai:model:all'
|
||||
AI_MODEL_DETAIL = 'ai:model:detail'
|
||||
AI_CHAT_CONFIG = 'ai:chat:config'
|
||||
AI_CHAT_SEND = 'ai:chat:send'
|
||||
AI_CHAT_CANCEL = 'ai:chat:cancel'
|
||||
|
||||
TOOL_GEN_LIST = 'tool:gen:list'
|
||||
TOOL_GEN_DB_LIST = 'tool:gen:db-list'
|
||||
TOOL_GEN_DETAIL = 'tool:gen:detail'
|
||||
@@ -362,8 +347,6 @@ class ApiGroup:
|
||||
CONFIG_MUTATION: 参数配置写操作关联命名空间分组
|
||||
DICT_TYPE_MUTATION: 字典类型写操作关联命名空间分组
|
||||
DICT_DATA_MUTATION: 字典数据写操作关联命名空间分组
|
||||
AI_MODEL_MUTATION: AI模型写操作关联命名空间分组
|
||||
AI_CHAT_CONFIG_MUTATION: AI对话配置写操作关联命名空间分组
|
||||
GEN_MUTATION: 代码生成写操作关联命名空间分组
|
||||
"""
|
||||
|
||||
@@ -389,9 +372,6 @@ class ApiGroup:
|
||||
ApiNamespace.SYSTEM_USER_LIST,
|
||||
ApiNamespace.SYSTEM_USER_DETAIL,
|
||||
ApiNamespace.SYSTEM_USER_PROFILE,
|
||||
ApiNamespace.AI_MODEL_LIST,
|
||||
ApiNamespace.AI_MODEL_ALL,
|
||||
ApiNamespace.AI_MODEL_DETAIL,
|
||||
)
|
||||
|
||||
MENU_MUTATION = (
|
||||
@@ -488,14 +468,6 @@ class ApiGroup:
|
||||
ApiNamespace.SYSTEM_DICT_DATA_DETAIL,
|
||||
)
|
||||
|
||||
AI_MODEL_MUTATION = (
|
||||
ApiNamespace.AI_MODEL_LIST,
|
||||
ApiNamespace.AI_MODEL_ALL,
|
||||
ApiNamespace.AI_MODEL_DETAIL,
|
||||
)
|
||||
|
||||
AI_CHAT_CONFIG_MUTATION = (ApiNamespace.AI_CHAT_CONFIG,)
|
||||
|
||||
GEN_MUTATION = (
|
||||
ApiNamespace.TOOL_GEN_LIST,
|
||||
ApiNamespace.TOOL_GEN_DB_LIST,
|
||||
@@ -576,42 +548,19 @@ class GenConstant:
|
||||
TREE_NAME = 'treeName'
|
||||
PARENT_MENU_ID = 'parentMenuId'
|
||||
PARENT_MENU_NAME = 'parentMenuName'
|
||||
COLUMNTYPE_STR = (
|
||||
['character varying', 'varchar', 'character', 'char']
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else ['char', 'varchar', 'nvarchar', 'varchar2']
|
||||
)
|
||||
COLUMNTYPE_TEXT = (
|
||||
['text', 'citext'] if DataBaseConfig.db_type == 'postgresql' else ['tinytext', 'text', 'mediumtext', 'longtext']
|
||||
)
|
||||
COLUMNTYPE_TIME = (
|
||||
[
|
||||
'date',
|
||||
'time',
|
||||
'time with time zone',
|
||||
'time without time zone',
|
||||
'timestamp',
|
||||
'timestamp with time zone',
|
||||
'timestamp without time zone',
|
||||
'interval',
|
||||
]
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else ['datetime', 'time', 'date', 'timestamp']
|
||||
)
|
||||
COLUMNTYPE_GEOMETRY = (
|
||||
['point', 'line', 'lseg', 'box', 'path', 'polygon', 'circle']
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else [
|
||||
'geometry',
|
||||
'point',
|
||||
'linestring',
|
||||
'polygon',
|
||||
'multipoint',
|
||||
'multilinestring',
|
||||
'multipolygon',
|
||||
'geometrycollection',
|
||||
]
|
||||
)
|
||||
COLUMNTYPE_STR = ['char', 'varchar', 'nvarchar', 'varchar2']
|
||||
COLUMNTYPE_TEXT = ['tinytext', 'text', 'mediumtext', 'longtext']
|
||||
COLUMNTYPE_TIME = ['datetime', 'time', 'date', 'timestamp']
|
||||
COLUMNTYPE_GEOMETRY = [
|
||||
'geometry',
|
||||
'point',
|
||||
'linestring',
|
||||
'polygon',
|
||||
'multipoint',
|
||||
'multilinestring',
|
||||
'multipolygon',
|
||||
'geometrycollection',
|
||||
]
|
||||
COLUMNTYPE_NUMBER = [
|
||||
'tinyint',
|
||||
'smallint',
|
||||
@@ -646,211 +595,95 @@ class GenConstant:
|
||||
QUERY_LIKE = 'LIKE'
|
||||
QUERY_EQ = 'EQ'
|
||||
REQUIRE = '1'
|
||||
DB_TO_SQLALCHEMY_TYPE_MAPPING = (
|
||||
{
|
||||
'boolean': 'Boolean',
|
||||
'smallint': 'SmallInteger',
|
||||
'integer': 'Integer',
|
||||
'bigint': 'BigInteger',
|
||||
'real': 'Float',
|
||||
'double precision': 'Float',
|
||||
'numeric': 'Numeric',
|
||||
'character varying': 'String',
|
||||
'character': 'String',
|
||||
'text': 'Text',
|
||||
'bytea': 'LargeBinary',
|
||||
'date': 'Date',
|
||||
'time': 'Time',
|
||||
'time with time zone': 'Time',
|
||||
'time without time zone': 'Time',
|
||||
'timestamp': 'DateTime',
|
||||
'timestamp with time zone': 'DateTime',
|
||||
'timestamp without time zone': 'DateTime',
|
||||
'interval': 'Interval',
|
||||
'json': 'JSON',
|
||||
'jsonb': 'JSONB',
|
||||
'uuid': 'Uuid',
|
||||
'inet': 'INET',
|
||||
'cidr': 'CIDR',
|
||||
'macaddr': 'MACADDR',
|
||||
'point': 'Geometry',
|
||||
'line': 'Geometry',
|
||||
'lseg': 'Geometry',
|
||||
'box': 'Geometry',
|
||||
'path': 'Geometry',
|
||||
'polygon': 'Geometry',
|
||||
'circle': 'Geometry',
|
||||
'bit': 'Bit',
|
||||
'bit varying': 'Bit',
|
||||
'tsvector': 'TSVECTOR',
|
||||
'tsquery': 'TSQUERY',
|
||||
'xml': 'String',
|
||||
'array': 'ARRAY',
|
||||
'composite': 'JSON',
|
||||
'enum': 'Enum',
|
||||
'range': 'Range',
|
||||
'money': 'Numeric',
|
||||
'pg_lsn': 'BigInteger',
|
||||
'txid_snapshot': 'String',
|
||||
'oid': 'BigInteger',
|
||||
'regproc': 'String',
|
||||
'regclass': 'String',
|
||||
'regtype': 'String',
|
||||
'regrole': 'String',
|
||||
'regnamespace': 'String',
|
||||
'int2vector': 'ARRAY',
|
||||
'oidvector': 'ARRAY',
|
||||
'pg_node_tree': 'Text',
|
||||
}
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else {
|
||||
# 数值类型
|
||||
'TINYINT': 'SmallInteger',
|
||||
'SMALLINT': 'SmallInteger',
|
||||
'MEDIUMINT': 'Integer',
|
||||
'INT': 'Integer',
|
||||
'INTEGER': 'Integer',
|
||||
'BIGINT': 'BigInteger',
|
||||
'FLOAT': 'Float',
|
||||
'DOUBLE': 'Float',
|
||||
'DECIMAL': 'DECIMAL',
|
||||
'BIT': 'Integer',
|
||||
# 日期和时间类型
|
||||
'DATE': 'Date',
|
||||
'TIME': 'Time',
|
||||
'DATETIME': 'DateTime',
|
||||
'TIMESTAMP': 'TIMESTAMP',
|
||||
'YEAR': 'Integer',
|
||||
# 字符串类型
|
||||
'CHAR': 'CHAR',
|
||||
'VARCHAR': 'String',
|
||||
'TINYTEXT': 'Text',
|
||||
'TEXT': 'Text',
|
||||
'MEDIUMTEXT': 'Text',
|
||||
'LONGTEXT': 'Text',
|
||||
'BINARY': 'BINARY',
|
||||
'VARBINARY': 'VARBINARY',
|
||||
'TINYBLOB': 'LargeBinary',
|
||||
'BLOB': 'LargeBinary',
|
||||
'MEDIUMBLOB': 'LargeBinary',
|
||||
'LONGBLOB': 'LargeBinary',
|
||||
# 枚举和集合类型
|
||||
'ENUM': 'Enum',
|
||||
'SET': 'String',
|
||||
# JSON 类型
|
||||
'JSON': 'JSON',
|
||||
# 空间数据类型(需要扩展支持,如 GeoAlchemy2)
|
||||
'GEOMETRY': 'Geometry', # 需要安装 geoalchemy2
|
||||
'POINT': 'Geometry',
|
||||
'LINESTRING': 'Geometry',
|
||||
'POLYGON': 'Geometry',
|
||||
'MULTIPOINT': 'Geometry',
|
||||
'MULTILINESTRING': 'Geometry',
|
||||
'MULTIPOLYGON': 'Geometry',
|
||||
'GEOMETRYCOLLECTION': 'Geometry',
|
||||
}
|
||||
)
|
||||
DB_TO_PYTHON_TYPE_MAPPING = (
|
||||
{
|
||||
'boolean': 'bool',
|
||||
'smallint': 'int',
|
||||
'integer': 'int',
|
||||
'bigint': 'int',
|
||||
'real': 'float',
|
||||
'double precision': 'float',
|
||||
'numeric': 'Decimal',
|
||||
'character varying': 'str',
|
||||
'character': 'str',
|
||||
'text': 'str',
|
||||
'bytea': 'bytes',
|
||||
'date': 'date',
|
||||
'time': 'time',
|
||||
'time with time zone': 'time',
|
||||
'time without time zone': 'time',
|
||||
'timestamp': 'datetime',
|
||||
'timestamp with time zone': 'datetime',
|
||||
'timestamp without time zone': 'datetime',
|
||||
'interval': 'timedelta',
|
||||
'json': 'dict',
|
||||
'jsonb': 'dict',
|
||||
'uuid': 'str',
|
||||
'inet': 'str',
|
||||
'cidr': 'str',
|
||||
'macaddr': 'str',
|
||||
'point': 'list',
|
||||
'line': 'list',
|
||||
'lseg': 'list',
|
||||
'box': 'list',
|
||||
'path': 'list',
|
||||
'polygon': 'list',
|
||||
'circle': 'list',
|
||||
'bit': 'int',
|
||||
'bit varying': 'int',
|
||||
'tsvector': 'str',
|
||||
'tsquery': 'str',
|
||||
'xml': 'str',
|
||||
'array': 'list',
|
||||
'composite': 'dict',
|
||||
'enum': 'str',
|
||||
'range': 'list',
|
||||
'money': 'Decimal',
|
||||
'pg_lsn': 'int',
|
||||
'txid_snapshot': 'str',
|
||||
'oid': 'int',
|
||||
'regproc': 'str',
|
||||
'regclass': 'str',
|
||||
'regtype': 'str',
|
||||
'regrole': 'str',
|
||||
'regnamespace': 'str',
|
||||
'int2vector': 'list',
|
||||
'oidvector': 'list',
|
||||
'pg_node_tree': 'str',
|
||||
}
|
||||
if DataBaseConfig.db_type == 'postgresql'
|
||||
else {
|
||||
# 数值类型
|
||||
'TINYINT': 'int',
|
||||
'SMALLINT': 'int',
|
||||
'MEDIUMINT': 'int',
|
||||
'INT': 'int',
|
||||
'INTEGER': 'int',
|
||||
'BIGINT': 'int',
|
||||
'FLOAT': 'float',
|
||||
'DOUBLE': 'float',
|
||||
'DECIMAL': 'Decimal',
|
||||
'BIT': 'int',
|
||||
# 日期和时间类型
|
||||
'DATE': 'date',
|
||||
'TIME': 'time',
|
||||
'DATETIME': 'datetime',
|
||||
'TIMESTAMP': 'datetime',
|
||||
'YEAR': 'int',
|
||||
# 字符串类型
|
||||
'CHAR': 'str',
|
||||
'VARCHAR': 'str',
|
||||
'TINYTEXT': 'str',
|
||||
'TEXT': 'str',
|
||||
'MEDIUMTEXT': 'str',
|
||||
'LONGTEXT': 'str',
|
||||
'BINARY': 'bytes',
|
||||
'VARBINARY': 'bytes',
|
||||
'TINYBLOB': 'bytes',
|
||||
'BLOB': 'bytes',
|
||||
'MEDIUMBLOB': 'bytes',
|
||||
'LONGBLOB': 'bytes',
|
||||
# 枚举和集合类型
|
||||
'ENUM': 'str',
|
||||
'SET': 'str',
|
||||
# JSON 类型
|
||||
'JSON': 'dict',
|
||||
# 空间数据类型(通常需要特殊处理)
|
||||
'GEOMETRY': 'bytes',
|
||||
'POINT': 'bytes',
|
||||
'LINESTRING': 'bytes',
|
||||
'POLYGON': 'bytes',
|
||||
'MULTIPOINT': 'bytes',
|
||||
'MULTILINESTRING': 'bytes',
|
||||
'MULTIPOLYGON': 'bytes',
|
||||
'GEOMETRYCOLLECTION': 'bytes',
|
||||
}
|
||||
)
|
||||
DB_TO_SQLALCHEMY_TYPE_MAPPING = {
|
||||
# 数值类型
|
||||
'TINYINT': 'SmallInteger',
|
||||
'SMALLINT': 'SmallInteger',
|
||||
'MEDIUMINT': 'Integer',
|
||||
'INT': 'Integer',
|
||||
'INTEGER': 'Integer',
|
||||
'BIGINT': 'BigInteger',
|
||||
'FLOAT': 'Float',
|
||||
'DOUBLE': 'Float',
|
||||
'DECIMAL': 'DECIMAL',
|
||||
'BIT': 'Integer',
|
||||
# 日期和时间类型
|
||||
'DATE': 'Date',
|
||||
'TIME': 'Time',
|
||||
'DATETIME': 'DateTime',
|
||||
'TIMESTAMP': 'TIMESTAMP',
|
||||
'YEAR': 'Integer',
|
||||
# 字符串类型
|
||||
'CHAR': 'CHAR',
|
||||
'VARCHAR': 'String',
|
||||
'TINYTEXT': 'Text',
|
||||
'TEXT': 'Text',
|
||||
'MEDIUMTEXT': 'Text',
|
||||
'LONGTEXT': 'Text',
|
||||
'BINARY': 'BINARY',
|
||||
'VARBINARY': 'VARBINARY',
|
||||
'TINYBLOB': 'LargeBinary',
|
||||
'BLOB': 'LargeBinary',
|
||||
'MEDIUMBLOB': 'LargeBinary',
|
||||
'LONGBLOB': 'LargeBinary',
|
||||
# 枚举和集合类型
|
||||
'ENUM': 'Enum',
|
||||
'SET': 'String',
|
||||
# JSON 类型
|
||||
'JSON': 'JSON',
|
||||
# 空间数据类型(需要扩展支持,如 GeoAlchemy2)
|
||||
'GEOMETRY': 'Geometry', # 需要安装 geoalchemy2
|
||||
'POINT': 'Geometry',
|
||||
'LINESTRING': 'Geometry',
|
||||
'POLYGON': 'Geometry',
|
||||
'MULTIPOINT': 'Geometry',
|
||||
'MULTILINESTRING': 'Geometry',
|
||||
'MULTIPOLYGON': 'Geometry',
|
||||
'GEOMETRYCOLLECTION': 'Geometry',
|
||||
}
|
||||
DB_TO_PYTHON_TYPE_MAPPING = {
|
||||
# 数值类型
|
||||
'TINYINT': 'int',
|
||||
'SMALLINT': 'int',
|
||||
'MEDIUMINT': 'int',
|
||||
'INT': 'int',
|
||||
'INTEGER': 'int',
|
||||
'BIGINT': 'int',
|
||||
'FLOAT': 'float',
|
||||
'DOUBLE': 'float',
|
||||
'DECIMAL': 'Decimal',
|
||||
'BIT': 'int',
|
||||
# 日期和时间类型
|
||||
'DATE': 'date',
|
||||
'TIME': 'time',
|
||||
'DATETIME': 'datetime',
|
||||
'TIMESTAMP': 'datetime',
|
||||
'YEAR': 'int',
|
||||
# 字符串类型
|
||||
'CHAR': 'str',
|
||||
'VARCHAR': 'str',
|
||||
'TINYTEXT': 'str',
|
||||
'TEXT': 'str',
|
||||
'MEDIUMTEXT': 'str',
|
||||
'LONGTEXT': 'str',
|
||||
'BINARY': 'bytes',
|
||||
'VARBINARY': 'bytes',
|
||||
'TINYBLOB': 'bytes',
|
||||
'BLOB': 'bytes',
|
||||
'MEDIUMBLOB': 'bytes',
|
||||
'LONGBLOB': 'bytes',
|
||||
# 枚举和集合类型
|
||||
'ENUM': 'str',
|
||||
'SET': 'str',
|
||||
# JSON 类型
|
||||
'JSON': 'dict',
|
||||
# 空间数据类型(通常需要特殊处理)
|
||||
'GEOMETRY': 'bytes',
|
||||
'POINT': 'bytes',
|
||||
'LINESTRING': 'bytes',
|
||||
'POLYGON': 'bytes',
|
||||
'MULTIPOINT': 'bytes',
|
||||
'MULTILINESTRING': 'bytes',
|
||||
'MULTIPOLYGON': 'bytes',
|
||||
'GEOMETRYCOLLECTION': 'bytes',
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Literal
|
||||
|
||||
from exceptions.exception import LoginException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
|
||||
# 定义上下文变量
|
||||
# 存储当前请求的编译后的排除路由模式列表
|
||||
@@ -12,6 +13,8 @@ current_exclude_patterns: ContextVar[
|
||||
] = ContextVar('current_exclude_patterns', default=None)
|
||||
# 存储当前用户信息
|
||||
current_user: ContextVar[CurrentUserModel | None] = ContextVar('current_user', default=None)
|
||||
# 存储当前请求已校验的测试批次作用域
|
||||
current_test_batch_scope: ContextVar[TestBatchScope | None] = ContextVar('current_test_batch_scope', default=None)
|
||||
|
||||
|
||||
class RequestContext:
|
||||
@@ -69,6 +72,21 @@ class RequestContext:
|
||||
raise LoginException(data='', message='当前用户信息为空,请检查是否已登录')
|
||||
return _current_user
|
||||
|
||||
@staticmethod
|
||||
def set_current_test_batch_scope(scope: TestBatchScope) -> Token:
|
||||
"""
|
||||
设置当前请求已校验的测试批次作用域
|
||||
|
||||
:param scope: 测试批次作用域
|
||||
:return: 上下文变量令牌,用于重置
|
||||
"""
|
||||
return current_test_batch_scope.set(scope)
|
||||
|
||||
@staticmethod
|
||||
def get_current_test_batch_scope() -> TestBatchScope | None:
|
||||
"""获取当前请求已校验的测试批次作用域。"""
|
||||
return current_test_batch_scope.get()
|
||||
|
||||
@staticmethod
|
||||
def reset_current_exclude_patterns(token: Token) -> None:
|
||||
"""
|
||||
@@ -87,6 +105,11 @@ class RequestContext:
|
||||
"""
|
||||
current_user.reset(token)
|
||||
|
||||
@staticmethod
|
||||
def reset_current_test_batch_scope(token: Token) -> None:
|
||||
"""重置当前请求的测试批次作用域。"""
|
||||
current_test_batch_scope.reset(token)
|
||||
|
||||
@staticmethod
|
||||
def clear_all() -> None:
|
||||
"""
|
||||
@@ -94,3 +117,4 @@ class RequestContext:
|
||||
"""
|
||||
current_exclude_patterns.set(None)
|
||||
current_user.set(None)
|
||||
current_test_batch_scope.set(None)
|
||||
|
||||
@@ -13,11 +13,6 @@ def build_async_sqlalchemy_database_url() -> str:
|
||||
|
||||
:return: 异步 SQLAlchemy 数据库连接 URL
|
||||
"""
|
||||
if DataBaseConfig.db_type == 'postgresql':
|
||||
return (
|
||||
f'postgresql+asyncpg://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
|
||||
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
|
||||
)
|
||||
return (
|
||||
f'mysql+asyncmy://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
|
||||
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
|
||||
@@ -33,11 +28,6 @@ def build_sync_sqlalchemy_database_url() -> str:
|
||||
|
||||
:return: 同步 SQLAlchemy 数据库连接 URL
|
||||
"""
|
||||
if DataBaseConfig.db_type == 'postgresql':
|
||||
return (
|
||||
f'postgresql+psycopg2://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
|
||||
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
|
||||
)
|
||||
return (
|
||||
f'mysql+pymysql://{DataBaseConfig.db_username}:{quote_plus(DataBaseConfig.db_password)}@'
|
||||
f'{DataBaseConfig.db_host}:{DataBaseConfig.db_port}/{DataBaseConfig.db_database}'
|
||||
|
||||
@@ -47,7 +47,7 @@ class DataBaseSettings(BaseSettings):
|
||||
数据库配置
|
||||
"""
|
||||
|
||||
db_type: Literal['mysql', 'postgresql'] = 'mysql'
|
||||
db_type: Literal['mysql'] = 'mysql'
|
||||
db_host: str = '127.0.0.1'
|
||||
db_port: int = 3306
|
||||
db_username: str = 'root'
|
||||
@@ -62,8 +62,6 @@ class DataBaseSettings(BaseSettings):
|
||||
@computed_field
|
||||
@property
|
||||
def sqlglot_parse_dialect(self) -> str:
|
||||
if self.db_type == 'postgresql':
|
||||
return 'postgres'
|
||||
return self.db_type
|
||||
|
||||
|
||||
@@ -144,6 +142,15 @@ class TransportCryptoSettings(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class TestBatchSettings(BaseSettings):
|
||||
"""
|
||||
测试批次安全配置
|
||||
"""
|
||||
|
||||
test_batch_preview_ttl_seconds: int = 600
|
||||
test_batch_online_purge_max_rows: int = 0
|
||||
|
||||
|
||||
class GenSettings:
|
||||
"""
|
||||
代码生成配置
|
||||
@@ -265,6 +272,12 @@ class GetConfig:
|
||||
"""
|
||||
return TransportCryptoSettings()
|
||||
|
||||
def get_test_batch_config(self) -> TestBatchSettings:
|
||||
"""
|
||||
获取测试批次安全配置
|
||||
"""
|
||||
return TestBatchSettings()
|
||||
|
||||
def get_gen_config(self) -> GenSettings:
|
||||
"""
|
||||
获取代码生成配置
|
||||
@@ -328,6 +341,8 @@ RedisConfig = get_config.get_redis_config()
|
||||
LogConfig = get_config.get_log_config()
|
||||
# 传输层加解密配置
|
||||
TransportCryptoConfig = get_config.get_transport_crypto_config()
|
||||
# 测试批次安全配置
|
||||
TestBatchConfig = get_config.get_test_batch_config()
|
||||
# 代码生成配置
|
||||
GenConfig = get_config.get_gen_config()
|
||||
# 上传配置
|
||||
|
||||
@@ -38,23 +38,16 @@ shierfeng --help
|
||||
|
||||
### 2.2 安装依赖
|
||||
|
||||
MySQL 版本:
|
||||
安装依赖(统一使用 MySQL):
|
||||
|
||||
```bash
|
||||
cd shierfeng-fastapi-backend
|
||||
pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
PostgreSQL 版本:
|
||||
|
||||
```bash
|
||||
cd shierfeng-fastapi-backend
|
||||
pip3 install -r requirements-pg.txt
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `requirements*.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `pip install -e .`
|
||||
- `requirements.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `pip install -e .`
|
||||
- 安装完成后,`shierfeng` 会随当前 Python 环境一起可用
|
||||
|
||||
如果本地使用 Conda,推荐先进入项目环境再执行命令:
|
||||
@@ -65,7 +58,7 @@ cd shierfeng-fastapi-backend
|
||||
shierfeng --help
|
||||
```
|
||||
|
||||
`textual` 已包含在现有依赖文件中,因此安装 `requirements.txt` 或 `requirements-pg.txt` 后即可直接使用 TUI。
|
||||
`textual` 已包含在现有依赖文件中,安装 `requirements.txt` 后即可直接使用 TUI。
|
||||
|
||||
### 2.3 第一个命令
|
||||
|
||||
@@ -104,7 +97,6 @@ CLI 不单独维护配置系统,仍然复用项目原有的 `config/env.py`
|
||||
- `--env=dev` -> `.env.dev`
|
||||
- `--env=prod` -> `.env.prod`
|
||||
- `--env=dockermy` -> `.env.dockermy`
|
||||
- `--env=dockerpg` -> `.env.dockerpg`
|
||||
|
||||
### 3.3 帮助命令
|
||||
|
||||
@@ -494,7 +486,7 @@ shierfeng tui --env=prod
|
||||
- 当前 TUI 是只读巡检工作台,页面内的写操作入口会通过确认弹窗或向导二次确认
|
||||
- 页面切换快捷键为 `D/A/O/B/C/T/G/P/E`,分别对应总览、应用、运维、数据库、缓存、任务、代码生成、参数配置、加密
|
||||
- 通用快捷键包括 `R` 刷新、`Q` 退出、`S` 聚焦侧栏、`←/→` 切换焦点或区域、`J/K` 滚动、`PgUp/PgDn` 翻页、`Home/End` 首尾跳转
|
||||
- 若当前 Python 环境缺少 TUI 依赖,`shierfeng tui` 会返回失败结果并提示重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`
|
||||
- 若当前 Python 环境缺少 TUI 依赖,`shierfeng tui` 会返回失败结果并提示重新执行 `pip install -r requirements.txt`
|
||||
|
||||
## 6. 危险命令清单
|
||||
|
||||
@@ -814,7 +806,7 @@ shierfeng ops health --env=dev --output=json
|
||||
请依次确认:
|
||||
|
||||
- 当前目录是否为 `shierfeng-fastapi-backend`
|
||||
- 当前 Python 环境是否执行过 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`
|
||||
- 当前 Python 环境是否执行过 `pip install -r requirements.txt`
|
||||
- 当前终端是否真的使用了安装依赖的那个 Python/Conda 环境
|
||||
|
||||
### 8.2 命令报数据库或 Redis 连接失败
|
||||
|
||||
@@ -64,23 +64,20 @@ TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH=4096
|
||||
当前项目的 Docker 部署使用:
|
||||
|
||||
- `shierfeng-fastapi-test/docker-compose.test.my.yml` + `shierfeng-fastapi-backend/Dockerfile.my`
|
||||
- `shierfeng-fastapi-test/docker-compose.test.pg.yml` + `shierfeng-fastapi-backend/Dockerfile.pg`
|
||||
|
||||
后端容器启动命令分别是:
|
||||
|
||||
- `shierfeng app run --env=dockermy`
|
||||
- `shierfeng app run --env=dockerpg`
|
||||
|
||||
所以 Docker 环境需要直接在以下文件中配置传输层密钥:
|
||||
|
||||
- `shierfeng-fastapi-backend/.env.dockermy`
|
||||
- `shierfeng-fastapi-backend/.env.dockerpg`
|
||||
|
||||
配置方式与生产环境相同;`.env.dockermy` / `.env.dockerpg` 里也已经默认提供一套可用示例值,正式部署前请替换为正式密钥。
|
||||
配置方式与生产环境相同;`.env.dockermy` 里已经默认提供一套可用示例值,正式部署前请替换为正式密钥。
|
||||
|
||||
使用时只需要:
|
||||
|
||||
1. 修改对应的 `.env.dockermy` 或 `.env.dockerpg`
|
||||
1. 修改 `.env.dockermy`
|
||||
2. 重新构建并启动 Docker 服务
|
||||
|
||||
## 密钥生成
|
||||
|
||||
@@ -14,10 +14,10 @@ class ContextCleanupMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
在每个请求处理完成后清理上下文信息
|
||||
"""
|
||||
response = await call_next(request)
|
||||
# 请求处理完成后清理所有上下文变量
|
||||
RequestContext.clear_all()
|
||||
return response
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
RequestContext.clear_all()
|
||||
|
||||
|
||||
def add_context_cleanup_middleware(app: FastAPI) -> None:
|
||||
|
||||
@@ -60,6 +60,22 @@ class OperationLogDao:
|
||||
|
||||
return operation_log_list
|
||||
|
||||
@classmethod
|
||||
async def get_scan_exception_log_list(
|
||||
cls, db: AsyncSession, oper_name: str | None, page_size: int
|
||||
) -> PageModel:
|
||||
"""按扫码日志标题和精确操作人查询首页异常记录。"""
|
||||
query = (
|
||||
select(SysOperLog)
|
||||
.where(
|
||||
SysOperLog.title == '扫码操作',
|
||||
SysOperLog.status == 1,
|
||||
SysOperLog.oper_name == oper_name if oper_name is not None else True,
|
||||
)
|
||||
.order_by(desc(SysOperLog.oper_time))
|
||||
)
|
||||
return await PageUtil.paginate(db, query, page_num=1, page_size=page_size, is_page=True)
|
||||
|
||||
@classmethod
|
||||
async def add_operation_log_dao(cls, db: AsyncSession, operation_log: OperLogModel) -> SysOperLog:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CHAR, Column, DateTime, Integer, LargeBinary, String
|
||||
from sqlalchemy import CHAR, Column, DateTime, Integer, String
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from config.database import Base
|
||||
@@ -20,7 +20,7 @@ class SysNotice(Base):
|
||||
notice_title = Column(String(50), nullable=False, comment='公告标题')
|
||||
notice_type = Column(CHAR(1), nullable=False, comment='公告类型(1通知 2公告)')
|
||||
notice_content = Column(
|
||||
mysql.LONGBLOB if DataBaseConfig.db_type == 'mysql' else LargeBinary,
|
||||
mysql.LONGBLOB,
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type, False),
|
||||
comment='公告内容',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, SmallInteger, String
|
||||
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Integer, String
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from config.database import Base
|
||||
@@ -27,13 +27,13 @@ class SysRole(Base):
|
||||
comment='数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)',
|
||||
)
|
||||
menu_check_strictly = Column(
|
||||
mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger,
|
||||
mysql.TINYINT(display_width=1),
|
||||
nullable=True,
|
||||
server_default='1',
|
||||
comment='菜单树选择项是否关联显示',
|
||||
)
|
||||
dept_check_strictly = Column(
|
||||
mysql.TINYINT(display_width=1) if DataBaseConfig.db_type == 'mysql' else SmallInteger,
|
||||
mysql.TINYINT(display_width=1),
|
||||
nullable=True,
|
||||
server_default='1',
|
||||
comment='部门树选择项是否关联显示',
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Body, Path, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.cache_annotation import ApiCache, ApiCacheEvict
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.constant import ApiGroup, ApiNamespace
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_ai.entity.vo.ai_chat_vo import (
|
||||
AiChatConfigModel,
|
||||
AiChatRequestModel,
|
||||
AiChatSessionBaseModel,
|
||||
AiChatSessionModel,
|
||||
)
|
||||
from module_ai.service.ai_chat_service import AiChatService
|
||||
from utils.log_util import logger
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
ai_chat_controller = APIRouterPro(
|
||||
prefix='/ai/chat', order_num=19, tags=['AI管理-AI对话'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
|
||||
|
||||
@ai_chat_controller.post(
|
||||
'/send',
|
||||
summary='发送对话消息',
|
||||
description='流式返回对话结果',
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
'description': '流式返回对话结果',
|
||||
'content': {
|
||||
'text/event-stream': {},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@ApiRateLimit(namespace=ApiNamespace.AI_CHAT_SEND, preset=ApiRateLimitPreset.USER_INTERACTIVE_HIGH_FREQ)
|
||||
async def send_chat_message(
|
||||
request: Request,
|
||||
chat_req: AiChatRequestModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> StreamingResponse:
|
||||
user_id = current_user.user.user_id if current_user and current_user.user else 1
|
||||
chat_stream = AiChatService.chat_services(query_db, chat_req, user_id)
|
||||
logger.info(f'用户{user_id}发送对话消息成功')
|
||||
|
||||
return StreamingResponse(content=chat_stream, media_type='text/event-stream')
|
||||
|
||||
|
||||
@ai_chat_controller.get(
|
||||
'/config',
|
||||
summary='获取用户对话配置',
|
||||
description='获取当前用户的AI对话配置',
|
||||
response_model=DataResponseModel[AiChatConfigModel],
|
||||
)
|
||||
@ApiCache(namespace=ApiNamespace.AI_CHAT_CONFIG)
|
||||
async def get_user_chat_config(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
user_id = current_user.user.user_id
|
||||
ai_chat_config_detail_result = await AiChatService.ai_chat_config_detail_services(query_db, user_id)
|
||||
logger.info(f'获取user_id为{user_id}的对话配置成功')
|
||||
|
||||
return ResponseUtil.success(data=ai_chat_config_detail_result)
|
||||
|
||||
|
||||
@ai_chat_controller.put(
|
||||
'/config',
|
||||
summary='保存用户对话配置',
|
||||
description='保存当前用户的AI对话配置',
|
||||
response_model=DataResponseModel[AiChatConfigModel],
|
||||
)
|
||||
@ApiCacheEvict(namespaces=ApiGroup.AI_CHAT_CONFIG_MUTATION)
|
||||
@Log(title='AI对话配置管理', business_type=BusinessType.INSERT)
|
||||
async def save_user_chat_config(
|
||||
request: Request,
|
||||
ai_chat_config: AiChatConfigModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
user_id = current_user.user.user_id if current_user and current_user.user else 1
|
||||
save_ai_chat_config_result = await AiChatService.save_ai_chat_config_services(query_db, user_id, ai_chat_config)
|
||||
logger.info(save_ai_chat_config_result.message)
|
||||
|
||||
return ResponseUtil.success(msg=save_ai_chat_config_result.message)
|
||||
|
||||
|
||||
@ai_chat_controller.get(
|
||||
'/session/list',
|
||||
summary='获取会话列表',
|
||||
description='获取用户的会话列表',
|
||||
response_model=DataResponseModel[list[AiChatSessionBaseModel]],
|
||||
)
|
||||
async def get_chat_session_list(
|
||||
request: Request,
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await AiChatService.get_chat_session_list_services(current_user.user.user_id)
|
||||
logger.info('获取成功')
|
||||
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@ai_chat_controller.delete(
|
||||
'/session/{session_id}',
|
||||
summary='删除会话',
|
||||
description='删除指定会话',
|
||||
response_model=ResponseBaseModel,
|
||||
)
|
||||
@Log(title='AI对话会话管理', business_type=BusinessType.DELETE)
|
||||
async def delete_chat_session(
|
||||
request: Request,
|
||||
session_id: Annotated[str, Path(description='会话ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
) -> Response:
|
||||
delete_chat_session_result = await AiChatService.delete_chat_session_services(session_id)
|
||||
logger.info(delete_chat_session_result.message)
|
||||
|
||||
return ResponseUtil.success(msg=delete_chat_session_result.message)
|
||||
|
||||
|
||||
@ai_chat_controller.get(
|
||||
'/session/{session_id}',
|
||||
summary='获取会话消息详情',
|
||||
description='获取指定会话的消息详情',
|
||||
response_model=DataResponseModel[AiChatSessionModel],
|
||||
)
|
||||
async def get_chat_session_detail(
|
||||
request: Request,
|
||||
session_id: Annotated[str, Path(description='会话ID')],
|
||||
) -> Response:
|
||||
chat_session_detail_result = await AiChatService.get_chat_session_detail_services(session_id)
|
||||
logger.info(f'获取session_id为{session_id}的信息成功')
|
||||
|
||||
return ResponseUtil.success(data=chat_session_detail_result)
|
||||
|
||||
|
||||
@ai_chat_controller.post(
|
||||
'/cancel',
|
||||
summary='取消对话',
|
||||
description='取消正在进行的对话',
|
||||
response_model=ResponseBaseModel,
|
||||
)
|
||||
@ApiRateLimit(namespace=ApiNamespace.AI_CHAT_CANCEL, preset=ApiRateLimitPreset.USER_INTERACTIVE_HIGH_FREQ)
|
||||
async def cancel_chat_run(
|
||||
request: Request,
|
||||
run_id: Annotated[str, Body(embed=True, description='运行ID', alias='runId')],
|
||||
) -> Response:
|
||||
cancel_result = await AiChatService.cancel_run_services(run_id)
|
||||
logger.info(cancel_result.message)
|
||||
|
||||
return ResponseUtil.success(msg=cancel_result.message)
|
||||
@@ -1,188 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from pydantic_validation_decorator import ValidateFields
|
||||
from sqlalchemy import ColumnElement
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.cache_annotation import ApiCache, ApiCacheEvict
|
||||
from common.annotation.log_annotation import Log, RequestLogFieldRoot
|
||||
from common.aspect.data_scope import DataScopeDependency
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.constant import ApiGroup, ApiNamespace
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_ai.entity.do.ai_model_do import AiModels
|
||||
from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel
|
||||
from module_ai.service.ai_model_service import AiModelService
|
||||
from utils.log_util import logger
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
ai_model_controller = APIRouterPro(
|
||||
prefix='/ai/model', order_num=18, tags=['AI管理-模型管理'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
|
||||
|
||||
@ai_model_controller.get(
|
||||
'/list',
|
||||
summary='获取AI模型分页列表接口',
|
||||
description='用于获取AI模型分页列表',
|
||||
response_model=PageResponseModel[AiModelModel],
|
||||
dependencies=[UserInterfaceAuthDependency('ai:model:list')],
|
||||
)
|
||||
@ApiCache(namespace=ApiNamespace.AI_MODEL_LIST)
|
||||
async def get_ai_model_list(
|
||||
request: Request,
|
||||
ai_model_page_query: Annotated[AiModelPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
|
||||
) -> Response:
|
||||
# 获取分页数据
|
||||
result = await AiModelService.get_ai_model_list_services(
|
||||
query_db, ai_model_page_query, data_scope_sql, is_page=True
|
||||
)
|
||||
logger.info('获取成功')
|
||||
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@ai_model_controller.get(
|
||||
'/all',
|
||||
summary='获取AI模型不分页列表接口',
|
||||
description='用于获取AI模型不分页列表',
|
||||
response_model=DataResponseModel[AiModelModel],
|
||||
)
|
||||
@ApiCache(namespace=ApiNamespace.AI_MODEL_ALL)
|
||||
async def get_ai_model_all(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
|
||||
) -> Response:
|
||||
# 获取不分页数据
|
||||
ai_model_page_query = AiModelPageQueryModel(status='0')
|
||||
result = await AiModelService.get_ai_model_list_services(
|
||||
query_db, ai_model_page_query, data_scope_sql, is_page=False
|
||||
)
|
||||
logger.info('获取成功')
|
||||
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@ai_model_controller.post(
|
||||
'',
|
||||
summary='新增AI模型接口',
|
||||
description='用于新增AI模型',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('ai:model:add')],
|
||||
)
|
||||
@ValidateFields(validate_model='add_ai_model')
|
||||
@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION)
|
||||
@Log(
|
||||
title='AI模型管理',
|
||||
business_type=BusinessType.INSERT,
|
||||
request_log_mode='exclude',
|
||||
request_exclude_fields=(RequestLogFieldRoot.JSON_BODY.field('api_key'),),
|
||||
)
|
||||
async def add_ai_model(
|
||||
request: Request,
|
||||
add_ai_model: AiModelModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
add_ai_model.user_id = current_user.user.user_id
|
||||
add_ai_model.dept_id = current_user.user.dept_id
|
||||
add_ai_model.create_by = current_user.user.user_name
|
||||
add_ai_model.create_time = datetime.now()
|
||||
add_ai_model.update_by = current_user.user.user_name
|
||||
add_ai_model.update_time = datetime.now()
|
||||
add_ai_model_result = await AiModelService.add_ai_model_services(query_db, add_ai_model)
|
||||
logger.info(add_ai_model_result.message)
|
||||
|
||||
return ResponseUtil.success(msg=add_ai_model_result.message)
|
||||
|
||||
|
||||
@ai_model_controller.put(
|
||||
'',
|
||||
summary='编辑AI模型接口',
|
||||
description='用于编辑AI模型',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('ai:model:edit')],
|
||||
)
|
||||
@ValidateFields(validate_model='edit_ai_model')
|
||||
@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION)
|
||||
@Log(
|
||||
title='AI模型管理',
|
||||
business_type=BusinessType.UPDATE,
|
||||
request_log_mode='exclude',
|
||||
request_exclude_fields=(RequestLogFieldRoot.JSON_BODY.field('api_key'),),
|
||||
)
|
||||
async def edit_ai_model(
|
||||
request: Request,
|
||||
edit_ai_model: AiModelModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
|
||||
) -> Response:
|
||||
if not current_user.user.admin:
|
||||
await AiModelService.check_ai_model_data_scope_services(query_db, edit_ai_model.model_id, data_scope_sql)
|
||||
edit_ai_model.update_by = current_user.user.user_name
|
||||
edit_ai_model.update_time = datetime.now()
|
||||
edit_ai_model_result = await AiModelService.edit_ai_model_services(query_db, edit_ai_model)
|
||||
logger.info(edit_ai_model_result.message)
|
||||
|
||||
return ResponseUtil.success(msg=edit_ai_model_result.message)
|
||||
|
||||
|
||||
@ai_model_controller.delete(
|
||||
'/{model_ids}',
|
||||
summary='删除AI模型接口',
|
||||
description='用于删除AI模型',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('ai:model:remove')],
|
||||
)
|
||||
@ApiCacheEvict(namespaces=ApiGroup.AI_MODEL_MUTATION)
|
||||
@Log(title='AI模型管理', business_type=BusinessType.DELETE)
|
||||
async def delete_ai_model(
|
||||
request: Request,
|
||||
model_ids: Annotated[str, Path(description='需要删除的模型ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
|
||||
) -> Response:
|
||||
model_id_list = model_ids.split(',')
|
||||
for model_id in model_id_list:
|
||||
if not current_user.user.admin:
|
||||
await AiModelService.check_ai_model_data_scope_services(query_db, int(model_id), data_scope_sql)
|
||||
delete_ai_model = DeleteAiModelModel(modelIds=model_ids)
|
||||
delete_ai_model_result = await AiModelService.delete_ai_model_services(query_db, delete_ai_model)
|
||||
logger.info(delete_ai_model_result.message)
|
||||
|
||||
return ResponseUtil.success(msg=delete_ai_model_result.message)
|
||||
|
||||
|
||||
@ai_model_controller.get(
|
||||
'/{model_id}',
|
||||
summary='获取AI模型详情接口',
|
||||
description='用于获取指定AI模型的详细信息',
|
||||
response_model=DataResponseModel[AiModelModel],
|
||||
dependencies=[UserInterfaceAuthDependency('ai:model:query')],
|
||||
)
|
||||
@ApiCache(namespace=ApiNamespace.AI_MODEL_DETAIL)
|
||||
async def get_ai_model_detail(
|
||||
request: Request,
|
||||
model_id: Annotated[int, Path(description='模型ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)],
|
||||
) -> Response:
|
||||
if not current_user.user.admin:
|
||||
await AiModelService.check_ai_model_data_scope_services(query_db, model_id, data_scope_sql)
|
||||
ai_model_detail_result = await AiModelService.ai_model_detail_services(query_db, model_id)
|
||||
logger.info(f'获取model_id为{model_id}的信息成功')
|
||||
|
||||
return ResponseUtil.success(data=ai_model_detail_result)
|
||||
@@ -1,52 +0,0 @@
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from module_ai.entity.do.ai_chat_do import AiChatConfig
|
||||
from module_ai.entity.vo.ai_chat_vo import AiChatConfigModel
|
||||
|
||||
|
||||
class AiChatConfigDao:
|
||||
"""
|
||||
AI对话配置数据库操作层
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_chat_config_detail_by_user_id(cls, db: AsyncSession, user_id: int) -> AiChatConfig | None:
|
||||
"""
|
||||
根据用户ID获取配置
|
||||
|
||||
:param db: orm对象
|
||||
:param user_id: 用户ID
|
||||
:return: 配置对象
|
||||
"""
|
||||
ai_chat_config = (
|
||||
(await db.execute(select(AiChatConfig).where(AiChatConfig.user_id == user_id))).scalars().first()
|
||||
)
|
||||
|
||||
return ai_chat_config
|
||||
|
||||
@classmethod
|
||||
async def add_chat_config_dao(cls, db: AsyncSession, chat_config: AiChatConfigModel) -> AiChatConfig:
|
||||
"""
|
||||
新增对话配置数据库操作
|
||||
|
||||
:param db: orm对象
|
||||
:param chat_config: 对话配置对象
|
||||
:return: 配置对象
|
||||
"""
|
||||
db_chat_config = AiChatConfig(**chat_config.model_dump(exclude_unset=True))
|
||||
db.add(db_chat_config)
|
||||
await db.flush()
|
||||
|
||||
return db_chat_config
|
||||
|
||||
@classmethod
|
||||
async def edit_chat_config_dao(cls, db: AsyncSession, chat_config: dict) -> None:
|
||||
"""
|
||||
编辑对话配置数据库操作
|
||||
|
||||
:param db: orm对象
|
||||
:param chat_config: 需要更新的对话配置字典
|
||||
:return:
|
||||
"""
|
||||
await db.execute(update(AiChatConfig), [chat_config])
|
||||
@@ -1,96 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_ai.entity.do.ai_model_do import AiModels
|
||||
from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
class AiModelDao:
|
||||
"""
|
||||
AI模型管理数据库操作层
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_ai_model_detail_by_id(cls, db: AsyncSession, model_id: int) -> AiModels | None:
|
||||
"""
|
||||
根据AI模型id获取AI模型详细信息
|
||||
|
||||
:param db: orm对象
|
||||
:param model_id: AI模型id
|
||||
:return: AI模型信息对象
|
||||
"""
|
||||
ai_model_info = (await db.execute(select(AiModels).where(AiModels.model_id == model_id))).scalars().first()
|
||||
|
||||
return ai_model_info
|
||||
|
||||
@classmethod
|
||||
async def get_ai_model_list(
|
||||
cls, db: AsyncSession, query_object: AiModelPageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
"""
|
||||
根据查询参数获取AI模型列表信息
|
||||
|
||||
:param db: orm对象
|
||||
:param query_object: 查询参数对象
|
||||
:param data_scope_sql: 数据权限对应的查询sql语句
|
||||
:param is_page: 是否开启分页
|
||||
:return: AI模型列表信息对象
|
||||
"""
|
||||
query = (
|
||||
select(AiModels)
|
||||
.where(
|
||||
AiModels.model_id == query_object.model_id if query_object.model_id else True,
|
||||
AiModels.model_name.like(f'%{query_object.model_name}%') if query_object.model_name else True,
|
||||
AiModels.model_code.like(f'%{query_object.model_code}%') if query_object.model_code else True,
|
||||
AiModels.provider == query_object.provider if query_object.provider else True,
|
||||
AiModels.status == query_object.status if query_object.status else True,
|
||||
data_scope_sql,
|
||||
)
|
||||
.order_by(AiModels.model_sort)
|
||||
)
|
||||
ai_model_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate(
|
||||
db, query, query_object.page_num, query_object.page_size, is_page
|
||||
)
|
||||
|
||||
return ai_model_list
|
||||
|
||||
@classmethod
|
||||
async def add_ai_model_dao(cls, db: AsyncSession, ai_model: AiModelModel) -> AiModels:
|
||||
"""
|
||||
新增AI模型数据库操作
|
||||
|
||||
:param db: orm对象
|
||||
:param ai_model: AI模型对象
|
||||
:return: AI模型信息对象
|
||||
"""
|
||||
db_model = AiModels(**ai_model.model_dump(exclude_unset=True))
|
||||
db.add(db_model)
|
||||
await db.flush()
|
||||
|
||||
return db_model
|
||||
|
||||
@classmethod
|
||||
async def edit_ai_model_dao(cls, db: AsyncSession, ai_model: dict) -> None:
|
||||
"""
|
||||
编辑AI模型数据库操作
|
||||
|
||||
:param db: orm对象
|
||||
:param ai_model: 需要更新的AI模型字典
|
||||
:return:
|
||||
"""
|
||||
await db.execute(update(AiModels), [ai_model])
|
||||
|
||||
@classmethod
|
||||
async def delete_ai_model_dao(cls, db: AsyncSession, ai_model: AiModelModel) -> None:
|
||||
"""
|
||||
删除AI模型数据库操作
|
||||
|
||||
:param db: orm对象
|
||||
:param ai_model: AI模型对象
|
||||
:return:
|
||||
"""
|
||||
await db.execute(delete(AiModels).where(AiModels.model_id.in_([ai_model.model_id])))
|
||||
@@ -1,26 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Float, Integer, Text
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class AiChatConfig(Base):
|
||||
"""
|
||||
AI对话配置表
|
||||
"""
|
||||
|
||||
__tablename__ = 'ai_chat_config'
|
||||
__table_args__ = {'comment': 'AI对话配置表'}
|
||||
|
||||
chat_config_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='配置主键')
|
||||
user_id = Column(BigInteger, nullable=False, unique=True, comment='用户ID')
|
||||
temperature = Column(Float, nullable=True, comment='默认温度')
|
||||
add_history_to_context = Column(CHAR(1), server_default='0', comment='是否添加历史记录(0是, 1否)')
|
||||
num_history_runs = Column(Integer, nullable=True, comment='历史记录条数')
|
||||
system_prompt = Column(Text, nullable=True, comment='系统提示词')
|
||||
metrics_default_visible = Column(CHAR(1), server_default='0', comment='默认显示指标(0是, 1否)')
|
||||
vision_enabled = Column(CHAR(1), server_default='1', comment='是否开启视觉(0是, 1否)')
|
||||
image_max_size_mb = Column(Integer, nullable=True, comment='图片最大大小(MB)')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
|
||||
update_time = Column(DateTime, nullable=True, default=datetime.now, comment='更新时间')
|
||||
@@ -1,62 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CHAR, BigInteger, Column, DateTime, Float, Integer, String
|
||||
|
||||
from config.database import Base
|
||||
from config.env import DataBaseConfig
|
||||
from utils.common_util import SqlalchemyUtil
|
||||
|
||||
|
||||
class AiModels(Base):
|
||||
"""
|
||||
AI模型表
|
||||
"""
|
||||
|
||||
__tablename__ = 'ai_models'
|
||||
__table_args__ = {'comment': 'AI模型表'}
|
||||
|
||||
model_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='模型主键')
|
||||
model_code = Column(String(100), nullable=False, comment='模型编码')
|
||||
model_name = Column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='模型名称',
|
||||
)
|
||||
provider = Column(String(50), nullable=False, comment='提供商')
|
||||
model_sort = Column(Integer, nullable=False, comment='显示顺序')
|
||||
api_key = Column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='API Key',
|
||||
)
|
||||
base_url = Column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='Base URL',
|
||||
)
|
||||
model_type = Column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='模型类型',
|
||||
)
|
||||
max_tokens = Column(Integer, nullable=True, comment='最大输出token')
|
||||
temperature = Column(Float, nullable=True, comment='默认温度')
|
||||
support_reasoning = Column(CHAR(1), server_default='N', comment='是否支持推理')
|
||||
support_images = Column(CHAR(1), server_default='N', comment='是否支持图片')
|
||||
status = Column(CHAR(1), server_default='0', comment='模型状态')
|
||||
user_id = Column(BigInteger, nullable=True, comment='用户ID')
|
||||
dept_id = Column(BigInteger, nullable=True, comment='部门ID')
|
||||
create_by = Column(String(64), nullable=True, server_default="''", comment='创建者')
|
||||
create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间')
|
||||
update_by = Column(String(64), nullable=True, server_default="''", comment='更新者')
|
||||
update_time = Column(DateTime, nullable=True, default=datetime.now(), comment='更新时间')
|
||||
remark = Column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
|
||||
comment='备注',
|
||||
)
|
||||
@@ -1,159 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class AiChatRequestModel(BaseModel):
|
||||
"""
|
||||
AI对话请求模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
session_id: str | None = Field(default=None, description='会话ID')
|
||||
model_id: int = Field(description='模型ID')
|
||||
message: str = Field(description='用户消息')
|
||||
is_reasoning: bool | None = Field(default=None, description='本次是否开启深度思考')
|
||||
images: list[str] | None = Field(default=None, description='图片URL列表')
|
||||
|
||||
|
||||
class AiChatConfigModel(BaseModel):
|
||||
"""
|
||||
AI对话配置模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
chat_config_id: int | None = Field(default=None, description='配置主键')
|
||||
user_id: int | None = Field(default=None, description='用户ID')
|
||||
temperature: float | None = Field(default=None, description='默认温度')
|
||||
add_history_to_context: Literal['0', '1'] | None = Field(default=None, description='是否添加历史记录')
|
||||
num_history_runs: int | None = Field(default=3, description='历史记录条数')
|
||||
system_prompt: str | None = Field(default=None, description='系统提示词')
|
||||
metrics_default_visible: Literal['0', '1'] | None = Field(default=None, description='默认显示指标')
|
||||
vision_enabled: Literal['0', '1'] | None = Field(default=None, description='是否开启视觉')
|
||||
image_max_size_mb: int | None = Field(default=None, description='图片最大大小(MB)')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
update_time: datetime | None = Field(default=None, description='更新时间')
|
||||
|
||||
|
||||
class AiChatSessionBaseModel(BaseModel):
|
||||
"""
|
||||
AI对话会话基础模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
session_id: str = Field(description='会话ID')
|
||||
session_title: str | None = Field(default=None, description='会话标题')
|
||||
session_type: str | None = Field(default=None, description='会话类型')
|
||||
user_id: str | None = Field(default=None, description='用户ID')
|
||||
created_at: datetime | None = Field(default=None, description='创建时间')
|
||||
updated_at: datetime | None = Field(default=None, description='更新时间')
|
||||
|
||||
|
||||
class ModelInfoModel(BaseModel):
|
||||
"""
|
||||
对话会话数据模型-模型信息模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
id: str | None = Field(default=None, description='模型ID')
|
||||
name: str | None = Field(default=None, description='模型名称')
|
||||
provider: str | None = Field(default=None, description='模型提供者')
|
||||
temperature: float | None = Field(default=None, description='模型温度')
|
||||
|
||||
|
||||
class AgentDataModel(BaseModel):
|
||||
"""
|
||||
对话会话数据模型-智能体数据模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
model: ModelInfoModel | None = Field(default=None, description='模型信息')
|
||||
agent_id: str | None = Field(default=None, description='智能体ID')
|
||||
|
||||
|
||||
class SessionMetricsModel(BaseModel):
|
||||
"""
|
||||
对话会话数据模型-会话指标模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
cost: float | None = Field(default=None, description='成本(元)')
|
||||
timer: float | None = Field(default=None, description='运行时长(秒)')
|
||||
duration: float | None = Field(default=None, description='运行时长(秒)')
|
||||
input_tokens: int | None = Field(default=None, description='输入Token数量')
|
||||
total_tokens: int | None = Field(default=None, description='总Token数量')
|
||||
output_tokens: int | None = Field(default=None, description='输出Token数量')
|
||||
provider_metrics: dict | None = Field(default=None, description='提供者指标')
|
||||
reasoning_tokens: int | None = Field(default=None, description='推理Token数量')
|
||||
cache_read_tokens: int | None = Field(default=None, description='缓存读取Token数量')
|
||||
additional_metrics: dict | None = Field(default=None, description='其他指标')
|
||||
audio_input_tokens: int | None = Field(default=None, description='音频输入Token数量')
|
||||
audio_total_tokens: int | None = Field(default=None, description='音频总Token数量')
|
||||
cache_write_tokens: int | None = Field(default=None, description='缓存写入Token数量')
|
||||
audio_output_tokens: int | None = Field(default=None, description='音频输出Token数量')
|
||||
time_to_first_token: float | None = Field(default=None, description='到第一个Token的时间(秒)')
|
||||
|
||||
|
||||
class SessionDataModel(BaseModel):
|
||||
"""
|
||||
对话会话数据模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
session_state: dict | None = Field(default=None, description='会话状态')
|
||||
session_metrics: SessionMetricsModel | None = Field(default=None, description='会话指标')
|
||||
|
||||
|
||||
class MessageMetrics(BaseModel):
|
||||
"""
|
||||
对话消息模型-消息指标模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
input_tokens: int | None = Field(default=None, description='输入Token数量')
|
||||
total_tokens: int | None = Field(default=None, description='总Token数量')
|
||||
output_tokens: int | None = Field(default=None, description='输出Token数量')
|
||||
reasoning_tokens: int | None = Field(default=None, description='推理Token数量')
|
||||
duration: float | None = Field(default=None, description='运行时长(秒)')
|
||||
time_to_first_token: float | None = Field(default=None, description='到第一个Token的时间(秒)')
|
||||
|
||||
|
||||
class ChatMessageModel(BaseModel):
|
||||
"""
|
||||
对话消息模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
id: str | None = Field(default=None, description='消息ID')
|
||||
role: str | None = Field(default=None, description='角色')
|
||||
content: str | None = Field(default=None, description='内容')
|
||||
images: list[str] | None = Field(default=None, description='图片列表')
|
||||
metrics: MessageMetrics | None = Field(default=None, description='Token使用统计')
|
||||
created_at: datetime | None = Field(default=None, description='创建时间')
|
||||
from_history: bool | None = Field(default=None, description='是否来自历史记录')
|
||||
reasoning_content: str | None = Field(default=None, description='推理/思考内容')
|
||||
stop_after_tool_call: bool | None = Field(default=None, description='是否在工具调用后停止')
|
||||
|
||||
|
||||
class AiChatSessionModel(AiChatSessionBaseModel):
|
||||
"""
|
||||
AI对话会话模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
agent_id: str | None = Field(default=None, description='智能体ID')
|
||||
session_data: SessionDataModel | None = Field(default=None, description='会话数据')
|
||||
agent_data: AgentDataModel | None = Field(default=None, description='智能体数据')
|
||||
messages: list[ChatMessageModel] | None = Field(default=None, description='消息列表')
|
||||
@@ -1,64 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
from pydantic_validation_decorator import NotBlank, Size
|
||||
|
||||
|
||||
class AiModelModel(BaseModel):
|
||||
"""
|
||||
AI模型表对应pydantic模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
|
||||
|
||||
model_id: int | None = Field(default=None, description='模型主键')
|
||||
model_code: str | None = Field(default=None, description='模型编码')
|
||||
model_name: str | None = Field(default=None, description='模型名称')
|
||||
provider: str | None = Field(default=None, description='提供商')
|
||||
model_sort: int | None = Field(default=None, description='显示顺序')
|
||||
api_key: str | None = Field(default=None, description='API Key')
|
||||
base_url: str | None = Field(default=None, description='Base URL')
|
||||
max_tokens: int | None = Field(default=None, description='最大输出token')
|
||||
temperature: float | None = Field(default=None, description='默认温度')
|
||||
support_reasoning: Literal['Y', 'N'] | None = Field(default=None, description='是否支持推理(深度思考)')
|
||||
support_images: Literal['Y', 'N'] | None = Field(default=None, description='是否支持图片')
|
||||
model_type: str | None = Field(default=None, description='模型类型')
|
||||
status: Literal['0', '1'] | None = Field(default=None, description='模型状态')
|
||||
user_id: int | None = Field(default=None, description='用户ID')
|
||||
dept_id: int | None = Field(default=None, description='部门ID')
|
||||
create_by: str | None = Field(default=None, description='创建者')
|
||||
create_time: datetime | None = Field(default=None, description='创建时间')
|
||||
update_by: str | None = Field(default=None, description='更新者')
|
||||
update_time: datetime | None = Field(default=None, description='更新时间')
|
||||
remark: str | None = Field(default=None, description='备注')
|
||||
|
||||
@NotBlank(field_name='model_code', message='模型编码不能为空')
|
||||
@Size(field_name='model_code', min_length=0, max_length=100, message='模型编码长度不能超过100个字符')
|
||||
def get_model_code(self) -> str | None:
|
||||
return self.model_code
|
||||
|
||||
@NotBlank(field_name='base_url', message='Base URL不能为空')
|
||||
@Size(field_name='base_url', min_length=0, max_length=255, message='Base URL长度不能超过255个字符')
|
||||
def get_base_url(self) -> str | None:
|
||||
return self.base_url
|
||||
|
||||
|
||||
class AiModelPageQueryModel(AiModelModel):
|
||||
"""
|
||||
AI模型管理分页查询模型
|
||||
"""
|
||||
|
||||
page_num: int = Field(default=1, description='当前页码')
|
||||
page_size: int = Field(default=10, description='每页记录数')
|
||||
|
||||
|
||||
class DeleteAiModelModel(BaseModel):
|
||||
"""
|
||||
删除AI模型模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel)
|
||||
|
||||
model_ids: str = Field(description='需要删除的模型主键')
|
||||
@@ -1,483 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agno.agent import Agent
|
||||
from agno.db.base import SessionType
|
||||
from agno.media import Image
|
||||
from agno.run.agent import RunEvent, RunOutput, RunOutputEvent
|
||||
from agno.run.cancel import acancel_run
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import CrudResponseModel
|
||||
from config.env import UploadConfig
|
||||
from exceptions.exception import ServiceException
|
||||
from module_ai.dao.ai_chat_dao import AiChatConfigDao
|
||||
from module_ai.dao.ai_model_dao import AiModelDao
|
||||
from module_ai.entity.do.ai_chat_do import AiChatConfig
|
||||
from module_ai.entity.vo.ai_chat_vo import (
|
||||
AgentDataModel,
|
||||
AiChatConfigModel,
|
||||
AiChatRequestModel,
|
||||
AiChatSessionBaseModel,
|
||||
AiChatSessionModel,
|
||||
ChatMessageModel,
|
||||
MessageMetrics,
|
||||
SessionDataModel,
|
||||
SessionMetricsModel,
|
||||
)
|
||||
from module_ai.entity.vo.ai_model_vo import AiModelModel
|
||||
from utils.ai_util import AiUtil
|
||||
from utils.common_util import CamelCaseUtil
|
||||
from utils.crypto_util import CryptoUtil
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agno.models.message import Message
|
||||
from agno.run.team import TeamRunOutput
|
||||
from agno.run.workflow import WorkflowRunOutput
|
||||
from agno.session import Session
|
||||
|
||||
|
||||
class AiChatService:
|
||||
"""
|
||||
AI对话服务层
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _resolve_temperature(cls, user_config: AiChatConfigModel, model_config: AiModelModel) -> float:
|
||||
"""
|
||||
解析温度配置,优先级为 用户配置 > 模型配置
|
||||
|
||||
:param user_config: 用户配置对象
|
||||
:param model_config: 模型配置对象
|
||||
:return: 解析后的温度值
|
||||
"""
|
||||
temperature = user_config.temperature or model_config.temperature
|
||||
return temperature
|
||||
|
||||
@classmethod
|
||||
def _resolve_is_reasoning(cls, chat_req: AiChatRequestModel, model_config: AiModelModel) -> bool:
|
||||
"""
|
||||
解析深度思考开关,结合请求参数与模型配置确定最终是否开启
|
||||
|
||||
:param chat_req: 对话请求对象
|
||||
:param model_config: 模型配置对象
|
||||
:return: 是否开启深度思考
|
||||
"""
|
||||
if model_config.support_reasoning != 'Y':
|
||||
return False
|
||||
return bool(chat_req.is_reasoning)
|
||||
|
||||
@classmethod
|
||||
def _resolve_history_config(cls, user_config: AiChatConfigModel) -> tuple[bool, int]:
|
||||
"""
|
||||
解析历史消息配置,确定是否附带历史以及轮数
|
||||
|
||||
:param user_config: 用户配置对象
|
||||
:return: (是否附带历史, 历史轮数)
|
||||
"""
|
||||
add_history = user_config.add_history_to_context == '0'
|
||||
num_history = user_config.num_history_runs or 3
|
||||
|
||||
return bool(add_history), int(num_history)
|
||||
|
||||
@classmethod
|
||||
def _build_agent(
|
||||
cls,
|
||||
model_config: AiModelModel,
|
||||
temperature: float,
|
||||
system_prompt: str | None,
|
||||
user_id: int,
|
||||
session_id: str,
|
||||
add_history: bool,
|
||||
num_history: int,
|
||||
) -> Agent:
|
||||
"""
|
||||
构建对话Agent对象
|
||||
|
||||
:param model_config: 模型配置对象
|
||||
:param temperature: 对话温度
|
||||
:param system_prompt: 系统提示词
|
||||
:param user_id: 用户ID
|
||||
:param session_id: 会话ID
|
||||
:param add_history: 是否附带历史消息
|
||||
:param num_history: 历史消息轮数
|
||||
:return: Agent对象
|
||||
"""
|
||||
real_api_key = CryptoUtil.decrypt(model_config.api_key)
|
||||
|
||||
model = AiUtil.get_model_from_factory(
|
||||
provider=model_config.provider,
|
||||
model_code=model_config.model_code,
|
||||
model_name=model_config.model_name,
|
||||
api_key=real_api_key,
|
||||
base_url=model_config.base_url,
|
||||
temperature=temperature,
|
||||
max_tokens=model_config.max_tokens,
|
||||
)
|
||||
storage = AiUtil.get_storage_engine()
|
||||
return Agent(
|
||||
model=model,
|
||||
id='chat-agent',
|
||||
description=system_prompt or 'You are a helpful AI assistant.',
|
||||
db=storage,
|
||||
user_id=str(user_id),
|
||||
session_id=session_id,
|
||||
add_history_to_context=add_history,
|
||||
num_history_runs=num_history,
|
||||
markdown=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_run_kwargs(
|
||||
cls,
|
||||
chat_req: AiChatRequestModel,
|
||||
user_config: AiChatConfigModel,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
构造Agent运行参数
|
||||
|
||||
:param chat_req: 对话请求对象
|
||||
:param user_config: 用户配置对象
|
||||
:return: 运行参数字典
|
||||
"""
|
||||
run_kwargs: dict[str, Any] = {'stream': True, 'stream_events': True}
|
||||
if not chat_req.images or not user_config.vision_enabled:
|
||||
return run_kwargs
|
||||
|
||||
processed_images: list[Image] = []
|
||||
for img in chat_req.images:
|
||||
if img and img.startswith(UploadConfig.UPLOAD_PREFIX):
|
||||
relative_path = img[len(UploadConfig.UPLOAD_PREFIX) :]
|
||||
if relative_path.startswith('/'):
|
||||
relative_path = relative_path[1:]
|
||||
file_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path)
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if os.path.exists(abs_path):
|
||||
processed_images.append(Image(filepath=abs_path))
|
||||
run_kwargs['images'] = processed_images
|
||||
return run_kwargs
|
||||
|
||||
@classmethod
|
||||
def _convert_images_to_upload_paths(cls, images: list[Image] | None) -> list[str] | None:
|
||||
"""
|
||||
将Agno Image对象列表转换为前端可访问的上传路径列表
|
||||
|
||||
:param images: Image对象列表
|
||||
:return: 上传路径列表
|
||||
"""
|
||||
if not images:
|
||||
return None
|
||||
|
||||
result = []
|
||||
for img in images:
|
||||
# 如果是本地文件路径
|
||||
if hasattr(img, 'filepath') and img.filepath:
|
||||
try:
|
||||
# 使用 abspath 确保路径标准化
|
||||
abs_filepath = os.path.abspath(img.filepath)
|
||||
abs_upload_path = os.path.abspath(UploadConfig.UPLOAD_PATH)
|
||||
|
||||
if abs_filepath.startswith(abs_upload_path):
|
||||
relative_path = os.path.relpath(abs_filepath, abs_upload_path)
|
||||
# 转换路径分隔符为URL格式
|
||||
url_path = relative_path.replace(os.sep, '/')
|
||||
# 拼接前缀
|
||||
full_url = f'{UploadConfig.UPLOAD_PREFIX}/{url_path}'.replace('//', '/')
|
||||
result.append(full_url)
|
||||
else:
|
||||
result.append(img.filepath)
|
||||
except Exception:
|
||||
result.append(img.filepath)
|
||||
# 如果是URL
|
||||
elif hasattr(img, 'url') and img.url:
|
||||
result.append(img.url)
|
||||
|
||||
return result if result else None
|
||||
|
||||
@classmethod
|
||||
async def _stream_agent(
|
||||
cls,
|
||||
agent: Agent,
|
||||
chat_req: AiChatRequestModel,
|
||||
run_kwargs: dict[str, Any],
|
||||
is_reasoning: bool,
|
||||
session_id: str,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
将Agent输出流式转换为前端SSE消息
|
||||
|
||||
:param agent: Agent实例
|
||||
:param chat_req: 对话请求对象
|
||||
:param run_kwargs: 运行参数字典
|
||||
:param is_reasoning: 是否输出推理内容
|
||||
:param session_id: 会话ID
|
||||
:return: SSE消息生成器
|
||||
"""
|
||||
full_response = ''
|
||||
full_reasoning = ''
|
||||
try:
|
||||
yield json.dumps({'session_id': session_id, 'type': 'meta'}) + '\n'
|
||||
|
||||
response_stream: AsyncIterator[RunOutputEvent] = agent.arun(chat_req.message, **run_kwargs)
|
||||
|
||||
async for chunk in response_stream:
|
||||
content = None
|
||||
reasoning = None
|
||||
|
||||
if chunk.event == RunEvent.run_started and chunk.run_id:
|
||||
yield json.dumps({'run_id': chunk.run_id, 'type': 'run_info'}) + '\n'
|
||||
|
||||
if chunk.event == RunEvent.run_content:
|
||||
content = chunk.content
|
||||
if hasattr(chunk, 'reasoning_content') and chunk.reasoning_content:
|
||||
reasoning = chunk.reasoning_content
|
||||
|
||||
if reasoning and is_reasoning:
|
||||
full_reasoning += reasoning
|
||||
yield json.dumps({'content': reasoning, 'type': 'reasoning'}) + '\n'
|
||||
|
||||
if chunk.event == RunEvent.run_completed and chunk.metrics:
|
||||
yield (
|
||||
json.dumps(
|
||||
{'metrics': CamelCaseUtil.transform_result(chunk.metrics.to_dict()), 'type': 'metrics'}
|
||||
)
|
||||
+ '\n'
|
||||
)
|
||||
|
||||
if content:
|
||||
full_response += content
|
||||
yield json.dumps({'content': content, 'type': 'content'}) + '\n'
|
||||
except Exception as e:
|
||||
yield json.dumps({'error': str(e), 'type': 'error'}) + '\n'
|
||||
|
||||
@classmethod
|
||||
async def chat_services(
|
||||
cls, query_db: AsyncSession, chat_req: AiChatRequestModel, user_id: int
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
流式对话
|
||||
|
||||
:param query_db: orm对象
|
||||
:param chat_req: 对话请求对象
|
||||
:param user_id: 用户ID
|
||||
:return: 对话响应流
|
||||
"""
|
||||
ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, chat_req.model_id)
|
||||
model_config = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel()
|
||||
if not model_config:
|
||||
raise ServiceException(message='模型不存在')
|
||||
|
||||
user_config = await cls.ai_chat_config_detail_services(query_db, user_id)
|
||||
|
||||
session_id = chat_req.session_id
|
||||
if not session_id:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
temperature = cls._resolve_temperature(user_config, model_config)
|
||||
is_reasoning = cls._resolve_is_reasoning(chat_req, model_config)
|
||||
add_history, num_history = cls._resolve_history_config(user_config)
|
||||
system_prompt = user_config.system_prompt
|
||||
|
||||
agent = cls._build_agent(
|
||||
model_config=model_config,
|
||||
temperature=temperature,
|
||||
system_prompt=system_prompt,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
add_history=add_history,
|
||||
num_history=num_history,
|
||||
)
|
||||
run_kwargs = cls._build_run_kwargs(chat_req, user_config)
|
||||
async for chunk in cls._stream_agent(
|
||||
agent=agent,
|
||||
chat_req=chat_req,
|
||||
run_kwargs=run_kwargs,
|
||||
is_reasoning=is_reasoning,
|
||||
session_id=session_id,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
@classmethod
|
||||
async def ai_chat_config_detail_services(cls, query_db: AsyncSession, user_id: int) -> AiChatConfigModel:
|
||||
"""
|
||||
获取用户配置
|
||||
|
||||
:param query_db: orm对象
|
||||
:param user_id: 用户ID
|
||||
:return: 配置模型
|
||||
"""
|
||||
chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id)
|
||||
result = AiChatConfigModel(**CamelCaseUtil.transform_result(chat_config)) if chat_config else AiChatConfig()
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def save_ai_chat_config_services(
|
||||
cls, query_db: AsyncSession, user_id: int, page_object: AiChatConfigModel
|
||||
) -> CrudResponseModel:
|
||||
"""
|
||||
保存用户配置
|
||||
|
||||
:param query_db: orm对象
|
||||
:param user_id: 用户ID
|
||||
:param page_object: AI对话配置对象
|
||||
:return: 更新后的配置模型
|
||||
"""
|
||||
chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id)
|
||||
if page_object.user_id is None:
|
||||
page_object.user_id = user_id
|
||||
|
||||
try:
|
||||
if chat_config:
|
||||
if chat_config.chat_config_id != page_object.chat_config_id:
|
||||
raise ServiceException(message='只允许修改当前用户的配置')
|
||||
page_object.update_time = datetime.now()
|
||||
edit_ai_chat_config = page_object.model_dump(exclude_unset=True)
|
||||
await AiChatConfigDao.edit_chat_config_dao(query_db, edit_ai_chat_config)
|
||||
else:
|
||||
page_object.create_time = datetime.now()
|
||||
await AiChatConfigDao.add_chat_config_dao(query_db, page_object)
|
||||
|
||||
await query_db.commit()
|
||||
except Exception as e:
|
||||
await query_db.rollback()
|
||||
raise e
|
||||
|
||||
return CrudResponseModel(is_success=True, message='保存成功')
|
||||
|
||||
@classmethod
|
||||
async def get_chat_session_list_services(cls, user_id: int) -> list[AiChatSessionBaseModel]:
|
||||
"""
|
||||
获取用户会话列表
|
||||
|
||||
:param user_id: 用户ID
|
||||
:return: 用户会话列表
|
||||
"""
|
||||
# 获取Agno会话列表
|
||||
storage = AiUtil.get_storage_engine()
|
||||
sessions: list[Session] = await storage.get_sessions(
|
||||
user_id=str(user_id),
|
||||
component_id='chat-agent',
|
||||
session_type=SessionType.AGENT,
|
||||
)
|
||||
|
||||
result = []
|
||||
for s in sessions:
|
||||
created_at = datetime.fromtimestamp(s.created_at) if s.created_at else None
|
||||
updated_at = datetime.fromtimestamp(s.updated_at) if s.updated_at else None
|
||||
|
||||
title_limit = 20
|
||||
session_title = s.runs[0].input.input_content[:title_limit] + '...' if s.runs else ''
|
||||
|
||||
result.append(
|
||||
AiChatSessionBaseModel(
|
||||
sessionId=s.session_id,
|
||||
sessionTitle=session_title if len(session_title) <= title_limit else session_title[:title_limit],
|
||||
userId=s.user_id,
|
||||
createdAt=created_at,
|
||||
updatedAt=updated_at,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def delete_chat_session_services(cls, session_id: str) -> CrudResponseModel:
|
||||
"""
|
||||
删除会话
|
||||
|
||||
:param session_id: 会话ID
|
||||
:return: 删除结果
|
||||
"""
|
||||
storage = AiUtil.get_storage_engine()
|
||||
delete_result = await storage.delete_session(session_id=session_id)
|
||||
if not delete_result:
|
||||
raise ServiceException(message='删除会话失败')
|
||||
return CrudResponseModel(is_success=True, message='删除成功')
|
||||
|
||||
@classmethod
|
||||
async def get_chat_session_detail_services(cls, session_id: str) -> AiChatSessionModel:
|
||||
"""
|
||||
获取会话消息详情
|
||||
|
||||
:param session_id: 会话ID
|
||||
:return: 会话消息详情
|
||||
"""
|
||||
storage = AiUtil.get_storage_engine()
|
||||
session: Session | None = await storage.get_session(session_id=session_id, session_type=SessionType.AGENT)
|
||||
|
||||
if not session:
|
||||
raise ServiceException(message='会话不存在')
|
||||
|
||||
session_data: dict[str, Any] = session.session_data
|
||||
agent_data: dict[str, Any] = session.agent_data
|
||||
runs: list[RunOutput | TeamRunOutput | WorkflowRunOutput] = session.runs
|
||||
messages: list[Message] = session.get_messages(skip_roles=['system'])
|
||||
|
||||
run_metrics_map = {}
|
||||
if runs:
|
||||
for run in runs:
|
||||
if run.model_provider_data and (provider_id := run.model_provider_data.get('id')):
|
||||
run_metrics_map[provider_id] = run.metrics
|
||||
|
||||
chat_messages = []
|
||||
for m in messages:
|
||||
if hasattr(m, 'provider_data') and m.provider_data:
|
||||
provider_id = m.provider_data.get('id')
|
||||
if provider_id and provider_id in run_metrics_map:
|
||||
m.metrics = run_metrics_map[provider_id]
|
||||
|
||||
metrics_model = None
|
||||
if getattr(m, 'metrics', None) and hasattr(m.metrics, 'to_dict'):
|
||||
metrics_dict = m.metrics.to_dict()
|
||||
if metrics_dict:
|
||||
metrics_model = MessageMetrics(**CamelCaseUtil.transform_result(metrics_dict))
|
||||
|
||||
chat_messages.append(
|
||||
ChatMessageModel(
|
||||
id=m.id,
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
images=cls._convert_images_to_upload_paths(m.images),
|
||||
metrics=metrics_model,
|
||||
createdAt=datetime.fromtimestamp(m.created_at) if m.created_at else None,
|
||||
reasoningContent=m.reasoning_content,
|
||||
fromHistory=m.from_history,
|
||||
stopAfterToolCall=m.stop_after_tool_call,
|
||||
)
|
||||
)
|
||||
|
||||
session_detail = AiChatSessionModel(
|
||||
sessionId=session.session_id,
|
||||
sessionTitle=session.runs[0].input.input_content[:20] + '...' if session.runs else '',
|
||||
userId=session.user_id,
|
||||
createdAt=datetime.fromtimestamp(session.created_at) if session.created_at else None,
|
||||
updatedAt=datetime.fromtimestamp(session.updated_at) if session.updated_at else None,
|
||||
agentId=session.agent_id,
|
||||
sessionData=SessionDataModel(
|
||||
sessionState=session_data.get('session_state'),
|
||||
sessionMetrics=SessionMetricsModel(
|
||||
**CamelCaseUtil.transform_result(session_data.get('session_metrics'))
|
||||
),
|
||||
),
|
||||
agentData=AgentDataModel(**CamelCaseUtil.transform_result(agent_data)),
|
||||
messages=chat_messages,
|
||||
)
|
||||
|
||||
return session_detail
|
||||
|
||||
@classmethod
|
||||
async def cancel_run_services(cls, run_id: str) -> CrudResponseModel:
|
||||
"""
|
||||
取消运行
|
||||
|
||||
:param run_id: 运行ID
|
||||
:return: 取消结果
|
||||
"""
|
||||
cancel_result = await acancel_run(run_id)
|
||||
if not cancel_result:
|
||||
raise ServiceException(message='取消运行失败')
|
||||
return CrudResponseModel(is_success=True, message='取消成功')
|
||||
@@ -1,155 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import ServiceException
|
||||
from module_ai.dao.ai_model_dao import AiModelDao
|
||||
from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel
|
||||
from utils.common_util import CamelCaseUtil
|
||||
from utils.crypto_util import CryptoUtil
|
||||
|
||||
|
||||
class AiModelService:
|
||||
"""
|
||||
AI模型管理服务层
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_ai_model_list_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
query_object: AiModelPageQueryModel,
|
||||
data_scope_sql: ColumnElement,
|
||||
is_page: bool = False,
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
"""
|
||||
获取AI模型列表信息service
|
||||
|
||||
:param query_db: orm对象
|
||||
:param query_object: 查询参数对象
|
||||
:param data_scope_sql: 数据权限对应的查询sql语句
|
||||
:param is_page: 是否开启分页
|
||||
:return: AI模型列表信息对象
|
||||
"""
|
||||
ai_model_list_result = await AiModelDao.get_ai_model_list(query_db, query_object, data_scope_sql, is_page)
|
||||
rows = ai_model_list_result.rows if isinstance(ai_model_list_result, PageModel) else ai_model_list_result
|
||||
|
||||
for row in rows:
|
||||
if 'apiKey' in row:
|
||||
row['apiKey'] = '********' * 3
|
||||
|
||||
return ai_model_list_result
|
||||
|
||||
@classmethod
|
||||
async def check_ai_model_data_scope_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
model_id: int,
|
||||
data_scope_sql: ColumnElement,
|
||||
) -> CrudResponseModel:
|
||||
"""
|
||||
校验用户是否有AI模型数据权限service
|
||||
|
||||
:param query_db: orm对象
|
||||
:param model_id: 模型主键
|
||||
:param data_scope_sql: 数据权限对应的查询sql语句
|
||||
:return: 校验结果
|
||||
"""
|
||||
ai_models = await AiModelDao.get_ai_model_list(
|
||||
query_db, AiModelModel(modelId=model_id), data_scope_sql, is_page=False
|
||||
)
|
||||
if ai_models:
|
||||
return CrudResponseModel(is_success=True, message='校验通过')
|
||||
raise ServiceException(message='没有权限访问AI模型数据')
|
||||
|
||||
@classmethod
|
||||
async def add_ai_model_services(cls, query_db: AsyncSession, page_object: AiModelModel) -> CrudResponseModel:
|
||||
"""
|
||||
新增AI模型信息service
|
||||
|
||||
:param request: Request对象
|
||||
:param query_db: orm对象
|
||||
:param page_object: 新增AI模型对象
|
||||
:return: 新增AI模型校验结果
|
||||
"""
|
||||
try:
|
||||
if page_object.api_key:
|
||||
page_object.api_key = CryptoUtil.encrypt(page_object.api_key)
|
||||
await AiModelDao.add_ai_model_dao(query_db, page_object)
|
||||
await query_db.commit()
|
||||
return CrudResponseModel(is_success=True, message='新增成功')
|
||||
except Exception as e:
|
||||
await query_db.rollback()
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
async def edit_ai_model_services(cls, query_db: AsyncSession, page_object: AiModelModel) -> CrudResponseModel:
|
||||
"""
|
||||
编辑AI模型信息service
|
||||
|
||||
:param query_db: orm对象
|
||||
:param page_object: 编辑AI模型对象
|
||||
:return: 编辑AI模型校验结果
|
||||
"""
|
||||
edit_ai_model = page_object.model_dump(exclude_unset=True)
|
||||
if page_object.api_key:
|
||||
if page_object.api_key == '********' * 3:
|
||||
if 'api_key' in edit_ai_model:
|
||||
del edit_ai_model['api_key']
|
||||
else:
|
||||
edit_ai_model['api_key'] = CryptoUtil.encrypt(page_object.api_key)
|
||||
|
||||
ai_model_info = await cls.ai_model_detail_services(query_db, page_object.model_id)
|
||||
if ai_model_info.model_id:
|
||||
try:
|
||||
await AiModelDao.edit_ai_model_dao(query_db, edit_ai_model)
|
||||
await query_db.commit()
|
||||
return CrudResponseModel(is_success=True, message='修改成功')
|
||||
except Exception as e:
|
||||
await query_db.rollback()
|
||||
raise e
|
||||
else:
|
||||
raise ServiceException(message='AI模型不存在')
|
||||
|
||||
@classmethod
|
||||
async def delete_ai_model_services(
|
||||
cls, query_db: AsyncSession, page_object: DeleteAiModelModel
|
||||
) -> CrudResponseModel:
|
||||
"""
|
||||
删除AI模型信息service
|
||||
|
||||
:param query_db: orm对象
|
||||
:param page_object: 删除AI模型对象
|
||||
:return: 删除AI模型校验结果
|
||||
"""
|
||||
if page_object.model_ids:
|
||||
model_id_list = page_object.model_ids.split(',')
|
||||
try:
|
||||
for model_id in model_id_list:
|
||||
await AiModelDao.delete_ai_model_dao(query_db, AiModelModel(modelId=model_id))
|
||||
await query_db.commit()
|
||||
return CrudResponseModel(is_success=True, message='删除成功')
|
||||
except Exception as e:
|
||||
await query_db.rollback()
|
||||
raise e
|
||||
else:
|
||||
raise ServiceException(message='传入AI模型id为空')
|
||||
|
||||
@classmethod
|
||||
async def ai_model_detail_services(cls, query_db: AsyncSession, model_id: int) -> AiModelModel:
|
||||
"""
|
||||
获取AI模型详细信息service
|
||||
|
||||
:param query_db: orm对象
|
||||
:param model_id: AI模型id
|
||||
:return: AI模型id对应的信息
|
||||
"""
|
||||
ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, model_id=model_id)
|
||||
result = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel()
|
||||
|
||||
if result.api_key:
|
||||
result.api_key = '********' * 3
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,129 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
customer_controller = APIRouterPro(
|
||||
prefix='/master/customer', tags=['基础资料-客户管理'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
|
||||
|
||||
@customer_controller.get(
|
||||
'/list',
|
||||
summary='获取客户分页列表接口',
|
||||
description='用于获取客户分页列表',
|
||||
response_model=PageResponseModel[CustomerModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:list')],
|
||||
)
|
||||
async def get_master_customer_list(
|
||||
request: Request,
|
||||
customer_page_query: Annotated[CustomerPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True)
|
||||
|
||||
return ResponseUtil.success(model_content=customer_page_query_result)
|
||||
|
||||
|
||||
@customer_controller.get(
|
||||
'/{customer_id}',
|
||||
summary='获取客户详情接口',
|
||||
description='用于获取指定客户的详细信息',
|
||||
response_model=DataResponseModel[CustomerModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:query')],
|
||||
)
|
||||
async def query_detail_master_customer(
|
||||
request: Request,
|
||||
customer_id: Annotated[int, Path(gt=0, description='客户ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope)
|
||||
|
||||
return ResponseUtil.success(data=customer_detail_result)
|
||||
|
||||
|
||||
@customer_controller.post(
|
||||
'',
|
||||
summary='新增客户接口',
|
||||
description='用于新增客户',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:add')],
|
||||
)
|
||||
@Log(title='客户管理', business_type=BusinessType.INSERT)
|
||||
async def add_master_customer(
|
||||
request: Request,
|
||||
add_customer: CustomerModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
current_time = datetime.now()
|
||||
add_customer.create_by = current_user.user.user_name
|
||||
add_customer.create_time = current_time
|
||||
add_customer.update_by = current_user.user.user_name
|
||||
add_customer.update_time = current_time
|
||||
add_customer_result = await MasterService.add_customer(query_db, add_customer, current_user, scope)
|
||||
|
||||
return ResponseUtil.success(msg=add_customer_result.message)
|
||||
|
||||
|
||||
@customer_controller.put(
|
||||
'',
|
||||
summary='编辑客户接口',
|
||||
description='用于编辑客户',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:edit')],
|
||||
)
|
||||
@Log(title='客户管理', business_type=BusinessType.UPDATE)
|
||||
async def edit_master_customer(
|
||||
request: Request,
|
||||
edit_customer: CustomerModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
edit_customer.update_by = current_user.user.user_name
|
||||
edit_customer.update_time = datetime.now()
|
||||
edit_customer_result = await MasterService.edit_customer(query_db, edit_customer, current_user, scope)
|
||||
|
||||
return ResponseUtil.success(msg=edit_customer_result.message)
|
||||
|
||||
|
||||
@customer_controller.delete(
|
||||
'/{customer_ids}',
|
||||
summary='兼容旧接口,固定拒绝,请使用测试数据批次清理',
|
||||
description='兼容旧接口,固定拒绝单条物理删除,请使用测试数据批次清理',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:list')],
|
||||
)
|
||||
@Log(title='客户管理', business_type=BusinessType.DELETE)
|
||||
async def delete_master_customer(
|
||||
request: Request,
|
||||
customer_ids: Annotated[
|
||||
str,
|
||||
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的客户ID'),
|
||||
],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
delete_customer = DeleteCustomerModel(customerIds=[int(customer_id) for customer_id in customer_ids.split(',')])
|
||||
delete_customer_result = await MasterService.remove_customer(query_db, delete_customer)
|
||||
|
||||
return ResponseUtil.success(msg=delete_customer_result.message)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""首页运营驾驶舱接口。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import PageResponseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.entity.vo.dashboard_vo import ScanExceptionModel
|
||||
from module_business.service.dashboard_service import DashboardService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
|
||||
dashboard_controller = APIRouterPro(
|
||||
prefix='/business/dashboard',
|
||||
tags=['业务管理-运营驾驶舱'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@dashboard_controller.get(
|
||||
'/scan-exceptions',
|
||||
summary='获取首页扫码异常分页列表接口',
|
||||
description='普通用户仅查看本人扫码异常,超级管理员查看全部扫码异常',
|
||||
response_model=PageResponseModel[ScanExceptionModel],
|
||||
)
|
||||
async def get_business_dashboard_scan_exceptions(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await DashboardService.get_scan_exception_list_services(query_db, current_user)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
@@ -0,0 +1,141 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingCreateModel,
|
||||
FillingModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
from module_business.service.filling_service import FillingService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
filling_controller = APIRouterPro(
|
||||
prefix='/business/filling',
|
||||
tags=['业务管理-托盘装填'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/list',
|
||||
summary='获取托盘装填记录分页列表接口',
|
||||
description='按当前用户仓库数据范围获取托盘装填记录分页列表',
|
||||
response_model=PageResponseModel[FillingModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
|
||||
)
|
||||
async def get_business_filling_list(
|
||||
request: Request,
|
||||
filling_page_query: Annotated[FillingPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_filling_list_services(
|
||||
query_db,
|
||||
filling_page_query,
|
||||
current_user,
|
||||
test_scope,
|
||||
is_page=True,
|
||||
)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/strain-options',
|
||||
summary='获取装填菌种选项接口',
|
||||
description='获取全部启用菌种的业务选项',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
async def get_business_filling_strain_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_strain_options_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描待装填托盘接口',
|
||||
description='按托盘当前仓库数据范围判断空托盘是否可装填',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
@Log(
|
||||
title='扫码操作',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='none',
|
||||
response_log_mode='summary',
|
||||
oper_url_mode='route',
|
||||
)
|
||||
async def scan_business_filling_pallet(
|
||||
request: Request,
|
||||
qr_token: Annotated[
|
||||
str,
|
||||
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
|
||||
],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.scan_filling_pallet_services(
|
||||
query_db,
|
||||
qr_token,
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/{filling_id}',
|
||||
summary='获取托盘装填记录详情接口',
|
||||
description='按仓库数据范围获取指定托盘装填记录',
|
||||
response_model=DataResponseModel[FillingModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
async def query_detail_business_filling(
|
||||
request: Request,
|
||||
filling_id: Annotated[int, Path(gt=0, description='装填记录ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.filling_detail_services(query_db, filling_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.post(
|
||||
'',
|
||||
summary='单托盘装填接口',
|
||||
description='在仓库内即时确认单个空托盘装填',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:add')],
|
||||
)
|
||||
@Log(title='托盘装填', business_type=BusinessType.INSERT)
|
||||
async def add_business_filling(
|
||||
request: Request,
|
||||
add_filling: FillingCreateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await FillingService.fill_pallet_services(query_db, add_filling, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -0,0 +1,218 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.annotation.log_annotation import Log
|
||||
from common.aspect.db_seesion import DBSessionDependency
|
||||
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||
from common.enums import BusinessType
|
||||
from common.router import APIRouterPro
|
||||
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
from module_business.service.inbound_service import InboundService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
inbound_controller = APIRouterPro(
|
||||
prefix='/business/inbound',
|
||||
tags=['业务管理-仓库入库'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/list',
|
||||
summary='获取首次入库单分页列表接口',
|
||||
description='按当前用户仓库数据范围获取首次入库单分页列表',
|
||||
response_model=PageResponseModel[InboundModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
|
||||
)
|
||||
async def get_business_inbound_list(
|
||||
request: Request,
|
||||
inbound_page_query: Annotated[InboundPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await InboundService.get_inbound_list_services(
|
||||
query_db,
|
||||
inbound_page_query,
|
||||
current_user,
|
||||
test_scope,
|
||||
is_page=True,
|
||||
)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/warehouse-options',
|
||||
summary='获取首次入库仓库选项接口',
|
||||
description='仅返回当前用户有权访问的启用仓库',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
|
||||
)
|
||||
async def get_business_inbound_warehouse_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await InboundService.get_warehouse_options_services(query_db, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/scan/{qr_token:path}',
|
||||
summary='扫描首次入库托盘接口',
|
||||
description='查询托盘是否可加入所选仓库的首次入库单',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
|
||||
)
|
||||
@Log(
|
||||
title='扫码操作',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='none',
|
||||
response_log_mode='summary',
|
||||
oper_url_mode='route',
|
||||
)
|
||||
async def scan_business_inbound_pallet(
|
||||
request: Request,
|
||||
qr_token: Annotated[
|
||||
str,
|
||||
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
|
||||
],
|
||||
inbound_scan_query: Annotated[InboundScanQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await InboundService.scan_inbound_pallet_services(
|
||||
query_db,
|
||||
qr_token,
|
||||
inbound_scan_query,
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@inbound_controller.get(
|
||||
'/{inbound_id}',
|
||||
summary='获取首次入库单详情接口',
|
||||
description='按仓库数据范围获取指定首次入库单详情',
|
||||
response_model=DataResponseModel[InboundModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
|
||||
)
|
||||
async def query_detail_business_inbound(
|
||||
request: Request,
|
||||
inbound_id: Annotated[int, Path(gt=0, description='入库单ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await InboundService.inbound_detail_services(query_db, inbound_id, current_user, test_scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@inbound_controller.post(
|
||||
'',
|
||||
summary='新增首次入库草稿接口',
|
||||
description='新增空托盘首次入库草稿',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:add')],
|
||||
)
|
||||
@Log(title='仓库入库', business_type=BusinessType.INSERT)
|
||||
async def add_business_inbound(
|
||||
request: Request,
|
||||
add_inbound: InboundCreateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await InboundService.add_inbound_services(query_db, add_inbound, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@inbound_controller.put(
|
||||
'',
|
||||
summary='编辑首次入库草稿接口',
|
||||
description='编辑尚未确认的首次入库草稿',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:edit')],
|
||||
)
|
||||
@Log(title='仓库入库', business_type=BusinessType.UPDATE)
|
||||
async def edit_business_inbound(
|
||||
request: Request,
|
||||
edit_inbound: InboundUpdateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await InboundService.edit_inbound_services(query_db, edit_inbound, current_user, test_scope)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@inbound_controller.delete(
|
||||
'/{inbound_ids}',
|
||||
summary='不支持单条物理删除,请通过测试数据批次清理',
|
||||
description='不支持单条物理删除,请通过测试数据批次清理',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
|
||||
)
|
||||
@Log(title='仓库入库', business_type=BusinessType.DELETE)
|
||||
async def delete_business_inbound(
|
||||
request: Request,
|
||||
inbound_ids: Annotated[
|
||||
str,
|
||||
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的入库单ID'),
|
||||
],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await InboundService.delete_inbound_services(
|
||||
query_db,
|
||||
[int(inbound_id) for inbound_id in inbound_ids.split(',')],
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
|
||||
|
||||
@inbound_controller.put(
|
||||
'/{inbound_id}/confirm',
|
||||
summary='确认首次入库单接口',
|
||||
description='原子确认整张首次入库单并追加托盘流转事件',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:inbound:confirm')],
|
||||
)
|
||||
@Log(title='仓库入库', business_type=BusinessType.UPDATE)
|
||||
async def confirm_business_inbound(
|
||||
request: Request,
|
||||
inbound_id: Annotated[int, Path(gt=0, description='入库单ID')],
|
||||
confirm_command: ConfirmCommandModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
) -> Response:
|
||||
result = await InboundService.confirm_inbound_services(
|
||||
query_db,
|
||||
inbound_id,
|
||||
confirm_command,
|
||||
current_user,
|
||||
test_scope,
|
||||
)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user