Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01e373faf6 | ||
|
|
6b940e424c | ||
|
|
5762f4e762 | ||
|
|
718d8c5a3c | ||
|
|
9eac385378 | ||
|
|
ede01c06d2 | ||
|
|
9b4020d44f | ||
|
|
8c3ff509fc | ||
|
|
93d6a58a2b | ||
|
|
cef4398a5a | ||
|
|
fb76270882 | ||
|
|
a89825e23c | ||
|
|
ccb70ca10e | ||
|
|
ee32ecc995 | ||
|
|
fd4255d982 | ||
|
|
9a4a5f2339 | ||
|
|
403ffe0fde | ||
|
|
693c66c258 | ||
|
|
cf2918cfe2 |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -20,3 +20,20 @@ backend/**/logs/
|
||||
backend/**/*.log
|
||||
backend/.idea/
|
||||
backend/crmebimage/
|
||||
|
||||
# Dashboard frontend
|
||||
dashboard-frontend/node_modules/
|
||||
dashboard-frontend/npm-debug.log*
|
||||
dashboard-frontend/**/*.log
|
||||
|
||||
# 独立子仓库(各自有独立 git,不纳入根 repo)
|
||||
integral-resell/
|
||||
single-shop-22/
|
||||
MER-2.2_2601/
|
||||
db/
|
||||
|
||||
# 敏感运行时配置(不入库)
|
||||
deploy/docker/scripts/server.env
|
||||
deploy/docker/step1-integral/.env
|
||||
deploy/docker/step1-integral/houtai.env
|
||||
deploy/docker/step2-single-shop/.env
|
||||
|
||||
469
DOCKER_DEPLOY.md
Normal file
469
DOCKER_DEPLOY.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Docker 部署方案(寄卖商城 + 积分商城前后端)
|
||||
|
||||
> 本方案覆盖:
|
||||
> - **integral-resell(积分商城)**:Webman PHP 后端二进制 + H5 静态站
|
||||
> - **single-shop-22(寄卖商城)**:CRMEB Spring Boot 双 jar 后端 + Vue 管理后台 + uni-app H5
|
||||
> - **Redis**:每个子栈独立容器(两套互不干扰)
|
||||
>
|
||||
> 不包含:`MER-2.2_2601` 多商户、Kafka 同步、MySQL(使用 **阿里云 RDS for MySQL**)。
|
||||
|
||||
---
|
||||
|
||||
## 两步独立部署(推荐)
|
||||
|
||||
从 `czleilei240` 分支开始,两个商城**各自独立**部署,互不依赖。
|
||||
|
||||
| | 步骤一 寄卖商城 | 步骤二 积分商城 |
|
||||
|---|---|---|
|
||||
| 项目 | `integral-resell` | `single-shop-22` |
|
||||
| 目录 | `deploy/docker/step1-integral/` | `deploy/docker/step2-single-shop/` |
|
||||
| 服务 | redis · integral-houtai · integral-h5 | redis · single-admin-api · single-front-api · single-admin-web · single-h5 |
|
||||
| 宿主机端口 | `18080` | `18081`(管理后台) `18082`(H5) |
|
||||
| compose name | `resell-czleilei240` | `jifenmall-czleilei240` |
|
||||
| 详细说明 | [README](deploy/docker/step1-integral/README.md) | [README](deploy/docker/step2-single-shop/README.md) |
|
||||
|
||||
### 步骤一 — 寄卖商城(integral-resell)
|
||||
|
||||
```bash
|
||||
cd deploy/docker/step1-integral
|
||||
cp .env.example .env && cp houtai.env.example houtai.env
|
||||
# 填写 .env 中的 REDIS_PASSWORD
|
||||
# 填写 houtai.env 中的 DB_PASSWORD 和 REDIS_PASSWORD(两者须一致)
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
### 步骤二 — 积分商城(single-shop-22)
|
||||
|
||||
```bash
|
||||
cd deploy/docker/step2-single-shop
|
||||
cp .env.example .env
|
||||
# 填写 RDS_PASSWORD、REDIS_PASSWORD
|
||||
docker compose --env-file .env up -d --build
|
||||
# Java 首次构建约 10-20 分钟,可用 logs -f 观察进度
|
||||
```
|
||||
|
||||
> 下方章节描述的是历史上合并部署时的 `docker-compose.yml`(`deploy/docker/docker-compose.yml`),供参考。两步拆分方案已取代合并方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、架构总览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user((用户)) -->|H5/管理后台| EdgeLB[(阿里云 SLB / Nginx)]
|
||||
|
||||
subgraph Host [Docker 主机]
|
||||
direction TB
|
||||
|
||||
subgraph integralStack [积分商城]
|
||||
integral_h5[integral-h5\nNginx :80]
|
||||
integral_houtai[integral-houtai\nwebman.bin :8787]
|
||||
end
|
||||
|
||||
subgraph singleStack [寄卖商城]
|
||||
single_admin_web[single-admin-web\nNginx :80]
|
||||
single_h5[single-h5\nNginx :80]
|
||||
single_admin_api[single-admin-api\nminiao-admin :30032]
|
||||
single_front_api[single-front-api\nminiao-front :30031]
|
||||
end
|
||||
|
||||
redis[(redis :6379)]
|
||||
end
|
||||
|
||||
EdgeLB -- jf-h5.* --> single_h5
|
||||
EdgeLB -- jfadmin.* --> single_admin_web
|
||||
EdgeLB -- admin.* --> integral_h5
|
||||
|
||||
integral_h5 -- /api/ --> integral_houtai
|
||||
single_admin_web -- /api/ --> single_admin_api
|
||||
single_h5 -- /api/ --> single_front_api
|
||||
|
||||
integral_houtai --> redis
|
||||
single_admin_api --> redis
|
||||
single_front_api --> redis
|
||||
|
||||
integral_houtai --> RDS[(阿里云 RDS MySQL)]
|
||||
single_admin_api --> RDS
|
||||
single_front_api --> RDS
|
||||
```
|
||||
|
||||
### 服务清单
|
||||
|
||||
| 服务 | 镜像构建来源 | 容器内端口 | 默认宿主机端口 | 说明 |
|
||||
|------|--------------|------------|----------------|------|
|
||||
| `redis` | 官方 `redis:6.2-alpine` | 6379 | 6379 (可不暴露) | 持久化 AOF,挂卷 `redis-data` |
|
||||
| `integral-houtai` | `integral-resell/houtai/` | 8787 | (不暴露) | Webman 静态 ELF;同容器写 `runtime/` 与 `public/upload/` |
|
||||
| `integral-h5` | `integral-resell/h5/` | 80 | 18080 | 服务静态站;`/api/` 反代到 `integral-houtai`;启动时根据 env 重写 `static/configs.js` |
|
||||
| `single-admin-api` | `single-shop-22/backend/crmeb-admin` | 30032 | (不暴露) | 多阶段 Maven 构建出 `miao-admin-2.2.jar` |
|
||||
| `single-front-api` | `single-shop-22/backend/crmeb-front` | 30031 | (不暴露) | 多阶段 Maven 构建出 `miao-front-2.2.jar` |
|
||||
| `single-admin-web` | `single-shop-22/backend-adminend` | 80 | 18081 | 多阶段 Node 构建 `dist`;`/api/` 反代到 `single-admin-api` |
|
||||
| `single-h5` | `single-shop-22/single_uniapp22miao` | 80 | 18082 | 多阶段 Node 构建 `unpackage/dist/build/h5`;`/api/` 反代到 `single-front-api` |
|
||||
|
||||
> **域名/端口策略**:建议在 docker 主机前再放一层 Nginx 或阿里云 SLB,按域名分发到 18080/18081/18082;下面的 compose 默认把这三个静态站暴露在宿主机不同端口。
|
||||
|
||||
### 共享资源 / 卷
|
||||
|
||||
| 卷 / 目录 | 容器内挂载点 | 用途 |
|
||||
|-----------|-------------|------|
|
||||
| `redis-data` | `/data` | Redis AOF 持久化 |
|
||||
| `integral-runtime` | `/app/runtime` | Webman 运行时(session、views 缓存等) |
|
||||
| `integral-upload` | `/app/public/upload` | 积分商城上传图片 |
|
||||
| `single-images` | `/usr/local/crmeb/crmebimage` | 寄卖商城 `imagePath`,admin/front 两个 jar **共享** |
|
||||
| `single-logs` | `/app/log` | Spring Boot 日志(按需保留) |
|
||||
|
||||
### 与外部资源的关系
|
||||
|
||||
| 资源 | 地址来源 | 说明 |
|
||||
|------|---------|------|
|
||||
| 阿里云 RDS MySQL | `.env` 中 `RDS_*` | RDS 白名单需放通宿主机出口 IP;建议同地域 VPC |
|
||||
| OSS(可选) | `.env` 中 `OSS_*` | 不配置时积分商城走本地 `public/upload`;寄卖商城用 admin 后台界面里配置 |
|
||||
| 短信(可选) | `.env` 中 `SMS_*` | 仅积分商城 `.env` 用到 |
|
||||
|
||||
---
|
||||
|
||||
## 二、目录布局
|
||||
|
||||
```
|
||||
integral-shop/
|
||||
├── DOCKER_DEPLOY.md ← 本文档
|
||||
├── deploy/
|
||||
│ └── docker/
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env.example
|
||||
│ ├── README.md ← 一键启动说明(简版)
|
||||
│ ├── redis/
|
||||
│ │ └── redis.conf
|
||||
│ ├── integral-resell/
|
||||
│ │ ├── houtai.Dockerfile
|
||||
│ │ ├── h5.Dockerfile
|
||||
│ │ ├── nginx-h5.conf
|
||||
│ │ ├── .env.template ← Webman 后端运行时 .env 模板
|
||||
│ │ └── docker-entrypoint-h5.sh ← 启动时根据 env 改写 configs.js
|
||||
│ └── single-shop/
|
||||
│ ├── admin-api.Dockerfile
|
||||
│ ├── front-api.Dockerfile
|
||||
│ ├── admin-web.Dockerfile
|
||||
│ ├── h5.Dockerfile
|
||||
│ ├── application-docker.yml ← Spring Boot docker profile
|
||||
│ ├── nginx-admin-web.conf
|
||||
│ └── nginx-h5.conf
|
||||
├── integral-resell/ ← 源码(不改动)
|
||||
└── single-shop-22/ ← 源码(不改动)
|
||||
```
|
||||
|
||||
> 所有 Dockerfile / compose 都**只读访问**源码目录,不会修改它们。
|
||||
|
||||
---
|
||||
|
||||
## 三、配置与环境变量
|
||||
|
||||
复制一份 `deploy/docker/.env.example` 为 `deploy/docker/.env` 并按下面表格修改。
|
||||
|
||||
| 变量 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `TZ` | 容器时区 | `Asia/Shanghai` |
|
||||
| `RDS_HOST` | 阿里云 RDS 外网/内网地址 | `rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com` |
|
||||
| `RDS_PORT` | RDS 端口 | `3306` |
|
||||
| `RDS_INTEGRAL_DB` | 积分商城数据库 | `yangtangyoupin` |
|
||||
| `RDS_INTEGRAL_USER` / `RDS_INTEGRAL_PASS` | 积分商城账号 | 默认 `yangtangyoupin` / 来自原 sxsy80 |
|
||||
| `RDS_SINGLE_DB` | 寄卖商城数据库(与积分共库) | `yangtangyoupin` |
|
||||
| `RDS_SINGLE_USER` / `RDS_SINGLE_PASS` | 寄卖商城账号 | 同上 |
|
||||
| `REDIS_PASSWORD` | 容器 Redis 密码 | 123456 |
|
||||
| `REDIS_INTEGRAL_DB` | 积分商城使用的 Redis db | `0` |
|
||||
| `REDIS_SINGLE_ADMIN_DB` | 寄卖 admin Redis db | `25` |
|
||||
| `REDIS_SINGLE_FRONT_DB` | 寄卖 front Redis db | `26` |
|
||||
| `INTEGRAL_API_PUBLIC_URL` | H5 调用后端的对外 URL | `https://admin.example.com` |
|
||||
| `INTEGRAL_IMG_PUBLIC_URL` | H5 引用图片的对外 URL | `https://admin.example.com` |
|
||||
| `INTEGRAL_H5_PUBLIC_URL` | H5 自身对外 URL | `https://h5.example.com/` |
|
||||
| `INTEGRAL_SN_ID` | `configs.js` 中 `sn_id` | `17533260260405` |
|
||||
| `INTEGRAL_APP_STR` | `configs.js` 中 `appStr`(**与寄卖 APP_SECRET 必须一致**) | `ZFyTNQTWEkCBczKzyUDJWE9Ecx260405` |
|
||||
| `SINGLE_ADMIN_BASE_API` | 寄卖管理后台调用 API 的域名/路径 | 留空走同域 `/api/` 即可 |
|
||||
| `SINGLE_H5_DOMAIN` | uni-app H5 调用 API 的域名 | 留空走同域 `/api/` 即可 |
|
||||
| `SYNC_SOURCE_ID` / `SYNC_TARGET_MER_ID` | 多商户同步配置(无 MER 时留空) | `""` / `0` |
|
||||
|
||||
> **关键一致性**:积分商城 `configs.js#appStr` ↔ 积分商城 Webman `.env#APP_SECRET` ↔ 寄卖商城 admin 后台中的 `appStr` 必须一致;否则双向调用会鉴权失败。
|
||||
|
||||
---
|
||||
|
||||
## 四、镜像构建策略
|
||||
|
||||
### 0. 基础镜像选型(与主机对齐)
|
||||
|
||||
| 服务 | 基础镜像 | libc | 选择理由 |
|
||||
|------|----------|------|----------|
|
||||
| `redis` | `redis:6.2-alpine` | musl | Redis 官方推荐,无 native 依赖 |
|
||||
| `integral-houtai` | `php:8.0-cli-bullseye` (Debian 11) | **glibc** | 与宝塔 PHP 8.0.26 版本对齐;已装 pdo_mysql / redis / gd / imagick 等 webman 常用扩展 |
|
||||
| `integral-h5` | `nginx:1.25-alpine` | musl | 纯静态文件 + nginx,无字体/native 依赖 |
|
||||
| `single-admin-api` / `single-front-api` 构建 | `maven:3.8.8-eclipse-temurin-17` (Debian) | **glibc** | 与部署环境 OpenJDK 17.0.x 对齐;pom.xml source/target=1.8,Java 17 编译器向下兼容 |
|
||||
| `single-admin-api` / `single-front-api` 运行 | `eclipse-temurin:17-jre-jammy` (Ubuntu 22.04) | **glibc** | 匹配主机 openjdk 17.0.18;Spring Boot 2.2.6 + Java 17 需额外 `--add-opens` JVM 参数(已写入 JAVA_OPTS);预装 `fonts-dejavu` + `fonts-wqy-zenhei` |
|
||||
| `single-admin-web` / `single-h5` 构建 | `node:16-alpine` | musl | 仅打包 JS/CSS,无 native 依赖 |
|
||||
| `single-admin-web` / `single-h5` 运行 | `nginx:1.25-alpine` | musl | 同上 |
|
||||
|
||||
> **主机环境**:Debian-class(Linux kernel 6.1.164)/ x86_64 / Docker + BuildKit。
|
||||
> 所有 `--platform=linux/amd64` 在主机上都是原生执行,**无 QEMU 模拟**;只有在 Apple Silicon 上本地 build 时才会走 QEMU。
|
||||
> Java 后端最终镜像在 glibc 上运行,与主机 OS 完全同源;这样可避免 Alpine + musl 时验证码 / 中文字体 / POI 个别场景的兼容性问题。
|
||||
|
||||
### 1. integral-houtai(PHP Webman 二进制)
|
||||
- 基础镜像:`debian:bookworm-slim`(webman.bin 为 ELF x86_64,静态链接,glibc 兼容)。
|
||||
- 直接拷贝 `webman.bin`、`public/`、`runtime/views` 模板进镜像。
|
||||
- `.env` **不打进镜像**,通过 `docker-compose` 把 `deploy/docker/integral-resell/.env.template` 渲染后挂进 `/app/.env`。
|
||||
- 启动命令:`./webman.bin start -d`(业主指定的守护模式),entrypoint 在后台 `tail -F runtime/logs/*.log` 把日志接到容器 stdout,并监听主进程退出。收到 `SIGTERM` 时执行 `./webman.bin stop` 优雅退出。
|
||||
|
||||
### 2. integral-h5(静态站)
|
||||
- 基础镜像:`nginx:1.25-alpine`。
|
||||
- 直接拷贝 `integral-resell/h5/` 到 `/usr/share/nginx/html/`。
|
||||
- `docker-entrypoint-h5.sh` 在启动时根据环境变量重写 `static/configs.js`:把 `BASE_URL/IMG_URL/H5_URL/sn_id/appStr` 替换为部署值,**无需重新构建前端**。
|
||||
|
||||
### 3. single-admin-api / single-front-api(Spring Boot)
|
||||
- **多阶段构建**:
|
||||
- 构建阶段:`maven:3.8.8-eclipse-temurin-17` → `mvn package -pl crmeb-admin -am -DskipTests`(front 同理)。pom.xml 已配置 `source/target=1.8`,Java 17 编译器向下兼容,产物字节码级别不变。
|
||||
- 运行阶段:`eclipse-temurin:17-jre-jammy`(Ubuntu 22.04 / glibc),与部署环境 `openjdk 17.0.18` 对齐;预装 `fontconfig + fonts-dejavu + fonts-wqy-zenhei`。
|
||||
- **Java 17 兼容性**:Spring Boot 2.2.6 使用 cglib、Druid、Quartz 等大量反射,Java 17 强模块封装会报 `InaccessibleObjectException`。已在 `JAVA_OPTS` 中加入 7 条 `--add-opens`,覆盖已知触发点(`java.lang / reflect / util / io / math / sun.net.util / net`)。若运行时出现新的 `InaccessibleObjectException`,按报错包名继续追加 `--add-opens`。
|
||||
- 通过 `--spring.profiles.active=docker` + `--spring.config.additional-location=file:/config/` 加载 `application-docker.yml`,所有 DB/Redis/端口参数从 `.env` 注入。
|
||||
- `imagePath` 指向 `/usr/local/crmeb/crmebimage/`(挂卷 `single-images`,两个 jar 共享)。
|
||||
|
||||
### 4. single-admin-web(Vue 管理后台)
|
||||
- **多阶段构建**:
|
||||
- 构建阶段:`node:16-alpine` → `npm ci && npm run build:prod`(`VUE_APP_BASE_API` 设为空,走同域 `/api/`)。
|
||||
- 运行阶段:`nginx:1.25-alpine`,`/api/` 反代到 `single-admin-api:30032`。
|
||||
- 若想**直接复用源码里现成的 `dist/`**,可改用「fast」分支(见 Dockerfile 注释)。
|
||||
|
||||
### 5. single-h5(uni-app H5)
|
||||
- 同样多阶段:`node:16-alpine` → `npm install && npm run build:h5`,再 nginx 服务。
|
||||
- `config/app.js` 里 `domain` 硬编码,需在构建前注入:通过 ARG `H5_API_DOMAIN` 做字符串替换,或留空让同域生效(推荐)。
|
||||
|
||||
---
|
||||
|
||||
## 五、Spring Boot 接 RDS / Redis 的细节
|
||||
|
||||
寄卖商城原本通过 `spring.profiles.active=byjyw149` / `miao80` 等加载不同 yml。**Docker 部署用全新 profile `docker`**:
|
||||
|
||||
```yaml
|
||||
# deploy/docker/single-shop/application-docker.yml
|
||||
spring:
|
||||
datasource:
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT:3306}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
|
||||
username: ${MYSQL_USERNAME}
|
||||
password: ${MYSQL_PASSWORD}
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
```
|
||||
|
||||
通过 docker-compose 注入:
|
||||
```
|
||||
MYSQL_HOST=${RDS_HOST}
|
||||
MYSQL_DATABASE=${RDS_SINGLE_DB}
|
||||
MYSQL_USERNAME=${RDS_SINGLE_USER}
|
||||
MYSQL_PASSWORD=${RDS_SINGLE_PASS}
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
REDIS_DATABASE=${REDIS_SINGLE_ADMIN_DB}
|
||||
```
|
||||
|
||||
> **数据库初始化**:首次部署前请用 `db/` 下的 SQL(推荐 `db/yangtangyoupin.sql`、`db/shop22-v2.sql` 等)在 RDS 中初始化对应库;本方案不在容器中建库,**避免误覆盖现网数据**。
|
||||
|
||||
---
|
||||
|
||||
## 六、首次部署步骤
|
||||
|
||||
```bash
|
||||
# 1. 准备环境变量
|
||||
cd integral-shop/deploy/docker
|
||||
cp .env.example .env
|
||||
vim .env # 填入 RDS / Redis / 域名等
|
||||
|
||||
# 2. 准备积分商城 Webman .env(容器运行时挂载)
|
||||
cp integral-resell/.env.template integral-resell/.env
|
||||
vim integral-resell/.env # 至少配置 DB_*、APP_SECRET、SMS_*
|
||||
|
||||
# 3. 在阿里云 RDS 上提前导入数据
|
||||
# - 积分商城:db/yangtangyoupin.sql
|
||||
# - 寄卖商城:db/shop22-v2.sql / db/jjy153-mysql.sql (按实际版本)
|
||||
|
||||
# 4. 构建并启动
|
||||
docker compose --env-file .env build
|
||||
docker compose --env-file .env up -d
|
||||
|
||||
# 5. 健康检查
|
||||
docker compose ps
|
||||
docker compose logs -f single-admin-api # 看到 "Started CrmebAdminApplication" 即成功
|
||||
docker compose logs -f integral-houtai # 看到 "Webman start success" 即成功
|
||||
curl -I http://localhost:18080 # 积分商城 H5
|
||||
curl -I http://localhost:18081 # 寄卖管理后台
|
||||
curl -I http://localhost:18082 # 寄卖用户 H5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、日常运维
|
||||
|
||||
| 场景 | 操作 |
|
||||
|------|------|
|
||||
| 拉取最新源码后重建 | `docker compose build --no-cache <service>` 然后 `docker compose up -d <service>` |
|
||||
| 只重启某服务 | `docker compose restart single-admin-api` |
|
||||
| 看日志 | `docker compose logs -f --tail 200 single-front-api` |
|
||||
| 进入容器 | `docker compose exec single-admin-api sh` |
|
||||
| 备份用户上传 | `docker run --rm -v integral-upload:/d -v $(pwd):/b alpine tar czf /b/upload.tgz -C /d .` |
|
||||
| 备份寄卖商城图片 | 同上,卷名 `single-images` |
|
||||
| 切换到外部 Redis | 在 `.env` 把 `REDIS_HOST` 改成外部地址,并在 compose 里把 `redis` 服务注释 |
|
||||
| 切换到 Java 17 运行寄卖商城 | 把 `eclipse-temurin:8-jre-alpine` 改成 `17-jre`;本工程经验依旧建议 8 |
|
||||
|
||||
---
|
||||
|
||||
## 八、性能与资源建议
|
||||
|
||||
| 容器 | CPU | 内存(建议) |
|
||||
|------|----:|------------:|
|
||||
| `redis` | 0.2 | 256 MB |
|
||||
| `integral-houtai` | 0.5–1 | 256–512 MB(webman 多 worker 时 ↑) |
|
||||
| `integral-h5` | 0.1 | 64 MB |
|
||||
| `single-admin-api` | 1 | **JVM `-Xmx512m`,容器 limit 768 MB** |
|
||||
| `single-front-api` | 1 | JVM `-Xmx768m`,容器 limit 1 GB |
|
||||
| `single-admin-web` | 0.1 | 64 MB |
|
||||
| `single-h5` | 0.1 | 64 MB |
|
||||
|
||||
最低配宿主机:**2 vCPU / 4 GB**(建议 4 vCPU / 8 GB 起)。
|
||||
|
||||
---
|
||||
|
||||
## 九、上线检查清单
|
||||
|
||||
- [ ] 阿里云 RDS 白名单已放通 Docker 主机出口 IP
|
||||
- [ ] RDS 中已导入对应库的 SQL,账号/密码权限正确
|
||||
- [ ] `.env` 中无明文敏感信息提交到 git(已 `.gitignore`)
|
||||
- [ ] `INTEGRAL_APP_STR` 与寄卖商城 admin 后台 + Webman `.env#APP_SECRET` 三处一致
|
||||
- [ ] 域名 / SSL 在外层 Nginx 或阿里云 SLB 完成 443 卸载
|
||||
- [ ] OSS 与 SMS 等第三方密钥若启用,已写入对应位置
|
||||
- [ ] 备份策略:RDS 自动备份 + `integral-upload` / `single-images` 卷定期 tar 备份
|
||||
|
||||
---
|
||||
|
||||
## 十、远端 116.62.83.240 一键部署
|
||||
|
||||
服务器已具备的环境(来自宝塔软件商店截图):
|
||||
- Docker(节点管理 → Docker 已部署)
|
||||
- Nginx 1.28.1(**未使用**,本方案的 Nginx 在容器内)
|
||||
- MySQL 5.7.44(**未使用**,本方案走阿里云 RDS)
|
||||
- Redis 8.0.5(**默认未使用**,容器内自带 Redis;想复用宿主 Redis 见下方"复用宿主 Redis")
|
||||
- PHP 8.0.26(**未使用**,webman 走静态二进制)
|
||||
- rsync 已安装、SSH `root / A@123456`
|
||||
|
||||
### 1. 本机一次性配置
|
||||
|
||||
```bash
|
||||
cd deploy/docker/scripts
|
||||
cp server.env.example server.env
|
||||
$EDITOR server.env # 默认已写好 116.62.83.240 / root / A@123456
|
||||
```
|
||||
|
||||
> **强烈建议先用 SSH key 替代密码**:`ssh-copy-id root@116.62.83.240` 后把 `server.env` 里的 `SSHPASS=` 注释掉即可。脚本会自动切回 SSH key 通道。
|
||||
|
||||
### 2. 全量同步并启动
|
||||
|
||||
```bash
|
||||
./sync-to-server.sh up
|
||||
```
|
||||
|
||||
脚本流程:
|
||||
1. SSH 登录 116.62.83.240,确保 `/root/integral-shop` 目录存在;
|
||||
2. rsync 增量同步整个工程(自动排除 `node_modules / target / .git / runtime/logs / MER-2.2_2601` 等大目录);
|
||||
3. 在远端 `cd /root/integral-shop/deploy/docker && docker compose build && docker compose up -d`;
|
||||
4. 输出 `docker compose ps` 结果。
|
||||
|
||||
首次会要求你先在远端把 `.env / integral-resell/.env` 改成真实值(如未做,运行下面这一步):
|
||||
|
||||
```bash
|
||||
./bootstrap-remote-env.sh # 在远端基于模板创建 .env,再 ssh 上去填写
|
||||
```
|
||||
|
||||
### 3. 日常运维(无需登录服务器)
|
||||
|
||||
```bash
|
||||
./remote-up.sh ps # 服务状态
|
||||
./remote-up.sh logs single-admin-api # 跟随日志
|
||||
./remote-up.sh restart single-front-api # 重启某服务
|
||||
./remote-up.sh build single-admin-web # 只重建某镜像
|
||||
./remote-up.sh exec single-admin-api sh # 进容器
|
||||
./remote-up.sh ssh # 直接登录服务器
|
||||
./sync-to-server.sh up # 改了代码后再同步+重启
|
||||
```
|
||||
|
||||
### 4. 端口规划与宝塔放行
|
||||
|
||||
宿主机暴露端口(来自 `.env.example`,可改):
|
||||
|
||||
| 端口 | 服务 | 用途 |
|
||||
|------|------|------|
|
||||
| 18080 | `integral-h5` | 积分商城 H5 |
|
||||
| 18081 | `single-admin-web` | 寄卖管理后台 |
|
||||
| 18082 | `single-h5` | 寄卖用户 H5 |
|
||||
| 6379 | `redis` | (生产建议关闭)`REDIS_HOST_PORT=` 留空即不暴露 |
|
||||
|
||||
**安全组 / 防火墙**:阿里云安全组放通 18080-18082;宝塔面板 → 安全 → 放行同样端口。**强烈建议在前面再挂一层域名 + SSL**(宝塔自带 Nginx 反代 / 阿里云 SLB / Cloudflare 都行)。
|
||||
|
||||
### 5. 复用宿主已有 Redis 8.0.5(可选)
|
||||
|
||||
若想节省一份 Redis,直接用宿主机的 Redis:
|
||||
|
||||
```bash
|
||||
# 1) 远端编辑 deploy/docker/.env
|
||||
REDIS_PASSWORD=宿主-Redis-的密码
|
||||
|
||||
# 2) 远端编辑 deploy/docker/docker-compose.yml
|
||||
# a. 注释掉 redis: 整个服务块(包括 healthcheck)
|
||||
# b. single-admin-api / single-front-api 把 REDIS_HOST: redis 改成 172.17.0.1
|
||||
# (docker bridge 网关;宝塔默认端口 6379;先确保 Redis 监听 0.0.0.0 且防火墙放行 6379 to docker subnet)
|
||||
# c. integral-resell/.env 把 REDIS_HOST=redis 同步改为 172.17.0.1
|
||||
|
||||
# 3) 同步重启
|
||||
./sync-to-server.sh up
|
||||
```
|
||||
|
||||
### 6. 阿里云 RDS 白名单
|
||||
|
||||
到 RDS 控制台 → 白名单 → 加入 `116.62.83.240`(如果 RDS 与 ECS 同 VPC 则用内网地址 + 内网白名单更优)。
|
||||
|
||||
### 7. 数据库初始化
|
||||
|
||||
```bash
|
||||
# 本地把 SQL 上传到服务器(默认 db/ 已被 rsync 同步过去)
|
||||
./remote-up.sh ssh
|
||||
# 远端
|
||||
mysql -h <RDS_HOST> -u <RDS_USER> -p<RDS_PASS> < /root/integral-shop/db/yangtangyoupin.sql
|
||||
mysql -h <RDS_HOST> -u <RDS_USER> -p<RDS_PASS> < /root/integral-shop/db/shop22-v2.sql
|
||||
```
|
||||
|
||||
也可以用宝塔自带的 phpMyAdmin 直接连 RDS 导入。
|
||||
|
||||
---
|
||||
|
||||
## 十一、各文件清单一览
|
||||
|
||||
参见 `deploy/docker/` 下:
|
||||
|
||||
- `docker-compose.yml` ← 编排入口
|
||||
- `.env.example` ← 环境变量模板
|
||||
- `redis/redis.conf`
|
||||
- `integral-resell/houtai.Dockerfile`
|
||||
- `integral-resell/h5.Dockerfile`
|
||||
- `integral-resell/nginx-h5.conf`
|
||||
- `integral-resell/.env.template`
|
||||
- `integral-resell/docker-entrypoint-h5.sh`
|
||||
- `single-shop/admin-api.Dockerfile`
|
||||
- `single-shop/front-api.Dockerfile`
|
||||
- `single-shop/admin-web.Dockerfile`
|
||||
- `single-shop/h5.Dockerfile`
|
||||
- `single-shop/application-docker.yml`
|
||||
- `single-shop/nginx-admin-web.conf`
|
||||
- `single-shop/nginx-h5.conf`
|
||||
- `scripts/server.env.example` ← 远端 SSH 配置模板
|
||||
- `scripts/sync-to-server.sh` ← rsync 增量同步 + 触发远端部署
|
||||
- `scripts/remote-up.sh` ← 远端 `docker compose` 操作(up/build/restart/logs/ps/exec/ssh)
|
||||
- `scripts/bootstrap-remote-env.sh` ← 首次在远端生成 .env 模板
|
||||
|
||||
每个文件都已生成可直接使用的版本。
|
||||
@@ -3,13 +3,18 @@ ENV = 'development'
|
||||
|
||||
# base api
|
||||
# VUE_APP_BASE_API = '/dev-api'
|
||||
# VUE_APP_BASE_API = 'http://127.0.0.1:30032'
|
||||
# VUE_APP_BASE_API = 'http://127.0.0.1:30032'
|
||||
# VUE_APP_BASE_API = 'https://jfadmin.suzhouyuqi.com'
|
||||
# VUE_APP_BASE_API = 'http://jfanyueadmin.szxingming.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.wenjinhui.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin-bsy.bosenyuan.com'
|
||||
# byjyw149 项目(宝应金雅文商贸)
|
||||
VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# shjjy153 项目
|
||||
# VUE_APP_BASE_API = 'http://jjy-jfadmin.fwxgpt.com'
|
||||
|
||||
# byhlc112 项目
|
||||
VUE_APP_BASE_API = 'https://jf.lehoo6.com'
|
||||
|
||||
# hapr191 项目(淮安鹏然商贸)
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.hapengran.com'
|
||||
|
||||
@@ -9,7 +9,12 @@ ENV = 'production'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.xiashengjun.com'
|
||||
|
||||
# byjyw149 项目(宝应金雅文商贸)
|
||||
VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# shjjy153 项目
|
||||
# VUE_APP_BASE_API = 'http://jjy-jfadmin.fwxgpt.com'
|
||||
|
||||
# byhlc112 项目
|
||||
VUE_APP_BASE_API = 'https://jf.lehoo6.com'
|
||||
|
||||
# hapr191 项目(淮安鹏然商贸)
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.hapengran.com'
|
||||
@@ -17,6 +22,5 @@ VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# shccd159 项目
|
||||
# VUE_APP_BASE_API = 'http://ccd-jfadmin.cichude.com'
|
||||
|
||||
|
||||
# VUE_APP_BASE_API = 'http://jfanyueadmin.szxingming.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin-bsy.bosenyuan.com'
|
||||
|
||||
@@ -148,6 +148,8 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
.antMatchers("/api/admin/store/product/copy/**").permitAll()
|
||||
.antMatchers("/api/admin/merchandise/select").permitAll()
|
||||
.antMatchers("/api/admin/merchandise/update").permitAll()
|
||||
// 老板驾驶舱独立 H5 页面接口,本机演示和报表归档使用
|
||||
.antMatchers("/api/admin/dashboard/**").permitAll()
|
||||
// 积分模块外部免认证只读接口(供 /integral-external/* 页面调用)
|
||||
.antMatchers("/api/external/integral/**").permitAll()
|
||||
// 除上面外的所有请求全部需要鉴权认证
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.zbkj.admin.controller;
|
||||
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.BossDashboardService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("api/admin/dashboard")
|
||||
@Api(tags = "老板经营驾驶舱")
|
||||
public class BossDashboardController {
|
||||
|
||||
@Autowired
|
||||
private BossDashboardService bossDashboardService;
|
||||
|
||||
@ApiOperation(value = "老板驾驶舱概览")
|
||||
@RequestMapping(value = "/overview", method = RequestMethod.GET)
|
||||
public CommonResult<BossDashboardResponse> overview(@RequestParam(value = "date", required = false) String date) {
|
||||
return CommonResult.success(bossDashboardService.overview(date));
|
||||
}
|
||||
|
||||
@ApiOperation(value = "生成经营日报归档 HTML")
|
||||
@RequestMapping(value = "/daily-report/archive", method = RequestMethod.GET)
|
||||
public ResponseEntity<byte[]> dailyReportArchive(@RequestParam(value = "date", required = false) String date) {
|
||||
BossDashboardResponse overview = bossDashboardService.overview(date);
|
||||
String html = bossDashboardService.dailyReportArchiveHtml(date);
|
||||
String filename = "dashboard-daily-report-" + overview.getBusinessDate() + ".html";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(new MediaType("text", "html", StandardCharsets.UTF_8));
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"");
|
||||
return ResponseEntity.ok().headers(headers).body(html.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public class UploadController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,news文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> image(MultipartFile multipart,
|
||||
public CommonResult<FileResultVo> image(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
return CommonResult.success(uploadService.imageUpload(multipart, model, pid));
|
||||
@@ -66,7 +66,7 @@ public class UploadController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,news文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> file(MultipartFile multipart,
|
||||
public CommonResult<FileResultVo> file(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
return CommonResult.success(uploadService.fileUpload(multipart, model, pid));
|
||||
@@ -75,4 +75,3 @@ public class UploadController {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
# 订单同步配置(每个单商户实例需要配置不同的source-id和target-mer-id)
|
||||
sync:
|
||||
source-id: shop_16
|
||||
target-mer-id: 16
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: byhlc112
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 39.97.236.112 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./crmeb_log
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -0,0 +1,60 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
# 订单同步配置(每个单商户实例需要配置不同的source-id和target-mer-id)
|
||||
sync:
|
||||
source-id: shop_15
|
||||
target-mer-id: 15
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 116.62.83.240 #地址
|
||||
port: 6379 #端口
|
||||
password: 'UthinkCloud2017'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 25 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 25 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./crmeb_log
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -38,7 +38,7 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: byjyw149
|
||||
active: byhlc112
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB #设置单个文件大小
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.zbkj.common.response.dashboard;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 老板驾驶舱响应对象
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(value = "BossDashboardResponse", description = "老板驾驶舱响应对象")
|
||||
public class BossDashboardResponse {
|
||||
|
||||
@ApiModelProperty(value = "业务日期")
|
||||
private String businessDate;
|
||||
|
||||
@ApiModelProperty(value = "生成时间")
|
||||
private String generatedAt;
|
||||
|
||||
@ApiModelProperty(value = "经营摘要")
|
||||
private String summary;
|
||||
|
||||
@ApiModelProperty(value = "核心指标")
|
||||
private List<KpiMetric> kpis = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "资金池指标")
|
||||
private List<KpiMetric> fundPool = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "今日节点快报")
|
||||
private List<TodaySnapshot> snapshots = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "近 7 天趋势")
|
||||
private List<TrendPoint> trends = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "高价值用户排行")
|
||||
private List<RankItem> userRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "团队贡献排行")
|
||||
private List<RankItem> teamRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "高货值未成交商品排行")
|
||||
private List<RankItem> productRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "风险预警")
|
||||
private List<RiskAlert> risks = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class KpiMetric {
|
||||
private String key;
|
||||
private String title;
|
||||
private Object value;
|
||||
private String unit;
|
||||
private String trendLabel;
|
||||
private BigDecimal trendValue;
|
||||
private String status = "normal";
|
||||
private Boolean featured = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TodaySnapshot {
|
||||
private String slot;
|
||||
private String title;
|
||||
private String status;
|
||||
private String generatedAt;
|
||||
private String message;
|
||||
private Integer purchaseUsers = 0;
|
||||
private Integer orderCount = 0;
|
||||
private BigDecimal dealAmount = BigDecimal.ZERO;
|
||||
private BigDecimal paidAmount = BigDecimal.ZERO;
|
||||
private Integer newMerchandiseCount = 0;
|
||||
private BigDecimal selfBonusChange = BigDecimal.ZERO;
|
||||
private BigDecimal shareBonusChange = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TrendPoint {
|
||||
private String date;
|
||||
private BigDecimal amount = BigDecimal.ZERO;
|
||||
private Integer orders = 0;
|
||||
private Integer newUsers = 0;
|
||||
private BigDecimal bonus = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RankItem {
|
||||
private String id;
|
||||
private String name;
|
||||
private BigDecimal value = BigDecimal.ZERO;
|
||||
private String description;
|
||||
private String badge;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RiskAlert {
|
||||
private String id;
|
||||
private String level;
|
||||
private String type;
|
||||
private String title;
|
||||
private String description;
|
||||
private String discoveredAt;
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,11 @@ public class UploadFrontController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,news文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> image(MultipartFile multipart, @RequestParam(value = "model") String model,
|
||||
public CommonResult<FileResultVo> image(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
return CommonResult.success(uploadService.imageUpload(multipart, model, pid));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ public class UserUploadController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,news文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> image(MultipartFile multipart,
|
||||
public CommonResult<FileResultVo> image(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
|
||||
@@ -66,9 +66,9 @@ public class UserUploadController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,news文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> imageOuter(MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
public CommonResult<FileResultVo> imageOuter(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
|
||||
return CommonResult.success(uploadService.imageUpload(multipart, model, pid));
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public class UserUploadController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,news文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> file(MultipartFile multipart,
|
||||
public CommonResult<FileResultVo> file(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
return CommonResult.success(uploadService.fileUpload(multipart, model, pid));
|
||||
@@ -92,4 +92,3 @@ public class UserUploadController {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.zbkj.common.response.WaLoginResponse;
|
||||
import com.zbkj.common.response.WaUserInfoResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.common.token.FrontTokenComponent;
|
||||
import com.zbkj.common.config.CrmebConfig;
|
||||
import com.zbkj.common.vo.FileResultVo;
|
||||
import com.zbkj.front.service.WaUserService;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
@@ -14,7 +15,6 @@ import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiImplicitParam;
|
||||
import io.swagger.annotations.ApiImplicitParams;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
@@ -22,6 +22,9 @@ import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -44,12 +47,13 @@ import java.util.Date;
|
||||
* |
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("api/front/wa/user")
|
||||
@Api(tags = "寄卖服务 -- 用户认证")
|
||||
public class WaUserController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WaUserController.class);
|
||||
|
||||
@Autowired
|
||||
private WaUserService waUserService;
|
||||
|
||||
@@ -62,6 +66,17 @@ public class WaUserController {
|
||||
@Autowired
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Autowired
|
||||
private CrmebConfig crmebConfig;
|
||||
|
||||
private String buildPublicFileUrl(String relativeUrl) {
|
||||
String domain = StringUtils.defaultString(crmebConfig.getDomain(), "https://h5y2c.com").trim();
|
||||
if (!StringUtils.startsWithAny(domain, "http://", "https://")) {
|
||||
domain = "https://" + domain;
|
||||
}
|
||||
return StringUtils.removeEnd(domain, "/") + "/" + StringUtils.removeStart(relativeUrl, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理PDF文件,添加用户签名和签署日期
|
||||
* @param signatureImage 用户签名图片
|
||||
@@ -73,7 +88,7 @@ public class WaUserController {
|
||||
FileInputStream fileInputStream = null;
|
||||
try {
|
||||
// 读取模板PDF文件
|
||||
Resource resource = new ClassPathResource("pdf/sign_contract_byjyw149.pdf");
|
||||
Resource resource = new ClassPathResource("pdf/sign_contract_byhlc112.pdf");
|
||||
InputStream pdfInputStream = resource.getInputStream();
|
||||
document = PDDocument.load(pdfInputStream);
|
||||
pdfInputStream.close();
|
||||
@@ -185,21 +200,27 @@ public class WaUserController {
|
||||
@ApiImplicitParam(name = "model", value = "模块 用户user,商品product,微信wechat,新闻文章"),
|
||||
@ApiImplicitParam(name = "pid", value = "分类ID 0编辑器,1商品图片,2拼团图片,3砍价图片,4秒杀图片,5文章图片,6组合数据图,7前台用户,8微信系列 ", allowableValues = "range[0,1,2,3,4,5,6,7,8]")
|
||||
})
|
||||
public CommonResult<FileResultVo> image(MultipartFile multipart, @RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid") Integer pid) throws IOException {
|
||||
public CommonResult<FileResultVo> image(@RequestParam("multipart") MultipartFile multipart,
|
||||
@RequestParam(value = "model") String model,
|
||||
@RequestParam(value = "pid", required = false) Integer pid,
|
||||
@RequestParam(value = "userId", required = false) Integer userId) throws IOException {
|
||||
// 如果是用户模型且上传的是图片,则先处理PDF文件
|
||||
if ("user".equals(model) && multipart != null) {
|
||||
// 处理PDF文件,添加用户签名和签署日期
|
||||
FileResultVo pdfResultVo = processPdfWithSignature(multipart);
|
||||
if (pdfResultVo != null) {
|
||||
// 更新用户contract字段
|
||||
if (pid != null) {
|
||||
Integer targetUserId = userId != null ? userId : pid;
|
||||
if (targetUserId == null) {
|
||||
targetUserId = frontTokenComponent.getUserId();
|
||||
}
|
||||
if (targetUserId != null) {
|
||||
WaUsers user = new WaUsers();
|
||||
user.setId(pid);
|
||||
user.setId(targetUserId);
|
||||
// user.setContract("https://anyue.szxingming.com/"+pdfResultVo.getUrl());
|
||||
// user.setContract("https://xiashengjun.com/"+pdfResultVo.getUrl());
|
||||
// user.setContract("https://ccd.cichude.com/"+pdfResultVo.getUrl());
|
||||
user.setContract("https://jinyawen.com/"+pdfResultVo.getUrl());
|
||||
user.setContract(buildPublicFileUrl(pdfResultVo.getUrl()));
|
||||
waUsersDao.updateById(user);
|
||||
}
|
||||
return CommonResult.success(pdfResultVo);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/h5y2c.com/ # 服务器图片路径配置 斜杠结尾
|
||||
domain: h5y2c.com # 当前项目域名,合同/PDF 等公开地址拼接使用
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: byhlc112
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 39.97.236.112 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -0,0 +1,54 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/leilei.czchunfang.com/ # 服务器图片路径配置 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 116.62.83.240 #地址
|
||||
port: 6379 #端口
|
||||
password: 'UthinkCloud2017'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 25 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 25 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -32,7 +32,7 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: byjyw149
|
||||
active: byhlc112
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB #设置单个文件大小
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱服务
|
||||
*/
|
||||
public interface BossDashboardService {
|
||||
|
||||
/**
|
||||
* 获取老板经营驾驶舱数据
|
||||
*
|
||||
* @param date 业务日期,格式 yyyy-MM-dd,为空时默认上一个工作日
|
||||
* @return BossDashboardResponse
|
||||
*/
|
||||
BossDashboardResponse overview(String date);
|
||||
|
||||
/**
|
||||
* 生成经营日报归档 HTML
|
||||
*
|
||||
* @param date 业务日期,格式 yyyy-MM-dd,为空时默认上一个工作日
|
||||
* @return standalone HTML
|
||||
*/
|
||||
String dailyReportArchiveHtml(String date);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateTime;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaMerchandise;
|
||||
import com.zbkj.common.model.consignment.WaOrder;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.consignment.WaSharebonusLog;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.model.consignment.WaWithdraw;
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
import com.zbkj.service.dao.consignment.WaMerchandiseDao;
|
||||
import com.zbkj.service.dao.consignment.WaOrderDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.dao.consignment.WaSharebonusLogDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.dao.consignment.WaWithdrawDao;
|
||||
import com.zbkj.service.service.BossDashboardService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱服务实现
|
||||
*/
|
||||
@Service
|
||||
public class BossDashboardServiceImpl implements BossDashboardService {
|
||||
|
||||
@Resource
|
||||
private WaOrderDao waOrderDao;
|
||||
|
||||
@Resource
|
||||
private WaMerchandiseDao waMerchandiseDao;
|
||||
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Resource
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
|
||||
@Resource
|
||||
private WaSharebonusLogDao waSharebonusLogDao;
|
||||
|
||||
@Resource
|
||||
private WaWithdrawDao waWithdrawDao;
|
||||
|
||||
@Override
|
||||
public BossDashboardResponse overview(String date) {
|
||||
DateTime businessDate = StrUtil.isBlank(date) ? previousWorkday(DateUtil.date()) : DateUtil.parseDate(date);
|
||||
DateTime previousDate = previousWorkday(businessDate);
|
||||
DateRange businessRange = dayRange(businessDate);
|
||||
DateRange previousRange = dayRange(previousDate);
|
||||
|
||||
DailyMetrics metrics = buildDailyMetrics(businessRange);
|
||||
DailyMetrics previousMetrics = buildDailyMetrics(previousRange);
|
||||
|
||||
BossDashboardResponse response = new BossDashboardResponse();
|
||||
response.setBusinessDate(businessDate.toString("yyyy-MM-dd"));
|
||||
response.setGeneratedAt(DateUtil.formatDateTime(new Date()));
|
||||
response.setSummary(buildSummary(metrics));
|
||||
response.getKpis().add(metric("dealAmount", "上个工作日成交额", metrics.dealAmount, "元", "较上一工作日", ratio(metrics.dealAmount, previousMetrics.dealAmount), statusByRatio(metrics.dealAmount, previousMetrics.dealAmount), true));
|
||||
response.getKpis().add(metric("orderCount", "上个工作日订单数", metrics.orderCount, "单", "较上一工作日", ratio(metrics.orderCount, previousMetrics.orderCount), statusByRatio(metrics.orderCount, previousMetrics.orderCount), false));
|
||||
response.getKpis().add(metric("purchaseUsers", "采购用户", metrics.purchaseUsers, "人", "较上一工作日", ratio(metrics.purchaseUsers, previousMetrics.purchaseUsers), statusByRatio(metrics.purchaseUsers, previousMetrics.purchaseUsers), false));
|
||||
response.getKpis().add(metric("newUsers", "新增用户", metrics.newUsers, "人", "较上一工作日", ratio(metrics.newUsers, previousMetrics.newUsers), statusByRatio(metrics.newUsers, previousMetrics.newUsers), false));
|
||||
response.getKpis().add(metric("newMerchandise", "新增寄售商品", metrics.newMerchandiseCount, "件", "较上一工作日", ratio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), statusByRatio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), false));
|
||||
response.getKpis().add(metric("selfBonus", "个人奖金发放", metrics.selfBonus, "元", "较上一工作日", ratio(metrics.selfBonus, previousMetrics.selfBonus), "normal", false));
|
||||
response.getKpis().add(metric("shareBonus", "推广奖金发放", metrics.shareBonus, "元", "较上一工作日", ratio(metrics.shareBonus, previousMetrics.shareBonus), "normal", false));
|
||||
response.getKpis().add(metric("pendingAmount", "待支付/待结算", metrics.pendingAmount, "元", "需关注", null, metrics.pendingAmount.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
|
||||
|
||||
buildFundPool(response);
|
||||
buildSnapshots(response);
|
||||
buildTrends(response, businessDate);
|
||||
buildRanks(response);
|
||||
buildRisks(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String dailyReportArchiveHtml(String date) {
|
||||
BossDashboardResponse data = overview(date);
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">");
|
||||
html.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">");
|
||||
html.append("<title>经营日报归档 - ").append(escape(data.getBusinessDate())).append("</title>");
|
||||
html.append("<style>");
|
||||
html.append(":root{color:#132033;background:#fff6f1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,rgba(255,91,54,.18),transparent 30rem),#fff6f1}.page{max-width:820px;margin:0 auto;padding:28px 18px 40px}.hero{color:#fff;padding:26px;border-radius:0 0 28px 28px;background:linear-gradient(145deg,#ff5b36,#ff8b52),radial-gradient(circle at 90% 10%,rgba(255,176,0,.42),transparent 18rem);box-shadow:0 16px 40px rgba(255,91,54,.14)}.eyebrow{margin:0;color:rgba(255,255,255,.76);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.hero h1{margin:12px 0 8px;font-size:32px;line-height:1.1}.hero p{margin:0;color:rgba(255,255,255,.82);line-height:1.7}.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.meta span{padding:7px 12px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.2);font-size:12px;font-weight:700}.section{margin-top:16px;padding:18px;background:#fff;border:1px solid rgba(19,32,51,.08);border-radius:24px;box-shadow:0 10px 28px rgba(22,47,80,.08)}.section h2{margin:0 0 14px;font-size:20px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.card{padding:14px;border-radius:18px;background:#f6f9fb}.card small{display:block;color:#6b7a90}.card strong{display:block;margin-top:6px;font-size:22px}.list{display:grid;gap:10px}.item{padding:12px;border-radius:16px;background:#f6f9fb}.item strong{display:block}.item small,.item p{color:#6b7a90}.risk-red strong{color:#dc2626}.risk-yellow strong{color:#ffb000}.footer{margin-top:18px;color:#6b7a90;font-size:12px;text-align:center}@media(max-width:560px){.grid{grid-template-columns:1fr}.hero h1{font-size:28px}}");
|
||||
html.append("</style></head><body><main class=\"page\">");
|
||||
html.append("<header class=\"hero\"><p class=\"eyebrow\">Daily Report Archive</p><h1>经营日报归档</h1>");
|
||||
html.append("<p>").append(escape(data.getSummary())).append("</p><div class=\"meta\">");
|
||||
html.append("<span>数据日期:").append(escape(data.getBusinessDate())).append("</span>");
|
||||
html.append("<span>生成时间:").append(escape(data.getGeneratedAt())).append("</span>");
|
||||
html.append("<span>归档类型:Standalone HTML</span></div></header>");
|
||||
appendMetricsSection(html, "核心经营指标", data.getKpis());
|
||||
appendTrendSection(html, data);
|
||||
appendMetricsSection(html, "资金池摘要", data.getFundPool());
|
||||
appendRankSection(html, "高价值用户", data.getUserRanks());
|
||||
appendRankSection(html, "团队贡献排行", data.getTeamRanks());
|
||||
appendRankSection(html, "高货值未成交商品", data.getProductRanks());
|
||||
appendRiskSection(html, data);
|
||||
html.append("<p class=\"footer\">本归档由经营驾驶舱实时数据生成,可独立保存和打开。</p>");
|
||||
html.append("</main></body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private DailyMetrics buildDailyMetrics(DateRange range) {
|
||||
DailyMetrics metrics = new DailyMetrics();
|
||||
metrics.dealAmount = sumOrderAmount(range.start, range.end, true, null);
|
||||
metrics.orderCount = countOrders(range.start, range.end, null);
|
||||
metrics.purchaseUsers = distinctBuyerCount(range.start, range.end, null);
|
||||
metrics.newUsers = countUsers(range.start, range.end);
|
||||
metrics.newMerchandiseCount = countMerchandise(range.start, range.end);
|
||||
metrics.selfBonus = sumSelfBonus(range.start, range.end);
|
||||
metrics.shareBonus = sumShareBonus(range.start, range.end);
|
||||
metrics.pendingAmount = sumOrderAmount(range.start, range.end, false, null);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private void buildFundPool(BossDashboardResponse response) {
|
||||
BigDecimal money = sumUsersDecimal("money");
|
||||
BigDecimal coupon = sumUsersDecimal("coupon");
|
||||
BigDecimal selfBonus = sumUsersDecimal("self_bonus");
|
||||
BigDecimal shareBonus = sumUsersDecimal("share_bonus");
|
||||
BigDecimal score = sumUsersDecimal("score");
|
||||
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
|
||||
Integer pendingWithdrawCount = countWithdraw(0);
|
||||
|
||||
response.getFundPool().add(metric("balance", "余额总额", money, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("coupon", "优惠券总额", coupon, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("selfBonusPool", "个人奖金总额", selfBonus, "元", null, null, selfBonus.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
|
||||
response.getFundPool().add(metric("shareBonusPool", "推广奖金总额", shareBonus, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("integral", "积分总额", score, "分", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("withdrawPending", "待审核提现", pendingWithdraw, "元", pendingWithdrawCount + " 笔", null, pendingWithdrawCount > 0 ? "danger" : "normal", false));
|
||||
}
|
||||
|
||||
private void buildSnapshots(BossDashboardResponse response) {
|
||||
DateTime today = DateUtil.date();
|
||||
DateRange morningRange = range(today, "00:00:00", "10:15:59");
|
||||
DateRange afternoonRange = range(today, "10:16:00", "14:55:59");
|
||||
|
||||
DailyMetrics morningMetrics = buildDailyMetrics(morningRange);
|
||||
BossDashboardResponse.TodaySnapshot morning = snapshot("1015", "10:15 上午快报", morningRange.end, "上午抢购节点已完成,上一日寄卖商品消化情况请关注成交额、付款和采购用户。", morningMetrics, "success");
|
||||
response.getSnapshots().add(morning);
|
||||
|
||||
String afternoonStatus = new Date().after(afternoonRange.end) ? "success" : "pending";
|
||||
String afternoonMessage = "success".equals(afternoonStatus)
|
||||
? "下午寄卖/转卖节点已完成,请关注用户抢购商品的再次上架与转卖承接。"
|
||||
: "下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。";
|
||||
DailyMetrics afternoonMetrics = "success".equals(afternoonStatus) ? buildDailyMetrics(afternoonRange) : new DailyMetrics();
|
||||
response.getSnapshots().add(snapshot("1455", "14:55 下午快报", afternoonRange.end, afternoonMessage, afternoonMetrics, afternoonStatus));
|
||||
}
|
||||
|
||||
private void buildTrends(BossDashboardResponse response, DateTime businessDate) {
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
DateTime date = DateUtil.offsetDay(businessDate, -i);
|
||||
DailyMetrics metrics = buildDailyMetrics(dayRange(date));
|
||||
BossDashboardResponse.TrendPoint point = new BossDashboardResponse.TrendPoint();
|
||||
point.setDate(date.toString("MM-dd"));
|
||||
point.setAmount(metrics.dealAmount);
|
||||
point.setOrders(metrics.orderCount);
|
||||
point.setNewUsers(metrics.newUsers);
|
||||
point.setBonus(metrics.selfBonus.add(metrics.shareBonus));
|
||||
response.getTrends().add(point);
|
||||
}
|
||||
}
|
||||
|
||||
private void buildRanks(BossDashboardResponse response) {
|
||||
QueryWrapper<WaUsers> userWrapper = new QueryWrapper<WaUsers>()
|
||||
.select("id", "nickname", "mobile", "self_bonus", "share_bonus", "coupon", "score")
|
||||
.orderByDesc("IFNULL(self_bonus,0) + IFNULL(share_bonus,0) + IFNULL(coupon,0)")
|
||||
.last("limit 3");
|
||||
List<WaUsers> users = waUsersDao.selectList(userWrapper);
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
WaUsers user = users.get(i);
|
||||
BigDecimal value = defaultDecimal(user.getSelfBonus()).add(defaultDecimal(user.getShareBonus())).add(defaultDecimal(user.getCoupon()));
|
||||
response.getUserRanks().add(rank("u" + user.getId(), displayName(user), value, maskMobile(user.getMobile()), i == 0 ? "高价值" : null));
|
||||
}
|
||||
|
||||
QueryWrapper<WaUsers> teamWrapper = new QueryWrapper<WaUsers>()
|
||||
.select("pid as id", "COUNT(id) as memberCount", "IFNULL(SUM(self_bonus),0) as selfBonus", "IFNULL(SUM(share_bonus),0) as shareBonus")
|
||||
.isNotNull("pid")
|
||||
.gt("pid", 0)
|
||||
.groupBy("pid")
|
||||
.orderByDesc("IFNULL(SUM(self_bonus),0) + IFNULL(SUM(share_bonus),0)")
|
||||
.last("limit 3");
|
||||
List<Map<String, Object>> teams = waUsersDao.selectMaps(teamWrapper);
|
||||
for (int i = 0; i < teams.size(); i++) {
|
||||
Map<String, Object> team = teams.get(i);
|
||||
BigDecimal selfBonus = decimal(team.get("selfBonus"));
|
||||
BigDecimal shareBonus = decimal(team.get("shareBonus"));
|
||||
String leaderId = stringValue(team.get("id"));
|
||||
WaUsers leader = waUsersDao.selectById(leaderId);
|
||||
String leaderName = leader == null ? "团队 " + leaderId : displayName(leader);
|
||||
response.getTeamRanks().add(rank("t" + leaderId, leaderName, selfBonus.add(shareBonus), "成员 " + intValue(team.get("memberCount")) + " 人", i == 0 ? "TOP1" : null));
|
||||
}
|
||||
|
||||
QueryWrapper<WaMerchandise> productWrapper = new QueryWrapper<WaMerchandise>()
|
||||
.select("id", "title", "price", "created_at")
|
||||
.eq("status", 1)
|
||||
.orderByDesc("price")
|
||||
.last("limit 3");
|
||||
List<WaMerchandise> products = waMerchandiseDao.selectList(productWrapper);
|
||||
for (int i = 0; i < products.size(); i++) {
|
||||
WaMerchandise product = products.get(i);
|
||||
response.getProductRanks().add(rank("p" + product.getId(), StrUtil.isBlank(product.getTitle()) ? "未命名商品" : product.getTitle(), defaultDecimal(product.getPrice()), "高货值待成交", i == 0 ? "滞销" : null));
|
||||
}
|
||||
}
|
||||
|
||||
private void buildRisks(BossDashboardResponse response) {
|
||||
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
|
||||
if (pendingWithdraw.compareTo(BigDecimal.ZERO) > 0) {
|
||||
response.getRisks().add(risk("r1", "red", "资金", "待审核提现", "当前待审核提现 " + pendingWithdraw + " 元,建议今日处理。"));
|
||||
}
|
||||
|
||||
Integer pendingOrders = countPendingOrders();
|
||||
if (pendingOrders > 0) {
|
||||
response.getRisks().add(risk("r2", "yellow", "订单", "待支付订单未处理", "当前存在 " + pendingOrders + " 笔待支付订单,请关注付款转化。"));
|
||||
}
|
||||
|
||||
Integer hiddenProducts = countHiddenMerchandise();
|
||||
if (hiddenProducts > 0) {
|
||||
response.getRisks().add(risk("r3", "gray", "商品", "隐藏寄售商品", "当前存在 " + hiddenProducts + " 个隐藏寄售商品,可按需核查。"));
|
||||
}
|
||||
}
|
||||
|
||||
private BossDashboardResponse.KpiMetric metric(String key, String title, Object value, String unit, String trendLabel, BigDecimal trendValue, String status, Boolean featured) {
|
||||
BossDashboardResponse.KpiMetric metric = new BossDashboardResponse.KpiMetric();
|
||||
metric.setKey(key);
|
||||
metric.setTitle(title);
|
||||
metric.setValue(value);
|
||||
metric.setUnit(unit);
|
||||
metric.setTrendLabel(trendLabel);
|
||||
metric.setTrendValue(trendValue);
|
||||
metric.setStatus(status);
|
||||
metric.setFeatured(featured);
|
||||
return metric;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.TodaySnapshot snapshot(String slot, String title, Date generatedAt, String message, DailyMetrics metrics, String status) {
|
||||
BossDashboardResponse.TodaySnapshot snapshot = new BossDashboardResponse.TodaySnapshot();
|
||||
snapshot.setSlot(slot);
|
||||
snapshot.setTitle(title);
|
||||
snapshot.setStatus(status);
|
||||
snapshot.setGeneratedAt(DateUtil.formatDateTime(generatedAt));
|
||||
snapshot.setMessage(message);
|
||||
snapshot.setPurchaseUsers(metrics.purchaseUsers);
|
||||
snapshot.setOrderCount(metrics.orderCount);
|
||||
snapshot.setDealAmount(metrics.dealAmount);
|
||||
snapshot.setPaidAmount(metrics.dealAmount);
|
||||
snapshot.setNewMerchandiseCount(metrics.newMerchandiseCount);
|
||||
snapshot.setSelfBonusChange(metrics.selfBonus);
|
||||
snapshot.setShareBonusChange(metrics.shareBonus);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.RankItem rank(String id, String name, BigDecimal value, String description, String badge) {
|
||||
BossDashboardResponse.RankItem rank = new BossDashboardResponse.RankItem();
|
||||
rank.setId(id);
|
||||
rank.setName(name);
|
||||
rank.setValue(value);
|
||||
rank.setDescription(description);
|
||||
rank.setBadge(badge);
|
||||
return rank;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.RiskAlert risk(String id, String level, String type, String title, String description) {
|
||||
BossDashboardResponse.RiskAlert risk = new BossDashboardResponse.RiskAlert();
|
||||
risk.setId(id);
|
||||
risk.setLevel(level);
|
||||
risk.setType(type);
|
||||
risk.setTitle(title);
|
||||
risk.setDescription(description);
|
||||
risk.setDiscoveredAt(DateUtil.format(new Date(), "HH:mm"));
|
||||
return risk;
|
||||
}
|
||||
|
||||
private void appendMetricsSection(StringBuilder html, String title, List<BossDashboardResponse.KpiMetric> metrics) {
|
||||
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"grid\">");
|
||||
for (BossDashboardResponse.KpiMetric metric : metrics) {
|
||||
html.append("<article class=\"card\"><small>").append(escape(metric.getTitle())).append("</small>");
|
||||
html.append("<strong>").append(formatMetric(metric.getValue(), metric.getUnit())).append("</strong>");
|
||||
if (StrUtil.isNotBlank(metric.getTrendLabel()) || metric.getTrendValue() != null) {
|
||||
html.append("<small>").append(escape(metric.getTrendLabel()));
|
||||
if (metric.getTrendValue() != null) {
|
||||
html.append(" ").append(metric.getTrendValue()).append("%");
|
||||
}
|
||||
html.append("</small>");
|
||||
}
|
||||
html.append("</article>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendTrendSection(StringBuilder html, BossDashboardResponse data) {
|
||||
html.append("<section class=\"section\"><h2>最近 7 天趋势</h2><div class=\"list\">");
|
||||
for (BossDashboardResponse.TrendPoint point : data.getTrends()) {
|
||||
html.append("<div class=\"item\"><strong>").append(escape(point.getDate())).append(":").append(formatMoney(point.getAmount())).append("</strong>");
|
||||
html.append("<small>").append(point.getOrders()).append(" 单 / 新增用户 ").append(point.getNewUsers()).append(" / 奖金 ").append(formatMoney(point.getBonus())).append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendRankSection(StringBuilder html, String title, List<BossDashboardResponse.RankItem> ranks) {
|
||||
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"list\">");
|
||||
if (ranks.isEmpty()) {
|
||||
html.append("<div class=\"item\"><strong>暂无数据</strong><small>当前实时数据未生成该排行。</small></div>");
|
||||
}
|
||||
for (int i = 0; i < ranks.size(); i++) {
|
||||
BossDashboardResponse.RankItem rank = ranks.get(i);
|
||||
html.append("<div class=\"item\"><strong>").append(i + 1).append(". ").append(escape(rank.getName())).append(" · ").append(formatMoney(rank.getValue())).append("</strong>");
|
||||
html.append("<small>").append(escape(rank.getDescription()));
|
||||
if (StrUtil.isNotBlank(rank.getBadge())) {
|
||||
html.append(" / ").append(escape(rank.getBadge()));
|
||||
}
|
||||
html.append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendRiskSection(StringBuilder html, BossDashboardResponse data) {
|
||||
html.append("<section class=\"section\"><h2>风险预警</h2><div class=\"list\">");
|
||||
if (data.getRisks().isEmpty()) {
|
||||
html.append("<div class=\"item\"><strong>暂无风险</strong><small>当前实时数据未触发风险预警。</small></div>");
|
||||
}
|
||||
for (BossDashboardResponse.RiskAlert risk : data.getRisks()) {
|
||||
html.append("<div class=\"item risk-").append(escape(risk.getLevel())).append("\"><strong>");
|
||||
html.append(escape(risk.getType())).append(" / ").append(escape(risk.getTitle())).append("</strong>");
|
||||
html.append("<p>").append(escape(risk.getDescription())).append("</p>");
|
||||
html.append("<small>发现时间:").append(escape(risk.getDiscoveredAt())).append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private BigDecimal sumOrderAmount(Date start, Date end, Boolean paidOnly, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("IFNULL(SUM(total_money),0) as total").between("buy_time", start, end);
|
||||
wrapper.eq("is_cancel", 0);
|
||||
if (Boolean.TRUE.equals(paidOnly)) {
|
||||
wrapper.ge("status", 1);
|
||||
} else if (Boolean.FALSE.equals(paidOnly)) {
|
||||
wrapper.eq("status", 0);
|
||||
}
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return aggregateDecimal(waOrderDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countOrders(Date start, Date end, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().between("buy_time", start, end).eq("is_cancel", 0);
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return waOrderDao.selectCount(wrapper);
|
||||
}
|
||||
|
||||
private Integer distinctBuyerCount(Date start, Date end, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("COUNT(DISTINCT buyer_id) as total").between("buy_time", start, end).eq("is_cancel", 0);
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return aggregateInt(waOrderDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countUsers(Date start, Date end) {
|
||||
return waUsersDao.selectCount(new QueryWrapper<WaUsers>().between("join_time", start, end));
|
||||
}
|
||||
|
||||
private Integer countMerchandise(Date start, Date end) {
|
||||
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().between("created_at", start, end));
|
||||
}
|
||||
|
||||
private BigDecimal sumSelfBonus(Date start, Date end) {
|
||||
QueryWrapper<WaSelfbonusLog> wrapper = new QueryWrapper<WaSelfbonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
|
||||
return aggregateDecimal(waSelfbonusLogDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumShareBonus(Date start, Date end) {
|
||||
QueryWrapper<WaSharebonusLog> wrapper = new QueryWrapper<WaSharebonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
|
||||
return aggregateDecimal(waSharebonusLogDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumUsersDecimal(String column) {
|
||||
QueryWrapper<WaUsers> wrapper = new QueryWrapper<WaUsers>().select("IFNULL(SUM(" + column + "),0) as total");
|
||||
return aggregateDecimal(waUsersDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumWithdrawAmount(Integer status) {
|
||||
QueryWrapper<WaWithdraw> wrapper = new QueryWrapper<WaWithdraw>().select("IFNULL(SUM(money),0) as total").eq("status", status);
|
||||
return aggregateDecimal(waWithdrawDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countWithdraw(Integer status) {
|
||||
return waWithdrawDao.selectCount(new QueryWrapper<WaWithdraw>().eq("status", status));
|
||||
}
|
||||
|
||||
private Integer countPendingOrders() {
|
||||
return waOrderDao.selectCount(new QueryWrapper<WaOrder>().eq("status", 0).eq("is_cancel", 0));
|
||||
}
|
||||
|
||||
private Integer countHiddenMerchandise() {
|
||||
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().eq("is_show", 0));
|
||||
}
|
||||
|
||||
private BigDecimal ratio(BigDecimal current, BigDecimal previous) {
|
||||
if (previous == null || previous.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return current != null && current.compareTo(BigDecimal.ZERO) > 0 ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
|
||||
}
|
||||
return current.subtract(previous).multiply(BigDecimal.valueOf(100)).divide(previous, 1, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal ratio(Integer current, Integer previous) {
|
||||
return ratio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
|
||||
}
|
||||
|
||||
private String statusByRatio(BigDecimal current, BigDecimal previous) {
|
||||
BigDecimal value = ratio(current, previous);
|
||||
return value.compareTo(BigDecimal.ZERO) >= 0 ? "success" : "warning";
|
||||
}
|
||||
|
||||
private String statusByRatio(Integer current, Integer previous) {
|
||||
return statusByRatio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
|
||||
}
|
||||
|
||||
private String buildSummary(DailyMetrics metrics) {
|
||||
if (metrics.dealAmount.compareTo(BigDecimal.ZERO) == 0 && metrics.orderCount == 0) {
|
||||
return "当前日期暂无成交数据,请关注抢购与寄卖节点是否正常生成。";
|
||||
}
|
||||
return "上个工作日成交 " + metrics.dealAmount + " 元,订单 " + metrics.orderCount + " 单,采购用户 " + metrics.purchaseUsers + " 人;请重点关注待支付、提现和寄售供给。";
|
||||
}
|
||||
|
||||
private DateRange dayRange(DateTime date) {
|
||||
return new DateRange(DateUtil.beginOfDay(date), DateUtil.endOfDay(date));
|
||||
}
|
||||
|
||||
private DateTime previousWorkday(DateTime referenceDate) {
|
||||
DateTime date = DateUtil.offsetDay(referenceDate, -1);
|
||||
while (isWeekend(date)) {
|
||||
date = DateUtil.offsetDay(date, -1);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private boolean isWeekend(DateTime date) {
|
||||
int dayOfWeek = DateUtil.dayOfWeek(date);
|
||||
return dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY;
|
||||
}
|
||||
|
||||
private DateRange range(DateTime date, String startTime, String endTime) {
|
||||
String day = date.toString("yyyy-MM-dd");
|
||||
return new DateRange(DateUtil.parse(day + " " + startTime), DateUtil.parse(day + " " + endTime));
|
||||
}
|
||||
|
||||
private BigDecimal aggregateDecimal(List<Map<String, Object>> maps, String key) {
|
||||
if (maps == null || maps.isEmpty()) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return decimal(maps.get(0).get(key));
|
||||
}
|
||||
|
||||
private Integer aggregateInt(List<Map<String, Object>> maps, String key) {
|
||||
if (maps == null || maps.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return intValue(maps.get(0).get(key));
|
||||
}
|
||||
|
||||
private BigDecimal decimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
|
||||
private BigDecimal defaultDecimal(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private Integer intValue(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String displayName(WaUsers user) {
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getUsername())) {
|
||||
return user.getUsername();
|
||||
}
|
||||
return "用户 " + user.getId();
|
||||
}
|
||||
|
||||
private String maskMobile(String mobile) {
|
||||
if (StrUtil.isBlank(mobile) || mobile.length() < 7) {
|
||||
return "手机号未完善";
|
||||
}
|
||||
return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
|
||||
}
|
||||
|
||||
private String formatMetric(Object value, String unit) {
|
||||
if ("元".equals(unit)) {
|
||||
return formatMoney(decimal(value));
|
||||
}
|
||||
if (value == null) {
|
||||
return "--";
|
||||
}
|
||||
return escape(String.valueOf(value)) + (unit == null ? "" : escape(unit));
|
||||
}
|
||||
|
||||
private String formatMoney(BigDecimal value) {
|
||||
return "¥" + defaultDecimal(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private String escape(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static class DailyMetrics {
|
||||
private BigDecimal dealAmount = BigDecimal.ZERO;
|
||||
private Integer orderCount = 0;
|
||||
private Integer purchaseUsers = 0;
|
||||
private Integer newUsers = 0;
|
||||
private Integer newMerchandiseCount = 0;
|
||||
private BigDecimal selfBonus = BigDecimal.ZERO;
|
||||
private BigDecimal shareBonus = BigDecimal.ZERO;
|
||||
private BigDecimal pendingAmount = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private static class DateRange {
|
||||
private Date start;
|
||||
private Date end;
|
||||
|
||||
private DateRange(Date start, Date end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,7 +691,10 @@ public class OrderPayServiceImpl implements OrderPayService {
|
||||
userService.updateNowMoney(user, storeOrder.getPayPrice(), "sub");
|
||||
// 扣除积分
|
||||
if (storeOrder.getUseIntegral() > 0) {
|
||||
userService.updateIntegral(user, storeOrder.getUseIntegral(), "sub");
|
||||
Boolean integralUpdated = userService.updateIntegral(user, storeOrder.getUseIntegral(), "sub");
|
||||
if (!integralUpdated) {
|
||||
throw new CrmebException("用户积分不足");
|
||||
}
|
||||
}
|
||||
// 添加支付成功redis队列
|
||||
redisUtil.lPush(TaskConstants.ORDER_TASK_PAY_SUCCESS_AFTER, storeOrder.getOrderId());
|
||||
|
||||
@@ -1,265 +1,22 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.model.user.UserIntegralRecord;
|
||||
import com.zbkj.common.utils.CrmebUtil;
|
||||
import com.zbkj.service.dao.UserDao;
|
||||
import com.zbkj.service.dao.UserIntegralRecordDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 用户积分并发安全服务实现类
|
||||
* 专门处理高并发场景下的积分更新,避免数据库锁等待超时
|
||||
* 保留并发安全服务 bean 名称兼容历史调用。
|
||||
* 实际逻辑统一委托到 waSelfbonusSyncService,避免双实现规则漂移。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("userIntegralConcurrencyService")
|
||||
public class UserIntegralConcurrencyServiceImpl {
|
||||
|
||||
@Autowired
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
private WaSelfbonusSyncService waSelfbonusSyncService;
|
||||
|
||||
@Autowired
|
||||
private UserIntegralRecordDao userIntegralRecordDao;
|
||||
|
||||
@Autowired
|
||||
private UserDao userDao;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
// 使用ConcurrentHashMap来缓存正在处理的用户ID,防止重复处理
|
||||
private final ConcurrentHashMap<Integer, Object> processingUsers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分 - 并发安全版本
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分(并发安全版)");
|
||||
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
try {
|
||||
// 查询最新的个人奖金变动记录
|
||||
LambdaQueryWrapper<WaSelfbonusLog> bonusLogWrapper = new LambdaQueryWrapper<>();
|
||||
bonusLogWrapper.orderByDesc(WaSelfbonusLog::getCreatedAt); // 按创建时间倒序
|
||||
bonusLogWrapper.last("LIMIT 500"); // 限制查询500条,避免一次性处理过多
|
||||
List<WaSelfbonusLog> bonusLogList = waSelfbonusLogDao.selectList(bonusLogWrapper);
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取用户ID
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
|
||||
// 使用同步块确保同一用户不会被重复处理
|
||||
Object lock = processingUsers.computeIfAbsent(ebUserId, k -> new Object());
|
||||
synchronized (lock) {
|
||||
try {
|
||||
// 再次检查积分记录是否已存在(双重检查)
|
||||
existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已在其他线程处理,跳过: bonusLogId={}", bonusLog.getId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证奖金类型和金额
|
||||
if (!isValidBonusLog(bonusLog)) {
|
||||
log.debug("奖金记录不符合处理条件,跳过: bonusLogId={}, type={}, amount={}",
|
||||
bonusLog.getId(), bonusLog.getType(), bonusLog.getMoney());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值
|
||||
BigDecimal integralValue = calculateIntegralValue(bonusLog.getMoney());
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0或负数,跳过: bonusLogId={}, integralValue={}",
|
||||
bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用CAS方式更新积分,避免锁竞争
|
||||
Boolean updateResult = updateIntegralWithRetry(user.getUid(), integralValue, "add", 3);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败(重试后): userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 插入积分记录
|
||||
UserIntegralRecord integralRecord = createUserIntegralRecord(user.getUid(), bonusLog, integralValue);
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusLog.getMoney(), integralValue);
|
||||
|
||||
} finally {
|
||||
// 清理锁对象
|
||||
processingUsers.remove(ebUserId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
log.error("处理奖金记录失败: bonusLogId={}, error={}", bonusLog.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", bonusLogList.size());
|
||||
result.put("successCount", successCount);
|
||||
result.put("skipCount", skipCount);
|
||||
result.put("failCount", failCount);
|
||||
|
||||
log.info("同步个人奖金变动到用户积分完成(并发安全版): 总数={}, 成功={}, 跳过={}, 失败={}",
|
||||
bonusLogList.size(), successCount, skipCount, failCount);
|
||||
|
||||
result.put("message", "同步完成");
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步个人奖金变动到用户积分异常", e);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "同步失败: " + e.getMessage());
|
||||
result.put("error", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证奖金记录是否符合处理条件
|
||||
*/
|
||||
private boolean isValidBonusLog(WaSelfbonusLog bonusLog) {
|
||||
// 只处理收入类型(type=1)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证奖金金额有效
|
||||
if (bonusLog.getMoney() == null || bonusLog.getMoney().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算积分值
|
||||
*/
|
||||
private BigDecimal calculateIntegralValue(BigDecimal bonusAmount) {
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
return integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建积分记录对象
|
||||
*/
|
||||
private UserIntegralRecord createUserIntegralRecord(Integer userId, WaSelfbonusLog bonusLog, BigDecimal integralValue) {
|
||||
User user = userDao.selectById(userId); // 重新查询用户获取最新积分
|
||||
Integer newIntegral = user != null && user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(userId);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral); // 实际上应该是更新后的积分,这里可能需要调整
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusLog.getMoney(), integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的积分更新
|
||||
*/
|
||||
private Boolean updateIntegralWithRetry(Integer uid, BigDecimal integral, String type, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
// 直接更新积分,不再依赖乐观锁
|
||||
Boolean result = userService.operationIntegral(uid, integral, BigDecimal.ZERO, type);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
return waSelfbonusSyncService.syncSelfbonusToIntegral();
|
||||
}
|
||||
}
|
||||
@@ -392,6 +392,27 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
|
||||
return update(lambdaUpdateWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户积分
|
||||
*
|
||||
* 使用数据库原子加减,避免根据调用方传入的旧 User 对象计算后覆盖最新积分。
|
||||
*
|
||||
* @param user 用户
|
||||
* @param integral 积分
|
||||
* @param type 增加add、扣减sub
|
||||
* @return Boolean
|
||||
*/
|
||||
@Override
|
||||
public Boolean updateIntegral(User user, Integer integral, String type) {
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
throw new CrmebException("用户不存在");
|
||||
}
|
||||
if (ObjectUtil.isNull(integral) || integral <= 0) {
|
||||
throw new CrmebException("积分必须大于0");
|
||||
}
|
||||
return operationIntegral(user.getUid(), BigDecimal.valueOf(integral), user.getIntegral(), type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员分组
|
||||
*
|
||||
|
||||
@@ -11,8 +11,8 @@ import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -44,12 +44,14 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分");
|
||||
|
||||
@@ -66,101 +68,16 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过(通过 waSelfbonusLogid 字段查询积分记录)
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
// 已处理过,跳过
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
ProcessStatus processStatus = transactionTemplate.execute(status -> processBonusLogAtomically(bonusLog));
|
||||
if (processStatus == ProcessStatus.SKIP) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据 wa_users 的 user_id 查找对应的 eb_user 表的 uid
|
||||
// 注意:wa_users.id 对应 eb_user.uid(在同步时已建立关联)
|
||||
Integer ebUserId = bonusLog.getUserId(); // wa_users.id 就是 eb_user.uid
|
||||
|
||||
// 查询 eb_user 表中的用户
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
if (processStatus == ProcessStatus.SUCCESS) {
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值:个人奖金变更金额的50%(只处理收入类型的奖金变动)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
// 只处理收入类型(type=1),支出类型不处理
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 奖金金额(收入为正数)
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
|
||||
BigDecimal integralValue = integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新用户积分 - 不再需要当前积分值作为乐观锁条件
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.valueOf(0), // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败: userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新查询用户获取最新积分
|
||||
user = userDao.selectById(user.getUid());
|
||||
Integer newIntegral = user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
// 新增积分记录
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(user.getUid());
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusAmount, integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
failCount++;
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
@@ -190,45 +107,102 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的用户积分更新
|
||||
* 原子处理单条奖金日志:
|
||||
* 1) 先插入积分流水(受唯一索引保护)
|
||||
* 2) 插入成功后再更新用户总积分
|
||||
* 3) 回填本条流水的 balance
|
||||
*/
|
||||
private Boolean updateUserIntegralWithRetry(Integer uid, BigDecimal integralValue, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
Boolean result = userService.operationIntegral(
|
||||
uid,
|
||||
integralValue,
|
||||
BigDecimal.ZERO, // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
private ProcessStatus processBonusLogAtomically(WaSelfbonusLog bonusLog) {
|
||||
if (isAlreadyProcessed(bonusLog.getId())) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal integralValue = bonusAmount.multiply(new BigDecimal("0.5")).setScale(3, RoundingMode.DOWN);
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
UserIntegralRecord integralRecord = buildIntegralRecord(user.getUid(), bonusLog, bonusAmount, integralValue);
|
||||
try {
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
throw new IllegalStateException("插入积分记录失败");
|
||||
}
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
// 数据库唯一索引兜底,保证多入口并发只处理一次
|
||||
log.info("奖金记录并发重复处理,已跳过: bonusLogId={}, userId={}", bonusLog.getId(), user.getUid());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.ZERO,
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
throw new IllegalStateException(String.format("更新用户积分失败: userId=%s, integralValue=%s", user.getUid(), integralValue));
|
||||
}
|
||||
|
||||
User latestUser = userDao.selectById(user.getUid());
|
||||
Integer latestIntegral = latestUser != null && latestUser.getIntegral() != null ? latestUser.getIntegral().intValue() : 0;
|
||||
integralRecord.setBalance(latestIntegral);
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
userIntegralRecordDao.updateById(integralRecord);
|
||||
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
return ProcessStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private boolean isAlreadyProcessed(Integer waSelfbonusLogId) {
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, waSelfbonusLogId);
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
return existCount != null && existCount > 0;
|
||||
}
|
||||
|
||||
private UserIntegralRecord buildIntegralRecord(Integer uid, WaSelfbonusLog bonusLog, BigDecimal bonusAmount, BigDecimal integralValue) {
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(uid);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId()));
|
||||
integralRecord.setLinkType("selfbonus");
|
||||
integralRecord.setType(1);
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(0);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%.3f",
|
||||
bonusAmount, integralValue));
|
||||
integralRecord.setStatus(3);
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId());
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
private enum ProcessStatus {
|
||||
SUCCESS,
|
||||
SKIP
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/sql/add_unique_index_uk_integral_selfbonus_log.sql
Normal file
23
backend/sql/add_unique_index_uk_integral_selfbonus_log.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Add strong idempotency guard for selfbonus -> integral conversion.
|
||||
-- This index guarantees one wa_selfbonus_log can map to at most one integral record.
|
||||
-- Prerequisite: clear duplicate wa_selfbonus_logid rows first.
|
||||
|
||||
-- Pre-checks
|
||||
SELECT wa_selfbonus_logid, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY wa_selfbonus_logid
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 20;
|
||||
|
||||
SELECT COUNT(*) AS zero_cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid = 0;
|
||||
|
||||
-- Apply unique index
|
||||
ALTER TABLE eb_user_integral_record
|
||||
ADD UNIQUE KEY uk_integral_selfbonus_log (wa_selfbonus_logid);
|
||||
|
||||
-- Verify index exists
|
||||
SHOW INDEX FROM eb_user_integral_record
|
||||
WHERE Key_name = 'uk_integral_selfbonus_log';
|
||||
130
backend/sql/fix_duplicate_selfbonus_integral_records.sql
Normal file
130
backend/sql/fix_duplicate_selfbonus_integral_records.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- Purpose:
|
||||
-- 1) Find duplicate selfbonus integral rows generated from same wa_selfbonus_log
|
||||
-- 2) Backup affected data
|
||||
-- 3) Keep min(id), delete duplicate rows
|
||||
-- 4) Resync eb_user.integral from remaining ledger rows
|
||||
-- 5) Rebuild integer balance snapshot for affected users
|
||||
--
|
||||
-- Notes:
|
||||
-- - Designed for MySQL 5.7
|
||||
-- - Run during low traffic window
|
||||
-- - Review backup table names before execution
|
||||
|
||||
-- 0) Preview duplicate groups
|
||||
SELECT uid,
|
||||
wa_selfbonus_logid,
|
||||
link_id,
|
||||
COUNT(*) AS cnt,
|
||||
SUM(integral) AS total_integral,
|
||||
MIN(id) AS keep_id,
|
||||
GROUP_CONCAT(id ORDER BY id) AS record_ids
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 1) Backup duplicate rows and affected users
|
||||
DROP TABLE IF EXISTS backup_euir_selfbonus_dups_20260511_0959;
|
||||
CREATE TABLE backup_euir_selfbonus_dups_20260511_0959 AS
|
||||
SELECT e.*
|
||||
FROM eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id;
|
||||
|
||||
DROP TABLE IF EXISTS backup_eb_user_integral_before_fix_20260511_0959;
|
||||
CREATE TABLE backup_eb_user_integral_before_fix_20260511_0959 AS
|
||||
SELECT u.*
|
||||
FROM eb_user u
|
||||
WHERE u.uid IN (
|
||||
SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959
|
||||
);
|
||||
|
||||
-- 2) Deduplicate + resync in one transaction
|
||||
START TRANSACTION;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_dup_groups;
|
||||
CREATE TEMPORARY TABLE tmp_dup_groups AS
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_affected_uids;
|
||||
CREATE TEMPORARY TABLE tmp_affected_uids AS
|
||||
SELECT DISTINCT uid FROM tmp_dup_groups;
|
||||
|
||||
DELETE e
|
||||
FROM eb_user_integral_record e
|
||||
JOIN tmp_dup_groups d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id
|
||||
WHERE e.id <> d.keep_id;
|
||||
|
||||
UPDATE eb_user u
|
||||
JOIN (
|
||||
SELECT r.uid, COALESCE(SUM(r.integral), 0) AS sum_integral
|
||||
FROM eb_user_integral_record r
|
||||
JOIN tmp_affected_uids t ON t.uid = r.uid
|
||||
GROUP BY r.uid
|
||||
) s ON s.uid = u.uid
|
||||
SET u.integral = s.sum_integral;
|
||||
|
||||
SET @run_uid := 0;
|
||||
SET @run_bal := 0;
|
||||
UPDATE eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT t.id, FLOOR(t.running) AS new_balance
|
||||
FROM (
|
||||
SELECT s.id,
|
||||
s.uid,
|
||||
(@run_bal := IF(@run_uid = s.uid, @run_bal + s.integral, s.integral)) AS running,
|
||||
(@run_uid := s.uid) AS uid_guard
|
||||
FROM (
|
||||
SELECT id, uid, integral
|
||||
FROM eb_user_integral_record
|
||||
WHERE uid IN (SELECT uid FROM tmp_affected_uids)
|
||||
ORDER BY uid, id
|
||||
) s
|
||||
) t
|
||||
) x ON x.id = e.id
|
||||
SET e.balance = x.new_balance;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 3) Post-checks
|
||||
SELECT COUNT(*) AS remaining_dup_groups
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) a;
|
||||
|
||||
SELECT u.uid, u.integral, s.sum_integral
|
||||
FROM eb_user u
|
||||
JOIN (
|
||||
SELECT uid, SUM(integral) AS sum_integral
|
||||
FROM eb_user_integral_record
|
||||
GROUP BY uid
|
||||
) s ON s.uid = u.uid
|
||||
WHERE u.uid IN (SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959)
|
||||
ORDER BY u.uid;
|
||||
5
dashboard-frontend/.env.development
Normal file
5
dashboard-frontend/.env.development
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_APP_ENV=development
|
||||
VITE_API_BASE_URL=/api/admin
|
||||
VITE_MOCK_ENABLED=false
|
||||
VITE_APP_TITLE=经营驾驶舱
|
||||
VITE_BUILD_VERSION=local
|
||||
24
dashboard-frontend/.gitignore
vendored
Normal file
24
dashboard-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
122
dashboard-frontend/README.md
Normal file
122
dashboard-frontend/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Dashboard Frontend
|
||||
|
||||
独立 H5 经营驾驶舱前端项目。第一阶段只使用本地 Mock 数据,不对接后端 API。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Vite
|
||||
- antd-mobile
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
- Axios
|
||||
- ECharts
|
||||
- MSW
|
||||
- Vitest
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
nvm use --delete-prefix v24.14.1
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:5174/h5/dashboard/boss
|
||||
```
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
- H5 移动端老板驾驶舱首页
|
||||
- 昨日经营核心 KPI
|
||||
- 今日 10:15 / 14:55 节点快报 Mock
|
||||
- 近 7 天交易趋势
|
||||
- 用户、团队、商品排行
|
||||
- 风险预警摘要
|
||||
- 底部 Tab 导航
|
||||
- MSW Mock 数据
|
||||
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test -- --run
|
||||
pnpm build
|
||||
```
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
61
dashboard-frontend/dist/assets/browser-EsQXAio1.js
vendored
Normal file
61
dashboard-frontend/dist/assets/browser-EsQXAio1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
dashboard-frontend/dist/assets/index-CApl8iiU.js
vendored
Normal file
34
dashboard-frontend/dist/assets/index-CApl8iiU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dashboard-frontend/dist/assets/index-CCwqfhCF.css
vendored
Normal file
1
dashboard-frontend/dist/assets/index-CCwqfhCF.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dashboard-frontend/dist/favicon.svg
vendored
Normal file
1
dashboard-frontend/dist/favicon.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
dashboard-frontend/dist/icons.svg
vendored
Normal file
24
dashboard-frontend/dist/icons.svg
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
14
dashboard-frontend/dist/index.html
vendored
Normal file
14
dashboard-frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard-frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-CApl8iiU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CCwqfhCF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
349
dashboard-frontend/dist/mockServiceWorker.js
vendored
Normal file
349
dashboard-frontend/dist/mockServiceWorker.js
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.5'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
22
dashboard-frontend/eslint.config.js
Normal file
22
dashboard-frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'public/mockServiceWorker.js']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
dashboard-frontend/index.html
Normal file
13
dashboard-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
dashboard-frontend/package.json
Normal file
50
dashboard-frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "dashboard-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"antd-mobile": "^5.42.3",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"msw": "^2.14.5",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
3461
dashboard-frontend/pnpm-lock.yaml
generated
Normal file
3461
dashboard-frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
dashboard-frontend/public/favicon.svg
Normal file
1
dashboard-frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
dashboard-frontend/public/icons.svg
Normal file
24
dashboard-frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
349
dashboard-frontend/public/mockServiceWorker.js
Normal file
349
dashboard-frontend/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.5'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
27
dashboard-frontend/src/App.tsx
Normal file
27
dashboard-frontend/src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppProviders } from './app/providers/AppProviders'
|
||||
import { MobileLayout } from './app/layouts/MobileLayout'
|
||||
import { BossDashboardPage } from './features/boss-dashboard/pages/BossDashboardPage'
|
||||
import { DailyReportPage, ProfilePage, RiskCenterPage, TodaySnapshotPage } from './features/boss-dashboard/pages/OperationsPages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
<Route element={<MobileLayout />}>
|
||||
<Route path="/h5/dashboard/boss" element={<BossDashboardPage />} />
|
||||
<Route path="/h5/dashboard/daily-report" element={<DailyReportPage />} />
|
||||
<Route path="/h5/dashboard/today-snapshot" element={<TodaySnapshotPage />} />
|
||||
<Route path="/h5/dashboard/risk-center" element={<RiskCenterPage />} />
|
||||
<Route path="/h5/dashboard/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
34
dashboard-frontend/src/app/layouts/MobileLayout.tsx
Normal file
34
dashboard-frontend/src/app/layouts/MobileLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AppOutline, BellOutline, FileOutline, HistogramOutline, UserOutline } from 'antd-mobile-icons'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { SafeArea, TabBar } from 'antd-mobile'
|
||||
|
||||
const tabs = [
|
||||
{ key: '/h5/dashboard/boss', title: '首页', icon: <AppOutline /> },
|
||||
{ key: '/h5/dashboard/daily-report', title: '日报', icon: <FileOutline /> },
|
||||
{ key: '/h5/dashboard/today-snapshot', title: '快报', icon: <HistogramOutline /> },
|
||||
{ key: '/h5/dashboard/risk-center', title: '风险', icon: <BellOutline /> },
|
||||
{ key: '/h5/dashboard/profile', title: '我的', icon: <UserOutline /> },
|
||||
]
|
||||
|
||||
export function MobileLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeKey = tabs.find((tab) => location.pathname.startsWith(tab.key))?.key ?? tabs[0].key
|
||||
|
||||
return (
|
||||
<div className="mobile-shell">
|
||||
<main className="mobile-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="bottom-nav" aria-label="Dashboard mobile navigation">
|
||||
<TabBar activeKey={activeKey} onChange={(key) => navigate(key)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabBar.Item key={tab.key} icon={tab.icon} title={tab.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
<SafeArea position="bottom" />
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
dashboard-frontend/src/app/providers/AppProviders.tsx
Normal file
26
dashboard-frontend/src/app/providers/AppProviders.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ConfigProvider } from 'antd-mobile'
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type AppProvidersProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
76
dashboard-frontend/src/components/charts/MiniTrendChart.tsx
Normal file
76
dashboard-frontend/src/components/charts/MiniTrendChart.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as echarts from 'echarts/core'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { TrendPoint } from '../../features/boss-dashboard/types'
|
||||
|
||||
echarts.use([GridComponent, TooltipComponent, LineChart, BarChart, CanvasRenderer])
|
||||
|
||||
type MiniTrendChartProps = {
|
||||
data: TrendPoint[]
|
||||
}
|
||||
|
||||
export function MiniTrendChart({ data }: MiniTrendChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: ['#ff5b36', '#ffb000'],
|
||||
grid: { left: 8, right: 8, top: 24, bottom: 18, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
valueFormatter: (value: number | string) => Number(value).toLocaleString('zh-CN'),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((item) => item.date),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', show: false },
|
||||
{ type: 'value', show: false },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '成交额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 0,
|
||||
data: data.map((item) => item.amount),
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 3 },
|
||||
areaStyle: { opacity: 0.08 },
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: data.map((item) => item.orders),
|
||||
barWidth: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return undefined
|
||||
const chart = echarts.init(chartRef.current)
|
||||
chart.setOption(option)
|
||||
|
||||
const resize = () => chart.resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart.dispose()
|
||||
}
|
||||
}, [option])
|
||||
|
||||
return <div className="mini-trend-chart" ref={chartRef} aria-label="近 7 天交易趋势图" />
|
||||
}
|
||||
32
dashboard-frontend/src/components/kpi/KpiCard.tsx
Normal file
32
dashboard-frontend/src/components/kpi/KpiCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Skeleton } from 'antd-mobile'
|
||||
import { formatMetricValue, formatTrend } from '../../utils/format'
|
||||
import type { KpiMetric } from '../../features/boss-dashboard/types'
|
||||
|
||||
type KpiCardProps = {
|
||||
metric: KpiMetric
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function KpiCard({ metric, loading }: KpiCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="kpi-card">
|
||||
<Skeleton.Title animated />
|
||||
<Skeleton.Paragraph lineCount={1} animated />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`kpi-card kpi-card--${metric.status} ${metric.featured ? 'kpi-card--featured' : ''}`}>
|
||||
<p className="kpi-title">{metric.title}</p>
|
||||
<strong className="kpi-value">{formatMetricValue(metric.value, metric.unit)}</strong>
|
||||
{(metric.trendLabel || metric.trendValue !== undefined) && (
|
||||
<p className="kpi-trend">
|
||||
{metric.trendLabel}
|
||||
{metric.trendValue !== undefined && <span>{formatTrend(metric.trendValue)}</span>}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
18
dashboard-frontend/src/features/boss-dashboard/api.ts
Normal file
18
dashboard-frontend/src/features/boss-dashboard/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getApiData, getBlob } from '../../services/http/client'
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardQueryKeys = {
|
||||
overview: (date?: string) => ['dashboard', 'overview', date ?? 'default'] as const,
|
||||
}
|
||||
|
||||
export function useDashboardOverview(date?: string) {
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.overview(date),
|
||||
queryFn: () => getApiData<DashboardOverview>(date ? `/dashboard/overview?date=${date}` : '/dashboard/overview'),
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadDailyReportArchive(date?: string) {
|
||||
return getBlob(date ? `/dashboard/daily-report/archive?date=${date}` : '/dashboard/daily-report/archive')
|
||||
}
|
||||
215
dashboard-frontend/src/features/boss-dashboard/archive.ts
Normal file
215
dashboard-frontend/src/features/boss-dashboard/archive.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { formatMetricValue, formatMoney, formatNumber } from '../../utils/format'
|
||||
import type { DashboardOverview, MetricStatus, RankItem, RiskAlert, RiskLevel, SnapshotSlot, TodaySnapshot } from './types'
|
||||
|
||||
const snapshotTitle: Record<SnapshotSlot, string> = {
|
||||
'1015': '上午抢购快报',
|
||||
'1455': '下午寄卖/转卖快报',
|
||||
}
|
||||
|
||||
const snapshotDescription: Record<SnapshotSlot, string> = {
|
||||
'1015': '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
'1455': '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
}
|
||||
|
||||
const metricStatusText: Record<MetricStatus, string> = {
|
||||
normal: '正常',
|
||||
success: '达标',
|
||||
warning: '关注',
|
||||
danger: '异常',
|
||||
}
|
||||
|
||||
const riskLevelText: Record<RiskLevel, string> = {
|
||||
red: '红色',
|
||||
yellow: '黄色',
|
||||
gray: '灰色',
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function serializeStaticData(data: DashboardOverview): string {
|
||||
return JSON.stringify(data, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
|
||||
}
|
||||
|
||||
function renderMetricGrid(metrics: DashboardOverview['kpis']): string {
|
||||
return metrics
|
||||
.map(
|
||||
(metric) => `
|
||||
<article class="card metric-card">
|
||||
<span>${escapeHtml(metricStatusText[metric.status])}</span>
|
||||
<h3>${escapeHtml(metric.title)}</h3>
|
||||
<strong>${escapeHtml(formatMetricValue(metric.value, metric.unit))}</strong>
|
||||
${metric.trendLabel ? `<p>${escapeHtml(metric.trendLabel)} ${escapeHtml(metric.trendValue ?? '')}%</p>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderSnapshots(snapshots: TodaySnapshot[]): string {
|
||||
return snapshots
|
||||
.map((snapshot) => {
|
||||
const bonusChange = Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange)
|
||||
return `
|
||||
<article class="card snapshot-card">
|
||||
<div class="card-title-row">
|
||||
<div>
|
||||
<span>${escapeHtml(snapshot.slot)}</span>
|
||||
<h3>${escapeHtml(snapshotTitle[snapshot.slot])}</h3>
|
||||
</div>
|
||||
<mark>${escapeHtml(snapshot.status)}</mark>
|
||||
</div>
|
||||
<p>${escapeHtml(snapshotDescription[snapshot.slot])}</p>
|
||||
<p class="message">${escapeHtml(snapshot.message)}</p>
|
||||
${snapshot.generatedAt ? `<small>生成时间:${escapeHtml(snapshot.generatedAt)}</small>` : ''}
|
||||
<div class="snapshot-grid">
|
||||
<span>用户<strong>${escapeHtml(formatNumber(snapshot.purchaseUsers))}人</strong></span>
|
||||
<span>订单<strong>${escapeHtml(formatNumber(snapshot.orderCount))}单</strong></span>
|
||||
<span>成交额<strong>${escapeHtml(formatMoney(snapshot.dealAmount))}</strong></span>
|
||||
<span>已支付<strong>${escapeHtml(formatMoney(snapshot.paidAmount))}</strong></span>
|
||||
<span>商品<strong>${escapeHtml(formatNumber(snapshot.newMerchandiseCount))}件</strong></span>
|
||||
<span>奖金<strong>${escapeHtml(formatMoney(bonusChange))}</strong></span>
|
||||
</div>
|
||||
</article>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderRanks(title: string, ranks: RankItem[]): string {
|
||||
return `
|
||||
<section class="section">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<div class="rank-list">
|
||||
${ranks
|
||||
.map(
|
||||
(rank, index) => `
|
||||
<div class="rank-item">
|
||||
<b>${index + 1}</b>
|
||||
<span>
|
||||
<strong>${escapeHtml(rank.name)}</strong>
|
||||
<small>${escapeHtml(rank.description)}</small>
|
||||
</span>
|
||||
<em>${escapeHtml(formatMoney(rank.value))}</em>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderRisks(risks: RiskAlert[]): string {
|
||||
return risks
|
||||
.map(
|
||||
(risk) => `
|
||||
<article class="risk risk--${risk.level}">
|
||||
<div>
|
||||
<mark>${escapeHtml(riskLevelText[risk.level])}</mark>
|
||||
<span>${escapeHtml(risk.type)}</span>
|
||||
<time>${escapeHtml(risk.discoveredAt)}</time>
|
||||
</div>
|
||||
<strong>${escapeHtml(risk.title)}</strong>
|
||||
<p>${escapeHtml(risk.description)}</p>
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function buildDailyReportArchiveHtml(data: DashboardOverview): string {
|
||||
const generatedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>经营日报归档 - ${escapeHtml(data.businessDate)}</title>
|
||||
<style>
|
||||
:root { --bg: #fff6f1; --surface: #fff; --surface-soft: #f6f9fb; --text: #132033; --muted: #6b7a90; --border: rgba(19, 32, 51, .08); --primary: #ff5b36; --warning: #ffb000; --danger: #dc2626; --shadow: 0 16px 40px rgba(255, 91, 54, .14); --radius-xl: 28px; --radius-lg: 20px; --radius-md: 14px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 320px; margin: 0; color: var(--text); background: radial-gradient(circle at top left, rgba(255, 91, 54, .2), transparent 28rem), var(--bg); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
main { width: min(100%, 430px); min-height: 100svh; margin: 0 auto; padding: 14px 14px 24px; background: var(--bg); box-shadow: 0 0 0 1px rgba(19, 32, 51, .04); }
|
||||
.hero { position: relative; overflow: hidden; padding: 20px; color: #fff; background: linear-gradient(145deg, rgba(255, 91, 54, .98), rgba(255, 139, 82, .92)), radial-gradient(circle at 90% 10%, rgba(255, 176, 0, .42), transparent 18rem); border-radius: 0 0 var(--radius-xl) var(--radius-xl); box-shadow: var(--shadow); }
|
||||
.hero p { margin: 0; color: rgba(255, 255, 255, .76); line-height: 1.6; }
|
||||
.eyebrow, .card-title-row span, .metric-card > span { margin: 0; color: rgba(255, 255, 255, .68); font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1, h2, h3 { margin: 0; }
|
||||
h1 { margin: 12px 0 8px; font-size: 28px; line-height: 1.12; }
|
||||
h2 { font-size: 18px; margin-bottom: 12px; }
|
||||
h3 { font-size: 16px; }
|
||||
.meta { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.meta span { display: inline-flex; width: max-content; padding: 7px 12px; color: rgba(255, 255, 255, .86); font-size: 12px; font-weight: 700; background: rgba(255, 255, 255, .14); border: 1px solid rgba(255, 255, 255, .18); border-radius: 999px; }
|
||||
.section { margin-top: 14px; padding: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.card { padding: 14px; background: var(--surface-soft); border: 0; border-radius: var(--radius-lg); }
|
||||
.metric-card { min-height: 112px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.metric-card > span { color: var(--muted); }
|
||||
.metric-card strong { display: block; margin-top: 8px; color: var(--text); font-size: 22px; line-height: 1.08; word-break: break-all; }
|
||||
.metric-card p { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.snapshot-stack, .trend-list, .rank-list, .risk-list { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.card-title-row, .rank-item, .risk div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.card-title-row span { color: var(--muted); }
|
||||
mark { padding: 3px 8px; color: var(--primary); font-size: 12px; font-weight: 700; background: #fff0eb; border: 0; border-radius: 999px; }
|
||||
.snapshot-card p { margin: 12px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.snapshot-card .message { margin: 10px 0 8px; color: var(--text); font-weight: 700; line-height: 1.55; }
|
||||
.snapshot-card small { color: var(--muted); }
|
||||
.snapshot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.snapshot-grid span, .trend-item, .rank-item, .risk { padding: 12px; background: var(--surface-soft); border: 0; border-radius: var(--radius-md); }
|
||||
.snapshot-grid span { color: var(--muted); font-size: 12px; }
|
||||
.snapshot-grid strong { display: block; margin-top: 4px; color: var(--text); font-size: 15px; }
|
||||
.trend-item { display: grid; gap: 4px; }
|
||||
.trend-item span { color: var(--muted); font-size: 13px; }
|
||||
.rank-item b { width: 28px; height: 28px; display: inline-grid; place-items: center; border-radius: 10px; color: #fff; background: var(--primary); }
|
||||
.rank-item span { flex: 1; }
|
||||
.rank-item small { display: block; margin-top: 3px; color: var(--muted); line-height: 1.45; }
|
||||
.rank-item em { color: var(--primary); font-size: 13px; font-style: normal; font-weight: 800; }
|
||||
.risk strong { display: block; margin-top: 10px; }
|
||||
.risk p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
|
||||
.risk--red mark { color: #991b1b; background: #fee2e2; }
|
||||
.risk--yellow mark { color: #92400e; background: #fef3c7; }
|
||||
.risk--gray mark { color: #475569; background: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Daily Report Archive</p>
|
||||
<h1>经营日报归档 ${escapeHtml(data.businessDate)}</h1>
|
||||
<p>${escapeHtml(data.summary)}</p>
|
||||
<div class="meta">
|
||||
<span>业务日期:${escapeHtml(data.businessDate)}</span>
|
||||
<span>数据生成:${escapeHtml(data.generatedAt)}</span>
|
||||
<span>归档生成:${escapeHtml(generatedAt)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section"><h2>核心指标</h2><div class="grid">${renderMetricGrid(data.kpis)}</div></section>
|
||||
<section class="section"><h2>资金池摘要</h2><div class="grid">${renderMetricGrid(data.fundPool)}</div></section>
|
||||
<section class="section"><h2>今日快报</h2><div class="snapshot-stack">${renderSnapshots(data.snapshots)}</div></section>
|
||||
<section class="section">
|
||||
<h2>最近趋势</h2>
|
||||
<div class="trend-list">
|
||||
${data.trends
|
||||
.map(
|
||||
(trend) => `
|
||||
<div class="trend-item">
|
||||
<strong>${escapeHtml(trend.date)}</strong>
|
||||
<span>成交 ${escapeHtml(formatMoney(trend.amount))}</span>
|
||||
<span>订单 ${escapeHtml(formatNumber(trend.orders))} 单</span>
|
||||
<span>奖金 ${escapeHtml(formatMoney(trend.bonus))}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
${renderRanks('高价值用户', data.userRanks)}
|
||||
${renderRanks('团队贡献排行', data.teamRanks)}
|
||||
${renderRanks('高货值未成交商品', data.productRanks)}
|
||||
<section class="section"><h2>风险预警</h2><div class="risk-list">${renderRisks(data.risks)}</div></section>
|
||||
<script id="dashboard-static-data" type="application/json">${serializeStaticData(data)}</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RightOutline } from 'antd-mobile-icons'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { RankItem } from '../types'
|
||||
|
||||
type RankListProps = {
|
||||
title: string
|
||||
items: RankItem[]
|
||||
valueType?: 'money' | 'number'
|
||||
}
|
||||
|
||||
export function RankList({ title, items, valueType = 'money' }: RankListProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Top 3</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button">
|
||||
全部
|
||||
<RightOutline />
|
||||
</button>
|
||||
</div>
|
||||
<div className="rank-list">
|
||||
{items.map((item, index) => (
|
||||
<button className="rank-item" key={item.id} type="button">
|
||||
<span className="rank-index">{index + 1}</span>
|
||||
<span className="rank-content">
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
<span className="rank-value">{valueType === 'money' ? formatMoney(item.value) : formatNumber(item.value)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Tag } from 'antd-mobile'
|
||||
import type { RiskAlert, RiskLevel } from '../types'
|
||||
|
||||
type RiskAlertSectionProps = {
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
|
||||
const levelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
export function RiskAlertSection({ risks }: RiskAlertSectionProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Risk</p>
|
||||
<h2>风险预警</h2>
|
||||
</div>
|
||||
<span className="risk-count">{risks.length} 条</span>
|
||||
</div>
|
||||
<div className="risk-list">
|
||||
{risks.map((risk) => {
|
||||
const meta = levelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CapsuleTabs, Tag } from 'antd-mobile'
|
||||
import { useState } from 'react'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
type TodaySnapshotSectionProps = {
|
||||
snapshots: TodaySnapshot[]
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '生成失败' },
|
||||
temporary: { color: 'warning', label: '临时数据' },
|
||||
} as const
|
||||
|
||||
export function TodaySnapshotSection({ snapshots }: TodaySnapshotSectionProps) {
|
||||
const [activeSlot, setActiveSlot] = useState<SnapshotSlot>('1015')
|
||||
const activeSnapshot = snapshots.find((snapshot) => snapshot.slot === activeSlot) ?? snapshots[0]
|
||||
const status = statusMap[activeSnapshot.status]
|
||||
|
||||
return (
|
||||
<section className="section-block snapshot-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">今日节点</p>
|
||||
<h2>抢购 / 寄卖快报</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
|
||||
<CapsuleTabs activeKey={activeSlot} onChange={(key) => setActiveSlot(key as SnapshotSlot)}>
|
||||
{snapshots.map((snapshot) => (
|
||||
<CapsuleTabs.Tab title={snapshot.title.replace(' ', '')} key={snapshot.slot} />
|
||||
))}
|
||||
</CapsuleTabs>
|
||||
|
||||
<div className="snapshot-card">
|
||||
<p className="snapshot-message">{activeSnapshot.message}</p>
|
||||
{activeSnapshot.generatedAt && <p className="snapshot-time">生成时间:{activeSnapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid">
|
||||
<span>
|
||||
采购用户<strong>{formatNumber(activeSnapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
订单数<strong>{formatNumber(activeSnapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
成交额<strong>{formatMoney(activeSnapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
支付额<strong>{formatMoney(activeSnapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
新增商品<strong>{formatNumber(activeSnapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
奖金变化<strong>{formatMoney(Number(activeSnapshot.selfBonusChange) + Number(activeSnapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
104
dashboard-frontend/src/features/boss-dashboard/mock.ts
Normal file
104
dashboard-frontend/src/features/boss-dashboard/mock.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardMock: DashboardOverview = {
|
||||
businessDate: '2026-05-10',
|
||||
generatedAt: '2026-05-11 00:10:12',
|
||||
summary: '昨日成交保持稳定,采购用户略有增长;资金风险主要集中在大额待提现和积分比例异常。',
|
||||
kpis: [
|
||||
{ key: 'dealAmount', title: '昨日成交额', value: 1289360.42, unit: '元', trendLabel: '较前日', trendValue: 8.6, status: 'success', featured: true },
|
||||
{ key: 'orderCount', title: '昨日订单数', value: 1842, unit: '单', trendLabel: '较前日', trendValue: 4.1, status: 'success' },
|
||||
{ key: 'purchaseUsers', title: '采购用户', value: 936, unit: '人', trendLabel: '较前日', trendValue: 2.7, status: 'success' },
|
||||
{ key: 'newUsers', title: '新增用户', value: 318, unit: '人', trendLabel: '较前日', trendValue: -3.2, status: 'warning' },
|
||||
{ key: 'newMerchandise', title: '新增寄售商品', value: 472, unit: '件', trendLabel: '较前日', trendValue: 12.4, status: 'success' },
|
||||
{ key: 'selfBonus', title: '个人奖金发放', value: 168230.36, unit: '元', trendLabel: '较前日', trendValue: 6.8, status: 'normal' },
|
||||
{ key: 'shareBonus', title: '推广奖金发放', value: 82460.18, unit: '元', trendLabel: '较前日', trendValue: 1.9, status: 'normal' },
|
||||
{ key: 'pendingAmount', title: '待支付/待结算', value: 95620.11, unit: '元', trendLabel: '需关注', status: 'warning' },
|
||||
],
|
||||
fundPool: [
|
||||
{ key: 'balance', title: '余额总额', value: 728903.22, unit: '元', status: 'normal' },
|
||||
{ key: 'coupon', title: '优惠券总额', value: 391082.88, unit: '元', status: 'normal' },
|
||||
{ key: 'selfBonusPool', title: '个人奖金总额', value: 836942.14, unit: '元', status: 'warning' },
|
||||
{ key: 'shareBonusPool', title: '推广奖金总额', value: 295402.77, unit: '元', status: 'normal' },
|
||||
{ key: 'integral', title: '积分总额', value: 418471.07, unit: '分', status: 'normal' },
|
||||
{ key: 'withdrawPending', title: '待审核提现', value: 63200, unit: '元', status: 'danger' },
|
||||
],
|
||||
snapshots: [
|
||||
{
|
||||
slot: '1015',
|
||||
title: '10:15 上午快报',
|
||||
status: 'success',
|
||||
generatedAt: '2026-05-11 10:15:08',
|
||||
message: '上午抢购节点已完成,上一日寄卖商品消化情况良好,采购用户和成交额略高于昨日同节点。',
|
||||
purchaseUsers: 421,
|
||||
orderCount: 756,
|
||||
dealAmount: 526880.2,
|
||||
paidAmount: 498320.5,
|
||||
newMerchandiseCount: 185,
|
||||
selfBonusChange: 64230.3,
|
||||
shareBonusChange: 31820.1,
|
||||
},
|
||||
{
|
||||
slot: '1455',
|
||||
title: '14:55 下午快报',
|
||||
status: 'pending',
|
||||
message: '下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。',
|
||||
purchaseUsers: 0,
|
||||
orderCount: 0,
|
||||
dealAmount: 0,
|
||||
paidAmount: 0,
|
||||
newMerchandiseCount: 0,
|
||||
selfBonusChange: 0,
|
||||
shareBonusChange: 0,
|
||||
},
|
||||
],
|
||||
trends: [
|
||||
{ date: '05-04', amount: 948000, orders: 1390, newUsers: 226, bonus: 186000 },
|
||||
{ date: '05-05', amount: 1024000, orders: 1512, newUsers: 251, bonus: 194000 },
|
||||
{ date: '05-06', amount: 1119000, orders: 1604, newUsers: 287, bonus: 205000 },
|
||||
{ date: '05-07', amount: 1086000, orders: 1542, newUsers: 243, bonus: 198000 },
|
||||
{ date: '05-08', amount: 1198000, orders: 1731, newUsers: 302, bonus: 221000 },
|
||||
{ date: '05-09', amount: 1187200, orders: 1769, newUsers: 329, bonus: 229000 },
|
||||
{ date: '05-10', amount: 1289360, orders: 1842, newUsers: 318, bonus: 250690 },
|
||||
],
|
||||
userRanks: [
|
||||
{ id: 'u1', name: '刘先生', value: 96520, description: '个人奖金 + 推广奖金 + 积分折算', badge: '高价值' },
|
||||
{ id: 'u2', name: '陈女士', value: 81230, description: '昨日采购 12 单', badge: '活跃' },
|
||||
{ id: 'u3', name: '周先生', value: 75880, description: '团队新增 18 人' },
|
||||
],
|
||||
teamRanks: [
|
||||
{ id: 't1', name: '华东一队', value: 386200, description: '成交额第一,团队收益 4.8 万', badge: 'TOP1' },
|
||||
{ id: 't2', name: '苏州团队', value: 318760, description: '采购用户 182 人' },
|
||||
{ id: 't3', name: '扬州团队', value: 287500, description: '新增成员 36 人' },
|
||||
],
|
||||
productRanks: [
|
||||
{ id: 'p1', name: '高端礼盒 A 款', value: 128800, description: '上架 7 天未成交', badge: '滞销' },
|
||||
{ id: 'p2', name: '精选组合 B 款', value: 98600, description: '高货值待成交' },
|
||||
{ id: 'p3', name: '会员专享 C 款', value: 83500, description: '浏览高,成交低' },
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
id: 'r1',
|
||||
level: 'red',
|
||||
type: '资金',
|
||||
title: '大额待审核提现',
|
||||
description: '当前待审核提现 6.32 万,建议今日处理。',
|
||||
discoveredAt: '11:00',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
level: 'yellow',
|
||||
type: '积分',
|
||||
title: '积分与个人奖金比例异常',
|
||||
description: '发现 3 名用户积分未接近个人奖金的 1/2。',
|
||||
discoveredAt: '10:40',
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
level: 'gray',
|
||||
type: '数据',
|
||||
title: '用户资料不一致',
|
||||
description: 'wa_users 与 eb_user 有 5 条手机号不一致。',
|
||||
discoveredAt: '09:55',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button, DotLoading, ErrorBlock } from 'antd-mobile'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { formatMoney } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { RankList } from '../components/RankList'
|
||||
import { RiskAlertSection } from '../components/RiskAlertSection'
|
||||
import { TodaySnapshotSection } from '../components/TodaySnapshotSection'
|
||||
|
||||
export function BossDashboardPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在生成经营简报...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title="驾驶舱加载失败" description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={() => void refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const coreKpis = data.kpis.slice(0, 4)
|
||||
const moreKpis = data.kpis.slice(4)
|
||||
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<header className="dashboard-hero">
|
||||
<div className="hero-topline">
|
||||
<span>经营驾驶舱</span>
|
||||
<button type="button">上个工作日</button>
|
||||
</div>
|
||||
<p className="eyebrow">数据日期 {data.businessDate}</p>
|
||||
<h1>上个工作日经营简报</h1>
|
||||
<p className="hero-summary">{data.summary}</p>
|
||||
<div className="hero-metric">
|
||||
<span>上个工作日成交额</span>
|
||||
<strong>{formatMoney(data.kpis[0]?.value)}</strong>
|
||||
<small>生成时间:{data.generatedAt}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="kpi-grid" aria-label="核心经营指标">
|
||||
{coreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">More</p>
|
||||
<h2>更多经营指标</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{moreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TodaySnapshotSection snapshots={data.snapshots} />
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>近 7 天交易趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund</p>
|
||||
<h2>资金池摘要</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.fundPool.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RankList title="高价值用户" items={data.userRanks} />
|
||||
<RankList title="团队贡献排行" items={data.teamRanks} />
|
||||
<RankList title="高货值未成交商品" items={data.productRanks} />
|
||||
<RiskAlertSection risks={data.risks} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { Button, CapsuleTabs, DotLoading, ErrorBlock, Tag, Toast } from 'antd-mobile'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { buildDailyReportArchiveHtml } from '../archive'
|
||||
import type { DashboardOverview, RiskLevel, SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
const snapshotStatusMeta = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '失败' },
|
||||
temporary: { color: 'warning', label: '临时' },
|
||||
} as const
|
||||
|
||||
const riskLevelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
const snapshotSlotMeta: Record<
|
||||
SnapshotSlot,
|
||||
{
|
||||
title: string
|
||||
subtitle: string
|
||||
metricLabels: {
|
||||
primaryUsers: string
|
||||
primaryOrders: string
|
||||
amount: string
|
||||
paidAmount: string
|
||||
merchandise: string
|
||||
bonus: string
|
||||
}
|
||||
checklist: string[]
|
||||
}
|
||||
> = {
|
||||
'1015': {
|
||||
title: '上午抢购快报',
|
||||
subtitle: '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
metricLabels: {
|
||||
primaryUsers: '抢购用户',
|
||||
primaryOrders: '抢购订单',
|
||||
amount: '抢购成交额',
|
||||
paidAmount: '已支付金额',
|
||||
merchandise: '成交商品',
|
||||
bonus: '相关奖金',
|
||||
},
|
||||
checklist: ['抢购成交额是否低于昨日同节点', '采购用户是否异常回落', '付款金额与成交额是否明显偏离', '高货值寄卖商品是否完成消化'],
|
||||
},
|
||||
'1455': {
|
||||
title: '下午寄卖/转卖快报',
|
||||
subtitle: '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
metricLabels: {
|
||||
primaryUsers: '寄卖用户',
|
||||
primaryOrders: '转卖订单',
|
||||
amount: '转卖成交额',
|
||||
paidAmount: '回款金额',
|
||||
merchandise: '新增寄售',
|
||||
bonus: '奖金变化',
|
||||
},
|
||||
checklist: ['抢购商品是否按预期转入寄卖', '新增寄售商品是否满足下午供给', '个人奖金与推广奖金是否同步变化', '转卖回款是否出现异常延迟'],
|
||||
},
|
||||
}
|
||||
|
||||
function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
title,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
refetch: () => void
|
||||
title: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在加载{title}...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title={`${title}加载失败`} description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={refetch}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function OperationsHeader({
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
extra,
|
||||
}: {
|
||||
kicker: string
|
||||
title: string
|
||||
description: string
|
||||
extra?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="operations-header">
|
||||
<p className="eyebrow">{kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
{extra && <span>{extra}</span>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDetailCard({ snapshot }: { snapshot: TodaySnapshot }) {
|
||||
const status = snapshotStatusMeta[snapshot.status]
|
||||
const slotMeta = snapshotSlotMeta[snapshot.slot]
|
||||
|
||||
return (
|
||||
<article className="snapshot-detail-card">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">{snapshot.slot}</p>
|
||||
<h2>{slotMeta.title}</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
<p className="snapshot-detail-subtitle">{slotMeta.subtitle}</p>
|
||||
<p className="snapshot-detail-message">{snapshot.message}</p>
|
||||
{snapshot.generatedAt && <p className="snapshot-time">生成时间:{snapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid snapshot-grid--wide">
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryUsers}
|
||||
<strong>{formatNumber(snapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryOrders}
|
||||
<strong>{formatNumber(snapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.amount}
|
||||
<strong>{formatMoney(snapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.paidAmount}
|
||||
<strong>{formatMoney(snapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.merchandise}
|
||||
<strong>{formatNumber(snapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.bonus}
|
||||
<strong>{formatMoney(Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDailyReports(data: DashboardOverview) {
|
||||
return data.trends
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((trend, index) => ({
|
||||
...trend,
|
||||
status: index === 0 ? '已生成' : '历史快照',
|
||||
bonusRate: Number(trend.amount) > 0 ? (Number(trend.bonus) / Number(trend.amount)) * 100 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DailyReportPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="经营日报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const reports = buildDailyReports(data)
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
setIsArchiving(true)
|
||||
const html = buildDailyReportArchiveHtml(data)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `dashboard-daily-report-${data.businessDate}.html`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
Toast.show({ icon: 'success', content: '归档 HTML 已生成' })
|
||||
} catch {
|
||||
Toast.show({ icon: 'fail', content: '归档生成失败,请稍后重试' })
|
||||
} finally {
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Daily Report"
|
||||
title="经营日报"
|
||||
description="按日沉淀成交、订单、用户与奖金变化,方便老板回看最近经营节奏。"
|
||||
extra={`最新数据:${data.businessDate}`}
|
||||
/>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Workday</p>
|
||||
<h2>上个工作日重点</h2>
|
||||
</div>
|
||||
<Tag color="success">已归档</Tag>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.kpis.slice(0, 4).map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>最近 7 天趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Archive</p>
|
||||
<h2>日报归档</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button" disabled={isArchiving} onClick={() => void handleArchive()}>
|
||||
{isArchiving ? '生成中...' : '生成归档'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="report-list">
|
||||
{reports.map((report) => (
|
||||
<button className="report-item" key={report.date} type="button">
|
||||
<span>
|
||||
<strong>{report.date}</strong>
|
||||
<small>{report.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatMoney(report.amount)}</strong>
|
||||
<small>
|
||||
{formatNumber(report.orders)} 单 / 奖金占比 {formatNumber(report.bonusRate, 1)}%
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TodaySnapshotPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="今日快报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Today Snapshot"
|
||||
title="今日快报"
|
||||
description="10:15 看上一日寄卖商品的抢购结果,14:55 看抢到商品的寄卖/转卖承接情况。"
|
||||
extra="节点状态随 Mock 场景切换"
|
||||
/>
|
||||
|
||||
<section className="section-block snapshot-page-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Timeline</p>
|
||||
<h2>节点快报</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-stack">
|
||||
{data.snapshots.map((snapshot) => (
|
||||
<SnapshotDetailCard key={snapshot.slot} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Checklist</p>
|
||||
<h2>节点检查项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
{data.snapshots.flatMap((snapshot) =>
|
||||
snapshotSlotMeta[snapshot.slot].checklist.map((item) => (
|
||||
<span key={`${snapshot.slot}-${item}`}>
|
||||
<strong>{snapshot.slot === '1015' ? '上午' : '下午'}</strong>
|
||||
{item}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RiskCenterPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [activeLevel, setActiveLevel] = useState<RiskLevel | 'all'>('all')
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="风险中心" />
|
||||
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (activeLevel === 'all') return data.risks
|
||||
return data.risks.filter((risk) => risk.level === activeLevel)
|
||||
}, [activeLevel, data])
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const dangerousFunds = data.fundPool.filter((metric) => metric.status === 'warning' || metric.status === 'danger')
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Risk Center"
|
||||
title="风险中心"
|
||||
description="把资金、积分与数据一致性风险集中处理,优先看红色和黄色事项。"
|
||||
extra={`${data.risks.length} 条待关注`}
|
||||
/>
|
||||
|
||||
<section className="risk-summary-grid" aria-label="风险概览">
|
||||
{(['red', 'yellow', 'gray'] as const).map((level) => {
|
||||
const meta = riskLevelMeta[level]
|
||||
const count = data.risks.filter((risk) => risk.level === level).length
|
||||
return (
|
||||
<button className={`risk-summary-card risk-summary-card--${level}`} key={level} type="button" onClick={() => setActiveLevel(level)}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<strong>{count}</strong>
|
||||
<span>条风险</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<CapsuleTabs activeKey={activeLevel} onChange={(key) => setActiveLevel(key as RiskLevel | 'all')}>
|
||||
<CapsuleTabs.Tab title="全部" key="all" />
|
||||
<CapsuleTabs.Tab title="红色" key="red" />
|
||||
<CapsuleTabs.Tab title="黄色" key="yellow" />
|
||||
<CapsuleTabs.Tab title="灰色" key="gray" />
|
||||
</CapsuleTabs>
|
||||
<div className="risk-list">
|
||||
{filteredRisks.map((risk) => {
|
||||
const meta = riskLevelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund Watch</p>
|
||||
<h2>资金池关注项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{dangerousFunds.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="我的" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Profile"
|
||||
title="我的"
|
||||
description="展示当前驾驶舱权限、数据环境与演示版本,方便联调时确认口径。"
|
||||
extra="老板驾驶舱 H5"
|
||||
/>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
老
|
||||
</div>
|
||||
<div>
|
||||
<h2>老板视角</h2>
|
||||
<p>可查看经营概览、今日快报、排行与风险预警。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Environment</p>
|
||||
<h2>数据环境</h2>
|
||||
</div>
|
||||
<Tag color="warning">Mock</Tag>
|
||||
</div>
|
||||
<div className="info-list">
|
||||
<span>
|
||||
<small>数据日期</small>
|
||||
<strong>{data.businessDate}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>生成时间</small>
|
||||
<strong>{data.generatedAt}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>API 模式</small>
|
||||
<strong>{import.meta.env.VITE_MOCK_ENABLED === 'false' ? '真实接口' : 'Mock 演示'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Permissions</p>
|
||||
<h2>权限模块</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
<span>经营概览:可见</span>
|
||||
<span>资金池摘要:可见</span>
|
||||
<span>风险预警:可见</span>
|
||||
<span>导出能力:待接入</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
72
dashboard-frontend/src/features/boss-dashboard/types.ts
Normal file
72
dashboard-frontend/src/features/boss-dashboard/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type MetricStatus = 'normal' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export type SnapshotStatus = 'pending' | 'success' | 'failed' | 'temporary'
|
||||
|
||||
export type SnapshotSlot = '1015' | '1455'
|
||||
|
||||
export type KpiMetric = {
|
||||
key: string
|
||||
title: string
|
||||
value: number | string | null
|
||||
unit?: string
|
||||
trendLabel?: string
|
||||
trendValue?: number | string
|
||||
status: MetricStatus
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export type TodaySnapshot = {
|
||||
slot: SnapshotSlot
|
||||
title: string
|
||||
status: SnapshotStatus
|
||||
generatedAt?: string
|
||||
message: string
|
||||
purchaseUsers: number
|
||||
orderCount: number
|
||||
dealAmount: number | string
|
||||
paidAmount: number | string
|
||||
newMerchandiseCount: number
|
||||
selfBonusChange: number | string
|
||||
shareBonusChange: number | string
|
||||
}
|
||||
|
||||
export type TrendPoint = {
|
||||
date: string
|
||||
amount: number | string
|
||||
orders: number
|
||||
newUsers: number
|
||||
bonus: number | string
|
||||
}
|
||||
|
||||
export type RankItem = {
|
||||
id: string
|
||||
name: string
|
||||
value: number | string
|
||||
description: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export type RiskLevel = 'red' | 'yellow' | 'gray'
|
||||
|
||||
export type RiskAlert = {
|
||||
id: string
|
||||
level: RiskLevel
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
discoveredAt: string
|
||||
}
|
||||
|
||||
export type DashboardOverview = {
|
||||
businessDate: string
|
||||
generatedAt: string
|
||||
summary: string
|
||||
kpis: KpiMetric[]
|
||||
fundPool: KpiMetric[]
|
||||
snapshots: TodaySnapshot[]
|
||||
trends: TrendPoint[]
|
||||
userRanks: RankItem[]
|
||||
teamRanks: RankItem[]
|
||||
productRanks: RankItem[]
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
18
dashboard-frontend/src/features/common/PlaceholderPage.tsx
Normal file
18
dashboard-frontend/src/features/common/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Empty } from 'antd-mobile'
|
||||
|
||||
type PlaceholderPageProps = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<section className="placeholder-page">
|
||||
<div className="mobile-page-header">
|
||||
<p className="eyebrow">经营驾驶舱</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<Empty description={description} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
589
dashboard-frontend/src/index.css
Normal file
589
dashboard-frontend/src/index.css
Normal file
@@ -0,0 +1,589 @@
|
||||
:root {
|
||||
--bg: #fff6f1;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fb;
|
||||
--text: #132033;
|
||||
--muted: #6b7a90;
|
||||
--border: rgba(19, 32, 51, 0.08);
|
||||
--primary: #ff5b36;
|
||||
--primary-deep: #f04a2a;
|
||||
--primary-soft: #fff0eb;
|
||||
--success: #14a46c;
|
||||
--warning: #ffb000;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 16px 40px rgba(255, 91, 54, 0.14);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--adm-color-primary: var(--primary);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 91, 54, 0.2), transparent 28rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(255, 91, 54, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: min(100%, 430px);
|
||||
min-height: 100svh;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 1px rgba(19, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
min-height: 100svh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-main {
|
||||
min-height: 100svh;
|
||||
padding-bottom: calc(74px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(100%, 430px);
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-topline,
|
||||
.section-title-row,
|
||||
.risk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-topline span,
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-topline button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.hero-metric span,
|
||||
.hero-metric small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.kpi-card--featured {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.kpi-title,
|
||||
.kpi-trend,
|
||||
.section-kicker,
|
||||
.snapshot-time,
|
||||
.rank-content small,
|
||||
.risk-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1.08;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kpi-trend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kpi-trend span {
|
||||
margin-left: 6px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-card--success .kpi-trend span {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.kpi-card--warning .kpi-trend span {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.kpi-card--danger .kpi-value,
|
||||
.kpi-card--danger .kpi-trend span {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.section-title-row h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.compact-section .kpi-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.snapshot-section .adm-capsule-tabs {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-message {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-grid span {
|
||||
min-height: 64px;
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.snapshot-grid strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mini-trend-chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rank-list,
|
||||
.risk-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-index {
|
||||
display: inline-grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
background: var(--text);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rank-content strong,
|
||||
.rank-content small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-value {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-count {
|
||||
color: var(--danger);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-header {
|
||||
justify-content: flex-start;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-header time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.risk-item strong {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-item p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.placeholder-page,
|
||||
.loading-page,
|
||||
.error-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.mobile-page-header h1 {
|
||||
margin: 4px 0 24px;
|
||||
}
|
||||
|
||||
.operations-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.operations-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.operations-header h1 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.operations-header p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.operations-header .eyebrow {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.operations-header span {
|
||||
display: inline-flex;
|
||||
margin-top: 16px;
|
||||
padding: 7px 12px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.report-list,
|
||||
.check-list,
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.report-item {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.report-item span,
|
||||
.info-list span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.report-item small,
|
||||
.info-list small,
|
||||
.profile-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.snapshot-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-detail-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-detail-subtitle {
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-detail-message {
|
||||
margin: 10px 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-grid--wide {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.check-list span,
|
||||
.info-list span {
|
||||
padding: 12px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.check-list span {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check-list strong {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.risk-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card {
|
||||
display: grid;
|
||||
min-height: 104px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.risk-summary-card strong {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.risk-summary-card span:last-child {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card--red strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-summary-card--yellow strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.risk-summary-card--gray strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: 58px 1fr;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.profile-card h2,
|
||||
.profile-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(145deg, var(--primary), var(--warning));
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.loading-page,
|
||||
.error-page {
|
||||
display: grid;
|
||||
min-height: 60svh;
|
||||
place-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
20
dashboard-frontend/src/main.tsx
Normal file
20
dashboard-frontend/src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'antd-mobile/es/global'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const startApp = async () => {
|
||||
if (import.meta.env.VITE_MOCK_ENABLED !== 'false') {
|
||||
const { worker } = await import('./services/mock/browser')
|
||||
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void startApp()
|
||||
26
dashboard-frontend/src/services/http/client.ts
Normal file
26
dashboard-frontend/src/services/http/client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const httpClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '',
|
||||
timeout: 8000,
|
||||
})
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number
|
||||
message?: string
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function getApiData<T>(url: string): Promise<T> {
|
||||
const response = await httpClient.get<ApiResponse<T>>(url)
|
||||
if (response.data.code !== 0 && response.data.code !== 200) {
|
||||
throw new Error(response.data.msg ?? response.data.message ?? '接口请求失败')
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function getBlob(url: string): Promise<Blob> {
|
||||
const response = await httpClient.get<Blob>(url, { responseType: 'blob' })
|
||||
return response.data
|
||||
}
|
||||
4
dashboard-frontend/src/services/mock/browser.ts
Normal file
4
dashboard-frontend/src/services/mock/browser.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import { handlers } from './handlers'
|
||||
|
||||
export const worker = setupWorker(...handlers)
|
||||
58
dashboard-frontend/src/services/mock/handlers.ts
Normal file
58
dashboard-frontend/src/services/mock/handlers.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { dashboardMock } from '../../features/boss-dashboard/mock'
|
||||
|
||||
function buildArchiveHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>经营日报归档 - ${dashboardMock.businessDate}</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #132033; background: #fff6f1; }
|
||||
main { max-width: 820px; margin: 0 auto; padding: 28px 18px 40px; }
|
||||
header { color: #fff; padding: 26px; border-radius: 0 0 28px 28px; background: linear-gradient(145deg, #ff5b36, #ff8b52); }
|
||||
section { margin-top: 16px; padding: 18px; background: #fff; border-radius: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
article { padding: 14px; border-radius: 18px; background: #f6f9fb; }
|
||||
small { color: #6b7a90; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p>Daily Report Archive</p>
|
||||
<h1>经营日报归档</h1>
|
||||
<p>${dashboardMock.summary}</p>
|
||||
<small>数据日期:${dashboardMock.businessDate} / 生成时间:${dashboardMock.generatedAt}</small>
|
||||
</header>
|
||||
<section>
|
||||
<h2>核心经营指标</h2>
|
||||
<div class="grid">
|
||||
${dashboardMock.kpis
|
||||
.map((metric) => `<article><small>${metric.title}</small><h3>${metric.value}${metric.unit ?? ''}</h3><small>${metric.trendLabel ?? ''}</small></article>`)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
http.get('/api/admin/dashboard/overview', () => {
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: dashboardMock,
|
||||
})
|
||||
}),
|
||||
http.get('/api/admin/dashboard/daily-report/archive', () => {
|
||||
return new HttpResponse(buildArchiveHtml(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="dashboard-daily-report-${dashboardMock.businessDate}.html"`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
22
dashboard-frontend/src/utils/format.test.ts
Normal file
22
dashboard-frontend/src/utils/format.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatMetricValue, formatMoney, formatNumber, formatTrend } from './format'
|
||||
|
||||
describe('format helpers', () => {
|
||||
it('formats money with yuan symbol and two decimals', () => {
|
||||
expect(formatMoney(1289360.4)).toBe('¥1,289,360.40')
|
||||
})
|
||||
|
||||
it('formats metric values based on unit', () => {
|
||||
expect(formatMetricValue(418471.07, '分')).toBe('418,471.070')
|
||||
expect(formatMetricValue(936, '人')).toBe('936人')
|
||||
})
|
||||
|
||||
it('uses placeholder for empty values', () => {
|
||||
expect(formatNumber(null)).toBe('--')
|
||||
})
|
||||
|
||||
it('adds plus sign for positive trend values', () => {
|
||||
expect(formatTrend(8.6)).toBe('+8.6%')
|
||||
expect(formatTrend(-3.2)).toBe('-3.2%')
|
||||
})
|
||||
})
|
||||
33
dashboard-frontend/src/utils/format.ts
Normal file
33
dashboard-frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function formatMoney(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return `¥${numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | string | null | undefined, digits = 0): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatMetricValue(value: number | string | null, unit?: string): string {
|
||||
if (unit === '元') return formatMoney(value)
|
||||
if (unit === '分') return formatNumber(value, 3)
|
||||
return `${formatNumber(value)}${unit ?? ''}`
|
||||
}
|
||||
|
||||
export function formatTrend(value?: number | string): string {
|
||||
if (value === undefined || value === '') return ''
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
const prefix = numberValue > 0 ? '+' : ''
|
||||
return `${prefix}${numberValue.toFixed(1)}%`
|
||||
}
|
||||
25
dashboard-frontend/tsconfig.app.json
Normal file
25
dashboard-frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard-frontend/tsconfig.json
Normal file
7
dashboard-frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
dashboard-frontend/tsconfig.node.json
Normal file
24
dashboard-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
dashboard-frontend/vite.config.ts
Normal file
17
dashboard-frontend/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:30032',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
79
deploy/docker/.env.example
Normal file
79
deploy/docker/.env.example
Normal file
@@ -0,0 +1,79 @@
|
||||
# ============================================================
|
||||
# Docker 部署环境变量模板
|
||||
# 使用方法: cp .env.example .env 然后按实际填写
|
||||
# 注意: .env 切勿提交到 git
|
||||
# ============================================================
|
||||
|
||||
# ---------- 基础 ----------
|
||||
TZ=Asia/Shanghai
|
||||
COMPOSE_PROJECT_NAME=integral-shop
|
||||
|
||||
# ---------- 阿里云 RDS for MySQL ----------
|
||||
# 外网地址(与 240 同地域 VPC 时建议改为内网地址,更快更安全)
|
||||
RDS_HOST=rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com
|
||||
RDS_PORT=3306
|
||||
|
||||
# 积分商城(Webman PHP 后端)
|
||||
RDS_INTEGRAL_DB=yangtangyoupin
|
||||
RDS_INTEGRAL_USER=yangtangyoupin
|
||||
RDS_INTEGRAL_PASS=change-me
|
||||
|
||||
# 寄卖商城(Spring Boot 后端) — 与积分商城共库共账号
|
||||
RDS_SINGLE_DB=yangtangyoupin
|
||||
RDS_SINGLE_USER=yangtangyoupin
|
||||
RDS_SINGLE_PASS=change-me
|
||||
|
||||
# ---------- 容器内 Redis ----------
|
||||
REDIS_PASSWORD=change-me-redis
|
||||
# 三个业务各自的 db 序号,建议互不冲突
|
||||
REDIS_INTEGRAL_DB=0
|
||||
REDIS_SINGLE_ADMIN_DB=25
|
||||
REDIS_SINGLE_FRONT_DB=26
|
||||
|
||||
# 是否把 Redis 端口暴露到宿主机(生产建议不暴露,留空字符串即可关闭)
|
||||
REDIS_HOST_PORT=6379
|
||||
|
||||
# ---------- 积分商城 H5 configs.js 注入 ----------
|
||||
# 用户浏览器访问到的 API/图片/H5 域名(必须为外网可达 URL)
|
||||
INTEGRAL_API_PUBLIC_URL=https://admin.example.com
|
||||
INTEGRAL_IMG_PUBLIC_URL=https://admin.example.com
|
||||
INTEGRAL_H5_PUBLIC_URL=https://h5.example.com/
|
||||
# 业务标识:sn_id(数字)、appStr(与寄卖商城 APP_SECRET 必须一致)
|
||||
INTEGRAL_SN_ID=17533260260405
|
||||
INTEGRAL_APP_STR=ZFyTNQTWEkCBczKzyUDJWE9Ecx260405
|
||||
INTEGRAL_TITLE=晨召春商贸
|
||||
INTEGRAL_CONTRACT_PAGE=10012
|
||||
|
||||
# ---------- 积分商城 Webman 后端短信 / OSS ----------
|
||||
# 不用就保持空值;启用时填写真实凭证(也可直接编辑 integral-resell/.env 文件)
|
||||
SMS_CHANNEL=alibaba
|
||||
SMS_SIGNNAME=
|
||||
SMS_TEMPLATE=
|
||||
SMS_KEYID=
|
||||
SMS_KEYSECRET=
|
||||
|
||||
# OSS_TYPE=public 表示用本地 public/upload;oss 表示走阿里云 OSS
|
||||
FILE_STORAGE=public
|
||||
OSS_ACCESS_ID=
|
||||
OSS_ACCESS_SECRET=
|
||||
OSS_BUCKET=
|
||||
OSS_ENDPOINT=
|
||||
OSS_URL=
|
||||
|
||||
# ---------- 寄卖商城前端构建参数 ----------
|
||||
# 留空即走与 Nginx 同域 /api/,最佳实践;如要直连后端写完整 URL
|
||||
SINGLE_ADMIN_BASE_API=
|
||||
SINGLE_H5_DOMAIN=
|
||||
|
||||
# ---------- 寄卖商城后端 JVM ----------
|
||||
SINGLE_ADMIN_JAVA_OPTS=-Xms256m -Xmx512m
|
||||
SINGLE_FRONT_JAVA_OPTS=-Xms256m -Xmx768m
|
||||
|
||||
# ---------- 寄卖商城订单同步(无 MER 时填默认) ----------
|
||||
SYNC_SOURCE_ID=
|
||||
SYNC_TARGET_MER_ID=0
|
||||
|
||||
# ---------- 宿主机暴露端口(可按需修改) ----------
|
||||
INTEGRAL_H5_PORT=18080
|
||||
SINGLE_ADMIN_WEB_PORT=18081
|
||||
SINGLE_H5_PORT=18082
|
||||
3
deploy/docker/.gitignore
vendored
Normal file
3
deploy/docker/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 部署敏感文件不入库
|
||||
.env
|
||||
integral-resell/.env
|
||||
119
deploy/docker/README.md
Normal file
119
deploy/docker/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Docker 部署 — 快速上手
|
||||
|
||||
> 详细方案见仓库根目录的 `DOCKER_DEPLOY.md`。本文件只列必要操作。
|
||||
|
||||
## 已提供的项目化部署目录
|
||||
|
||||
| 项目 | 步骤一 | 步骤二 |
|
||||
|-----|------|------|
|
||||
| `czleilei240` 参考模板 | `deploy/docker/step1-integral` | `deploy/docker/step2-single-shop` |
|
||||
| `byhlc112` | `deploy/docker/step1-integral-byhlc112` | `deploy/docker/step2-single-shop-byhlc112` |
|
||||
|
||||
## 1. 准备环境变量
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
cp .env.example .env
|
||||
$EDITOR .env # 填入 RDS / Redis / 域名 等
|
||||
|
||||
cp integral-resell/.env.template integral-resell/.env
|
||||
$EDITOR integral-resell/.env # 积分商城 Webman 后端配置
|
||||
```
|
||||
|
||||
## 2. 阿里云 RDS 初始化
|
||||
|
||||
在 RDS 控制台新建:
|
||||
- 积分商城库(默认名 `yangtangyoupin`)→ 导入 `db/yangtangyoupin.sql`
|
||||
- 寄卖商城库(默认同上)→ 导入 `db/shop22-v2.sql`(或与生产对齐的 `db/jjy153-mysql.sql`)
|
||||
|
||||
把 Docker 主机出口 IP 加入 RDS 白名单。
|
||||
|
||||
## 3. 构建并启动
|
||||
|
||||
```bash
|
||||
# 一次性构建所有镜像(首次 5-15 分钟,含 Maven & Node 拉依赖)
|
||||
docker compose build
|
||||
|
||||
# 后台启动全部服务
|
||||
docker compose up -d
|
||||
|
||||
# 查看状态
|
||||
docker compose ps
|
||||
docker compose logs -f single-admin-api
|
||||
docker compose logs -f single-front-api
|
||||
docker compose logs -f integral-houtai
|
||||
```
|
||||
|
||||
## 4. 验证
|
||||
|
||||
| URL | 期望 |
|
||||
|-----|------|
|
||||
| `http://<host>:18080/` | 积分商城 H5 首页 |
|
||||
| `http://<host>:18080/api/...` | 转发到 integral-houtai |
|
||||
| `http://<host>:18081/` | 寄卖管理后台登录页 |
|
||||
| `http://<host>:18082/` | 寄卖用户端 H5 |
|
||||
|
||||
## 5. 常用运维
|
||||
|
||||
```bash
|
||||
docker compose restart single-admin-api
|
||||
docker compose build --no-cache single-front-api && docker compose up -d single-front-api
|
||||
docker compose exec single-admin-api sh
|
||||
docker compose down # 不删卷
|
||||
docker compose down -v # 连卷一并删除(**慎用**)
|
||||
```
|
||||
|
||||
## 6. 备份卷
|
||||
|
||||
```bash
|
||||
# 在 docker host 上运行
|
||||
for v in integral-upload single-images redis-data; do
|
||||
docker run --rm -v $v:/d -v $(pwd):/b alpine \
|
||||
tar czf /b/${v}-$(date +%F).tgz -C /d .
|
||||
done
|
||||
```
|
||||
|
||||
## 7. "fast" 模式(跳过前端构建,使用源码已有 dist)
|
||||
|
||||
如果源码目录里 `backend-adminend/dist` 和 `single_uniapp22miao/unpackage/dist/build/` 已经是最新构建产物,可加速:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg=BUILDKIT_INLINE_CACHE=1 \
|
||||
--target fast single-admin-web single-h5
|
||||
```
|
||||
|
||||
## 8. 切换为外部 Redis
|
||||
|
||||
把 `.env` 中 `REDIS_HOST` 改为外部地址、注释 `docker-compose.yml` 中的 `redis:` 服务即可。Spring Boot 与 Webman 都通过环境变量读取 Redis 地址。
|
||||
|
||||
## 9. 远端一键部署(116.62.83.240)
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
cp server.env.example server.env # 已预填 116.62.83.240 / root / A@123456
|
||||
|
||||
# 首次(推荐):把密码登录换成 SSH key
|
||||
ssh-copy-id root@116.62.83.240
|
||||
# 然后把 server.env 里的 SSHPASS 行注释掉
|
||||
|
||||
# 同步代码并启动
|
||||
./sync-to-server.sh up
|
||||
|
||||
# 同步完成后,若是首次部署还需要先在远端填写 .env:
|
||||
./bootstrap-remote-env.sh
|
||||
./remote-up.sh ssh
|
||||
# 远端:cd /root/integral-shop/deploy/docker
|
||||
# vim .env
|
||||
# vim integral-resell/.env
|
||||
# 编辑完退出后:
|
||||
./remote-up.sh up
|
||||
|
||||
# 日常运维(本机执行,不用登录服务器)
|
||||
./remote-up.sh ps
|
||||
./remote-up.sh logs single-admin-api
|
||||
./remote-up.sh restart single-front-api
|
||||
./remote-up.sh build single-admin-web
|
||||
```
|
||||
|
||||
> 用密码模式需要先 `brew install hudochenkov/sshpass/sshpass`(macOS)。
|
||||
> 用 SSH key 模式则任何依赖都不需要。
|
||||
187
deploy/docker/docker-compose.yml
Normal file
187
deploy/docker/docker-compose.yml
Normal file
@@ -0,0 +1,187 @@
|
||||
# =============================================================
|
||||
# 寄卖商城 + 积分商城 前后端 Docker 编排
|
||||
# - 不含 MER-2.2 多商户
|
||||
# - 不含 MySQL(使用阿里云 RDS)
|
||||
# - 包含独立 Redis(如需用外部 Redis,注释掉 redis 服务并修改 REDIS_HOST)
|
||||
# =============================================================
|
||||
|
||||
name: integral-shop
|
||||
|
||||
x-common: &common
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
networks:
|
||||
inner:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
integral-runtime:
|
||||
integral-upload:
|
||||
single-images:
|
||||
single-logs:
|
||||
|
||||
services:
|
||||
# ---------------- Redis ----------------
|
||||
redis:
|
||||
<<: *common
|
||||
image: redis:6.2-alpine
|
||||
container_name: integral-shop-redis
|
||||
command: ["redis-server", "/etc/redis/redis.conf", "--requirepass", "${REDIS_PASSWORD}"]
|
||||
volumes:
|
||||
- ./redis/redis.conf:/etc/redis/redis.conf:ro
|
||||
- redis-data:/data
|
||||
networks: [inner]
|
||||
# 仅当 REDIS_HOST_PORT 非空时暴露
|
||||
ports:
|
||||
- "${REDIS_HOST_PORT:-}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# ---------------- 积分商城:Webman 后端 ----------------
|
||||
integral-houtai:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../integral-resell/houtai
|
||||
dockerfile: ../../deploy/docker/integral-resell/houtai.Dockerfile
|
||||
image: integral-shop/integral-houtai:latest
|
||||
container_name: integral-houtai
|
||||
networks: [inner]
|
||||
volumes:
|
||||
# .env 由宿主机模板渲染挂入容器;编辑此文件即可改 DB / OSS / 短信
|
||||
- ./integral-resell/.env:/app/.env:ro
|
||||
- integral-runtime:/app/runtime
|
||||
- integral-upload:/app/public/upload
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------------- 积分商城:H5 静态站 ----------------
|
||||
integral-h5:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../integral-resell/h5
|
||||
dockerfile: ../../deploy/docker/integral-resell/h5.Dockerfile
|
||||
image: integral-shop/integral-h5:latest
|
||||
container_name: integral-h5
|
||||
networks: [inner]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
# 这些会在 entrypoint 中被注入到 static/configs.js
|
||||
INTEGRAL_TITLE: ${INTEGRAL_TITLE}
|
||||
INTEGRAL_API_PUBLIC_URL: ${INTEGRAL_API_PUBLIC_URL}
|
||||
INTEGRAL_IMG_PUBLIC_URL: ${INTEGRAL_IMG_PUBLIC_URL}
|
||||
INTEGRAL_H5_PUBLIC_URL: ${INTEGRAL_H5_PUBLIC_URL}
|
||||
INTEGRAL_SN_ID: ${INTEGRAL_SN_ID}
|
||||
INTEGRAL_APP_STR: ${INTEGRAL_APP_STR}
|
||||
INTEGRAL_CONTRACT_PAGE: ${INTEGRAL_CONTRACT_PAGE}
|
||||
ports:
|
||||
- "${INTEGRAL_H5_PORT:-18080}:80"
|
||||
depends_on:
|
||||
- integral-houtai
|
||||
|
||||
# ---------------- 寄卖商城:管理后台 API ----------------
|
||||
single-admin-api:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/backend
|
||||
dockerfile: ../../deploy/docker/single-shop/admin-api.Dockerfile
|
||||
image: integral-shop/single-admin-api:latest
|
||||
container_name: single-admin-api
|
||||
networks: [inner]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
JAVA_HEAP_OPTS: ${SINGLE_ADMIN_JAVA_OPTS:--Xms128m -Xmx256m}
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
SERVER_PORT: "30032"
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_PORT: ${RDS_PORT:-3306}
|
||||
MYSQL_DATABASE: ${RDS_SINGLE_DB}
|
||||
MYSQL_USERNAME: ${RDS_SINGLE_USER}
|
||||
MYSQL_PASSWORD: ${RDS_SINGLE_PASS}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DATABASE: ${REDIS_SINGLE_ADMIN_DB:-25}
|
||||
SYNC_SOURCE_ID: ${SYNC_SOURCE_ID:-}
|
||||
SYNC_TARGET_MER_ID: ${SYNC_TARGET_MER_ID:-0}
|
||||
volumes:
|
||||
- ./single-shop/application-docker.yml:/config/application-docker.yml:ro
|
||||
- single-images:/usr/local/crmeb/crmebimage
|
||||
- single-logs:/app/log
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------------- 寄卖商城:用户端 API ----------------
|
||||
single-front-api:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/backend
|
||||
dockerfile: ../../deploy/docker/single-shop/front-api.Dockerfile
|
||||
image: integral-shop/single-front-api:latest
|
||||
container_name: single-front-api
|
||||
networks: [inner]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
JAVA_OPTS: ${SINGLE_FRONT_JAVA_OPTS:--Xms256m -Xmx768m}
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
SERVER_PORT: "30031"
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_PORT: ${RDS_PORT:-3306}
|
||||
MYSQL_DATABASE: ${RDS_SINGLE_DB}
|
||||
MYSQL_USERNAME: ${RDS_SINGLE_USER}
|
||||
MYSQL_PASSWORD: ${RDS_SINGLE_PASS}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DATABASE: ${REDIS_SINGLE_FRONT_DB:-26}
|
||||
volumes:
|
||||
- ./single-shop/application-docker.yml:/config/application-docker.yml:ro
|
||||
- single-images:/usr/local/crmeb/crmebimage
|
||||
- single-logs:/app/log
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------------- 寄卖商城:管理后台 Web ----------------
|
||||
single-admin-web:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/backend-adminend
|
||||
dockerfile: ../../deploy/docker/single-shop/admin-web.Dockerfile
|
||||
args:
|
||||
VUE_APP_BASE_API: ${SINGLE_ADMIN_BASE_API:-}
|
||||
image: integral-shop/single-admin-web:latest
|
||||
container_name: single-admin-web
|
||||
networks: [inner]
|
||||
ports:
|
||||
- "${SINGLE_ADMIN_WEB_PORT:-18081}:80"
|
||||
depends_on:
|
||||
- single-admin-api
|
||||
|
||||
# ---------------- 寄卖商城:用户端 H5 ----------------
|
||||
single-h5:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/single_uniapp22miao
|
||||
dockerfile: ../../deploy/docker/single-shop/h5.Dockerfile
|
||||
args:
|
||||
H5_API_DOMAIN: ${SINGLE_H5_DOMAIN:-}
|
||||
image: integral-shop/single-h5:latest
|
||||
container_name: single-h5
|
||||
networks: [inner]
|
||||
ports:
|
||||
- "${SINGLE_H5_PORT:-18082}:80"
|
||||
depends_on:
|
||||
- single-front-api
|
||||
83
deploy/docker/integral-resell/h5.Dockerfile
Normal file
83
deploy/docker/integral-resell/h5.Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
||||
# =============================================================
|
||||
# 寄卖商城 H5 静态站运行时镜像
|
||||
# 静态文件通过 bind-mount 挂入 /usr/share/nginx/html
|
||||
# =============================================================
|
||||
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache tzdata \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
RUN cat > /etc/nginx/conf.d/default.conf <<'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map|pdf)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://integral-houtai:8785/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /upload/ {
|
||||
proxy_pass http://integral-houtai:8785/upload/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
RUN cat > /docker-entrypoint.d/90-integral-configs.sh <<'SH' \
|
||||
&& chmod +x /docker-entrypoint.d/90-integral-configs.sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CONFIG_FILE=/usr/share/nginx/html/static/configs.js
|
||||
|
||||
js_escape() {
|
||||
printf '%s' "$1" | awk '{ gsub(/\\/, "\\\\"); gsub("\047", "\\\047"); printf "%s", $0 }'
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
window.configs = {
|
||||
TITLE: '$(js_escape "${INTEGRAL_TITLE:-}")',
|
||||
BASE_URL: '$(js_escape "${INTEGRAL_API_PUBLIC_URL:-}")/api',
|
||||
IMG_URL: '$(js_escape "${INTEGRAL_IMG_PUBLIC_URL:-}")',
|
||||
H5_URL: '$(js_escape "${INTEGRAL_H5_PUBLIC_URL:-}")',
|
||||
sn_id: ${INTEGRAL_SN_ID:-0},
|
||||
appStr: '$(js_escape "${INTEGRAL_APP_STR:-}")'
|
||||
}
|
||||
EOF
|
||||
SH
|
||||
|
||||
EXPOSE 80
|
||||
20
deploy/docker/integral-resell/houtai.Dockerfile
Normal file
20
deploy/docker/integral-resell/houtai.Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# =============================================================
|
||||
# 寄卖商城 Webman 后端运行时镜像
|
||||
# 应用目录通过 bind-mount 挂入 /app,镜像只提供基础运行环境
|
||||
# =============================================================
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache bash curl tzdata ca-certificates \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8785
|
||||
|
||||
CMD ["sh", "-c", "chmod +x /app/webman.bin && exec /app/webman.bin start"]
|
||||
54
deploy/docker/nginx/admin.lehoo6.com.conf
Normal file
54
deploy/docker/nginx/admin.lehoo6.com.conf
Normal file
@@ -0,0 +1,54 @@
|
||||
upstream resell_api {
|
||||
server 127.0.0.1:18085;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name admin.lehoo6.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/admin.lehoo6.com;
|
||||
include /www/server/panel/vhost/nginx/extension/admin.lehoo6.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/admin.lehoo6.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
location / {
|
||||
proxy_pass http://resell_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/admin.lehoo6.com.log;
|
||||
error_log /www/wwwlogs/admin.lehoo6.com.error.log;
|
||||
}
|
||||
92
deploy/docker/nginx/jf.lehoo6.com.conf
Normal file
92
deploy/docker/nginx/jf.lehoo6.com.conf
Normal file
@@ -0,0 +1,92 @@
|
||||
upstream jifenmall_h5 {
|
||||
server 127.0.0.1:18082;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name jf.lehoo6.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/jf.lehoo6.com;
|
||||
include /www/server/panel/vhost/nginx/extension/jf.lehoo6.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/jf.lehoo6.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_jf.lehoo6.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
location /api/front {
|
||||
proxy_pass http://127.0.0.1:30033/api/front;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /api/admin {
|
||||
proxy_pass http://127.0.0.1:30032/api/admin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /api/external {
|
||||
proxy_pass http://127.0.0.1:30032/api/external;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://jifenmall_h5;
|
||||
}
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/jf.lehoo6.com.log;
|
||||
error_log /www/wwwlogs/jf.lehoo6.com.error.log;
|
||||
}
|
||||
59
deploy/docker/nginx/jfadmin.lehoo6.com.conf
Normal file
59
deploy/docker/nginx/jfadmin.lehoo6.com.conf
Normal file
@@ -0,0 +1,59 @@
|
||||
upstream jifenmall_admin {
|
||||
server 127.0.0.1:18081;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name jfadmin.lehoo6.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/jfadmin.lehoo6.com;
|
||||
include /www/server/panel/vhost/nginx/extension/jfadmin.lehoo6.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/jfadmin.lehoo6.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_jfadmin.lehoo6.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://jifenmall_admin;
|
||||
}
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/jfadmin.lehoo6.com.log;
|
||||
error_log /www/wwwlogs/jfadmin.lehoo6.com.error.log;
|
||||
}
|
||||
55
deploy/docker/nginx/lehoo6.com.conf
Normal file
55
deploy/docker/nginx/lehoo6.com.conf
Normal file
@@ -0,0 +1,55 @@
|
||||
upstream resell_h5 {
|
||||
server 127.0.0.1:18080;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name lehoo6.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/lehoo6.com;
|
||||
include /www/server/panel/vhost/nginx/extension/lehoo6.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/lehoo6.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/lehoo6.com_cert/nginx/lehoo6.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://resell_h5;
|
||||
}
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/lehoo6.com.log;
|
||||
error_log /www/wwwlogs/lehoo6.com.error.log;
|
||||
}
|
||||
117
deploy/docker/nginx/leilei-jf.czchunfang.com.conf
Normal file
117
deploy/docker/nginx/leilei-jf.czchunfang.com.conf
Normal file
@@ -0,0 +1,117 @@
|
||||
upstream jifenmall_h5 {
|
||||
server 127.0.0.1:18082;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leilei-jf.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leilei-jf.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leilei-jf.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leilei-jf.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jf.czchunfang.com_cert/nginx/leilei-jf.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jf.czchunfang.com_cert/nginx/leilei-jf.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leilei-jf.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# ---------- API 直连 Docker 容器(不经 H5 Nginx 中转) ----------
|
||||
|
||||
# 前台 API(用户端)→ single-front-api 容器 30033
|
||||
location /api/front {
|
||||
proxy_pass http://127.0.0.1:30033/api/front;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# 管理后台 API → single-admin-api 容器 30032
|
||||
location /api/admin {
|
||||
proxy_pass http://127.0.0.1:30032/api/admin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# 外部接口(同 admin-api)
|
||||
location /api/external {
|
||||
proxy_pass http://127.0.0.1:30032/api/external;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# ---------- H5 前端静态文件 → Docker single-h5 容器 ----------
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://jifenmall_h5;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leilei-jf.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leilei-jf.czchunfang.com.error.log;
|
||||
}
|
||||
80
deploy/docker/nginx/leilei-jfadmin.czchunfang.com.conf
Normal file
80
deploy/docker/nginx/leilei-jfadmin.czchunfang.com.conf
Normal file
@@ -0,0 +1,80 @@
|
||||
upstream jifenmall_admin {
|
||||
server 127.0.0.1:18081;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leilei-jfadmin.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leilei-jfadmin.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leilei-jfadmin.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leilei-jfadmin.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jfadmin.czchunfang.com_cert/nginx/leilei-jfadmin.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jfadmin.czchunfang.com_cert/nginx/leilei-jfadmin.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leilei-jfadmin.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# ---------- 管理后台静态文件 → Docker single-admin-web 容器 ----------
|
||||
# 管理后台前端调用的 API 指向 leilei-jf.czchunfang.com(与 H5 共用 API 域名)
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://jifenmall_admin;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leilei-jfadmin.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leilei-jfadmin.czchunfang.com.error.log;
|
||||
}
|
||||
82
deploy/docker/nginx/leilei.czchunfang.com.conf
Normal file
82
deploy/docker/nginx/leilei.czchunfang.com.conf
Normal file
@@ -0,0 +1,82 @@
|
||||
upstream resell_h5 {
|
||||
server 127.0.0.1:18080;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leilei.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leilei.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leilei.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leilei.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
#error_page 404/404.html;
|
||||
#HTTP_TO_HTTPS_START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
#HTTP_TO_HTTPS_END
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei.czchunfang.com_cert/nginx/leilei.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei.czchunfang.com_cert/nginx/leilei.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leilei.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# 寄卖商城 H5 → Docker integral-h5 容器(内部已含 /api/ /upload/ 反代到 webman:8785)
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://resell_h5;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leilei.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leilei.czchunfang.com.error.log;
|
||||
}
|
||||
82
deploy/docker/nginx/leileiadmin.czchunfang.com.conf
Normal file
82
deploy/docker/nginx/leileiadmin.czchunfang.com.conf
Normal file
@@ -0,0 +1,82 @@
|
||||
upstream resell_api {
|
||||
server 127.0.0.1:18085;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leileiadmin.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leileiadmin.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leileiadmin.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leileiadmin.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
#error_page 404/404.html;
|
||||
#HTTP_TO_HTTPS_START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
#HTTP_TO_HTTPS_END
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leileiadmin.czchunfang.com_cert/nginx/leileiadmin.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leileiadmin.czchunfang.com_cert/nginx/leileiadmin.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leileiadmin.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# 寄卖商城后台 API → Docker integral-houtai 容器(webman.bin 写死端口 8785)
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://resell_api;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leileiadmin.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leileiadmin.czchunfang.com.error.log;
|
||||
}
|
||||
39
deploy/docker/redis/redis.conf
Normal file
39
deploy/docker/redis/redis.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
# =============================================================
|
||||
# Redis 6.2 — 容器内最小化生产配置
|
||||
# 密码由 docker-compose 通过 --requirepass 参数注入
|
||||
# =============================================================
|
||||
|
||||
bind 0.0.0.0
|
||||
protected-mode yes
|
||||
port 6379
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
|
||||
# 数据持久化: AOF + 一份 RDB 兜底
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
|
||||
dir /data
|
||||
dbfilename dump.rdb
|
||||
appendfilename "appendonly.aof"
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
|
||||
# 内存策略
|
||||
maxmemory 512mb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# 日志
|
||||
loglevel notice
|
||||
logfile ""
|
||||
|
||||
# 慢日志
|
||||
slowlog-log-slower-than 10000
|
||||
slowlog-max-len 128
|
||||
|
||||
# 客户端
|
||||
maxclients 1000
|
||||
1
deploy/docker/scripts/.gitignore
vendored
Normal file
1
deploy/docker/scripts/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
server.env
|
||||
39
deploy/docker/scripts/bootstrap-remote-env.sh
Executable file
39
deploy/docker/scripts/bootstrap-remote-env.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 首次部署:在远端创建 .env 模板(仅在缺失时创建)
|
||||
# 同步代码后执行: ./bootstrap-remote-env.sh
|
||||
# 之后 ssh 上去 vim 这两个文件填入真实密码即可
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
set -a; . "$SCRIPT_DIR/server.env"; set +a
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
|
||||
if [ -n "${SSHPASS:-}" ]; then
|
||||
command -v sshpass >/dev/null || { echo "需要 sshpass"; exit 1; }
|
||||
export SSHPASS
|
||||
SSH=(sshpass -e ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
else
|
||||
SSH=(ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no)
|
||||
fi
|
||||
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" bash -se <<EOSSH
|
||||
set -e
|
||||
cd "${REMOTE_DIR}/deploy/docker"
|
||||
[ -f .env ] || { cp .env.example .env; echo "[+] 已创建 .env (基于 .env.example)"; }
|
||||
[ -f integral-resell/.env ] || { cp integral-resell/.env.template integral-resell/.env; echo "[+] 已创建 integral-resell/.env"; }
|
||||
|
||||
echo
|
||||
echo "请在远端编辑以下文件填入真实参数:"
|
||||
echo " ${REMOTE_DIR}/deploy/docker/.env"
|
||||
echo " ${REMOTE_DIR}/deploy/docker/integral-resell/.env"
|
||||
EOSSH
|
||||
204
deploy/docker/scripts/deploy-step1.sh
Executable file
204
deploy/docker/scripts/deploy-step1.sh
Executable file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 寄卖商城(integral-resell)一键部署脚本
|
||||
# 目标服务器:116.62.83.240
|
||||
# 使用方式:bash deploy/docker/scripts/deploy-step1.sh
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
STEP1_DIR="${PROJECT_ROOT}/deploy/docker/step1-integral"
|
||||
|
||||
# ---------- 读取服务器配置 ----------
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
set -a; . "$SCRIPT_DIR/server.env"; set +a
|
||||
else
|
||||
echo "[!] 未找到 server.env,请先创建"; exit 1
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
REMOTE_STEP1="${REMOTE_DIR}/deploy/docker/step1-integral"
|
||||
|
||||
REDIS_PASS="LeileiRedis@8899"
|
||||
DB_PASS="5Fn8eWrbYFtAhCZw"
|
||||
|
||||
# ---------- SSH / rsync 通道 ----------
|
||||
export SSHPASS="${SSHPASS:?缺少 SSHPASS}"
|
||||
SSH_OPTS="-p $SERVER_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
SSH=(sshpass -e ssh $SSH_OPTS)
|
||||
RSYNC_SSH="sshpass -e ssh $SSH_OPTS"
|
||||
RSYNC_BIN="/opt/homebrew/bin/rsync"
|
||||
[ -x "$RSYNC_BIN" ] || RSYNC_BIN="rsync"
|
||||
|
||||
remote() { "${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "$@"; }
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " 寄卖商城 Docker 一键部署 → ${SERVER_HOST}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# ─── 1. 生成本地临时 env 文件 ─────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [1/6] 生成临时 .env / houtai.env ..."
|
||||
|
||||
TMPENV=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPENV"' EXIT
|
||||
|
||||
cat > "$TMPENV/.env" <<EOF
|
||||
TZ=Asia/Shanghai
|
||||
REDIS_PASSWORD=${REDIS_PASS}
|
||||
INTEGRAL_TITLE=池州雷蕾商贸
|
||||
INTEGRAL_API_PUBLIC_URL=https://leileiadmin.czchunfang.com
|
||||
INTEGRAL_IMG_PUBLIC_URL=https://leileiadmin.czchunfang.com
|
||||
INTEGRAL_H5_PUBLIC_URL=https://leilei.czchunfang.com/
|
||||
INTEGRAL_SN_ID=17533260260517
|
||||
INTEGRAL_APP_STR=ZFyTNQTWEkCBczKzyUDJWE9Ecx260517
|
||||
INTEGRAL_CONTRACT_PAGE=10012
|
||||
INTEGRAL_H5_PORT=18080
|
||||
RESELL_API_PORT=18085
|
||||
RESELL_H5_DIR=/www/wwwroot/leilei.czchunfang.com
|
||||
RESELL_HOUTAI_DIR=/www/wwwroot/leileiadmin.czchunfang.com
|
||||
EOF
|
||||
|
||||
cat > "$TMPENV/houtai.env" <<EOF
|
||||
DB_HOST = 'rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com'
|
||||
DB_PORT = 3306
|
||||
DB_DATABASE = 'yangtangyoupin'
|
||||
DB_USERNAME = 'yangtangyoupin'
|
||||
DB_PASSWORD = '${DB_PASS}'
|
||||
REDIS_HOST = 'redis'
|
||||
REDIS_PORT = 6379
|
||||
REDIS_PASSWORD = '${REDIS_PASS}'
|
||||
SMS_CHANNEL = 'alibaba'
|
||||
SMS_SIGNNAME = '池州雷蕾商贸'
|
||||
SMS_TEMPLATE = 'SMS_334320185'
|
||||
SMS_KEYID = 'LTAI5t7CfS15hZGdNLLEMUwG'
|
||||
SMS_KEYSECRET = 'ikfTvHbMMg5sStGgdvLNL8iuVYdner'
|
||||
SMS_SDKAPPID = ''
|
||||
FILE_STORAGE = 'public'
|
||||
OSS_ACCESS_ID = ''
|
||||
OSS_ACCESS_SECRET = ''
|
||||
OSS_BUCKET = ''
|
||||
OSS_ENDPOINT = ''
|
||||
OSS_URL = ''
|
||||
APP_SIGN = '1'
|
||||
APP_SECRET = 'ZFyTNQTWEkCBczKzyUDJWE9Ecx260517'
|
||||
EOF
|
||||
|
||||
echo " ✓ 临时文件已生成"
|
||||
|
||||
# ─── 2. rsync 项目文件 ────────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [2/6] rsync 项目文件 → ${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
remote "mkdir -p '${REMOTE_DIR}'"
|
||||
|
||||
"$RSYNC_BIN" -avz --partial \
|
||||
-e "$RSYNC_SSH" \
|
||||
--exclude '.git/' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.log' \
|
||||
--exclude '**/.idea/' \
|
||||
--exclude '**/.cursor/' \
|
||||
--exclude '**/.vscode/' \
|
||||
--exclude '**/node_modules/' \
|
||||
--exclude '**/target/' \
|
||||
--exclude '**/unpackage/cache/' \
|
||||
--exclude '**/unpackage/dist/dev/' \
|
||||
--exclude 'integral-resell/houtai/runtime/' \
|
||||
--exclude 'single-shop-22/backend/logs/' \
|
||||
--exclude 'single-shop-22/backend/crmebimage/' \
|
||||
--exclude 'MER-2.2_2601/' \
|
||||
--exclude 'db/' \
|
||||
--exclude 'deploy/docker/scripts/server.env' \
|
||||
--exclude 'deploy/docker/step1-integral/.env' \
|
||||
--exclude 'deploy/docker/step1-integral/houtai.env' \
|
||||
"${PROJECT_ROOT}/" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
|
||||
echo " ✓ 项目文件同步完成"
|
||||
|
||||
# ─── 3. 上传 env 文件 ─────────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [3/6] 上传 .env 和 houtai.env ..."
|
||||
|
||||
"$RSYNC_BIN" -avz \
|
||||
-e "$RSYNC_SSH" \
|
||||
"$TMPENV/.env" \
|
||||
"${SERVER_USER}@${SERVER_HOST}:${REMOTE_STEP1}/.env"
|
||||
|
||||
"$RSYNC_BIN" -avz \
|
||||
-e "$RSYNC_SSH" \
|
||||
"$TMPENV/houtai.env" \
|
||||
"${SERVER_USER}@${SERVER_HOST}:${REMOTE_STEP1}/houtai.env"
|
||||
|
||||
echo " ✓ env 文件上传完成"
|
||||
|
||||
# ─── 4. 创建宿主机挂载目录 + 同步 H5 到 wwwroot ──────────────
|
||||
echo ""
|
||||
echo "▶ [4/6] 创建宿主机目录 & 同步 H5 静态文件 ..."
|
||||
|
||||
remote "
|
||||
set -e
|
||||
mkdir -p /www/wwwroot/leilei.czchunfang.com
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/public/upload
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/runtime/logs
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/runtime/sessions
|
||||
echo ' ✓ 宿主机目录已就绪'
|
||||
|
||||
# 同步 H5 静态文件
|
||||
rsync -a --delete \
|
||||
'${REMOTE_DIR}/integral-resell/h5/' \
|
||||
'/www/wwwroot/leilei.czchunfang.com/'
|
||||
echo ' ✓ H5 静态文件已同步到 /www/wwwroot/leilei.czchunfang.com/'
|
||||
|
||||
# 同步 webman 应用文件(webman.bin / public / index.html)到宿主机,
|
||||
# 容器启动后直接从此目录运行,FTP 更新文件后 restart 即可生效
|
||||
rsync -a --exclude='runtime/' --exclude='.env' \
|
||||
'${REMOTE_DIR}/integral-resell/houtai/' \
|
||||
'/www/wwwroot/leileiadmin.czchunfang.com/'
|
||||
chmod +x /www/wwwroot/leileiadmin.czchunfang.com/webman.bin
|
||||
echo ' ✓ webman 应用文件已同步到 /www/wwwroot/leileiadmin.czchunfang.com/'
|
||||
ls /www/wwwroot/leileiadmin.czchunfang.com/ | head -8
|
||||
"
|
||||
|
||||
# ─── 5. docker compose build & up ────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [5/6] docker compose build --no-cache ..."
|
||||
echo " (首次构建耗时 5-15 分钟,请耐心等待)"
|
||||
|
||||
remote "
|
||||
set -e
|
||||
cd '${REMOTE_STEP1}'
|
||||
docker compose --env-file .env build --no-cache
|
||||
echo ' ✓ 镜像构建完成'
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "▶ [6/6] docker compose up -d ..."
|
||||
|
||||
remote "
|
||||
set -e
|
||||
cd '${REMOTE_STEP1}'
|
||||
docker compose --env-file .env up -d
|
||||
echo ''
|
||||
docker compose --env-file .env ps
|
||||
"
|
||||
|
||||
# ─── 完成 ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " ✅ 寄卖商城部署完成!"
|
||||
echo ""
|
||||
echo " H5 前台: http://${SERVER_HOST}:18080"
|
||||
echo " API 端口: http://${SERVER_HOST}:18085"
|
||||
echo ""
|
||||
echo " 查看日志:"
|
||||
echo " cd ${REMOTE_STEP1} && docker compose --env-file .env logs -f"
|
||||
echo ""
|
||||
echo " 查看容器状态:"
|
||||
echo " docker compose --env-file .env ps"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
92
deploy/docker/scripts/remote-up.sh
Executable file
92
deploy/docker/scripts/remote-up.sh
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 远端执行 docker compose 命令
|
||||
# 用法:
|
||||
# ./remote-up.sh up # build + up -d
|
||||
# ./remote-up.sh build # 仅 build
|
||||
# ./remote-up.sh restart svc # 重启某服务
|
||||
# ./remote-up.sh logs [svc] # 跟日志
|
||||
# ./remote-up.sh ps # 服务状态
|
||||
# ./remote-up.sh ssh # 登录到远端
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
. "$SCRIPT_DIR/server.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?SERVER_HOST 未配置,请先 cp server.env.example server.env}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
|
||||
if [ -n "${SSHPASS:-}" ]; then
|
||||
command -v sshpass >/dev/null 2>&1 || { echo "需要 sshpass"; exit 1; }
|
||||
export SSHPASS
|
||||
SSH=(sshpass -e ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -t)
|
||||
else
|
||||
SSH=(ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -t)
|
||||
fi
|
||||
|
||||
run_remote() {
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "cd ${REMOTE_DIR}/deploy/docker && $*"
|
||||
}
|
||||
|
||||
cmd="${1:-up}"
|
||||
shift || true
|
||||
|
||||
case "$cmd" in
|
||||
up)
|
||||
# 预检:必须存在 .env
|
||||
run_remote "test -f .env && test -f integral-resell/.env" || {
|
||||
cat <<EOF
|
||||
[!] 远端缺少 .env 文件,请在远端执行:
|
||||
|
||||
ssh ${SERVER_USER}@${SERVER_HOST}
|
||||
cd ${REMOTE_DIR}/deploy/docker
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
cp integral-resell/.env.template integral-resell/.env
|
||||
vim integral-resell/.env
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
run_remote "docker compose build && docker compose up -d && docker compose ps"
|
||||
;;
|
||||
build)
|
||||
run_remote "docker compose build $*"
|
||||
;;
|
||||
restart)
|
||||
run_remote "docker compose restart $*"
|
||||
;;
|
||||
logs)
|
||||
run_remote "docker compose logs --tail 200 -f $*"
|
||||
;;
|
||||
ps|status)
|
||||
run_remote "docker compose ps"
|
||||
;;
|
||||
down)
|
||||
run_remote "docker compose down"
|
||||
;;
|
||||
pull)
|
||||
run_remote "docker compose pull"
|
||||
;;
|
||||
ssh)
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}"
|
||||
;;
|
||||
exec)
|
||||
run_remote "docker compose exec $*"
|
||||
;;
|
||||
*)
|
||||
echo "未知子命令: $cmd"
|
||||
echo "支持: up | build | restart | logs | ps | down | pull | ssh | exec"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
21
deploy/docker/scripts/server.env.example
Normal file
21
deploy/docker/scripts/server.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# =============================================================
|
||||
# 部署目标服务器配置 — 复制为 server.env 并按实际填写
|
||||
# 不要提交到 git
|
||||
# =============================================================
|
||||
|
||||
# 远端服务器
|
||||
# 宝塔 Docker 管理面板: https://116.62.83.240:14874/docker/conmanger
|
||||
SERVER_HOST=116.62.83.240
|
||||
SERVER_USER=root
|
||||
SERVER_PORT=22
|
||||
# 部署到服务器的目标目录
|
||||
REMOTE_DIR=/root/integral-shop
|
||||
|
||||
# 用密码登录时填写(需要 sshpass)
|
||||
# macOS: brew install hudochenkov/sshpass/sshpass
|
||||
# Linux: apt-get install -y sshpass
|
||||
# 留空则走 SSH key(推荐先 ssh-copy-id root@116.62.83.240)
|
||||
SSHPASS=A@123456
|
||||
|
||||
# 是否在 rsync 之后自动 build + up(yes / no)
|
||||
AUTO_UP=yes
|
||||
123
deploy/docker/scripts/sync-to-server.sh
Executable file
123
deploy/docker/scripts/sync-to-server.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 通过 rsync 把整个工程同步到远端服务器
|
||||
# 用法:
|
||||
# 1. 复制 server.env.example -> server.env,填入主机/密码
|
||||
# 2. ./sync-to-server.sh # 仅同步
|
||||
# ./sync-to-server.sh up # 同步后在远端 docker compose build && up -d
|
||||
# ./sync-to-server.sh logs # 远端 docker compose logs
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# 读取服务器配置
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
. "$SCRIPT_DIR/server.env"
|
||||
set +a
|
||||
elif [ -f "$SCRIPT_DIR/server.env.example" ]; then
|
||||
echo "[!] 未发现 server.env,请先 cp server.env.example server.env 并修改"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?SERVER_HOST 未配置}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
|
||||
# ---------- 选择 SSH/rsync 通道 ----------
|
||||
if [ -n "${SSHPASS:-}" ]; then
|
||||
if ! command -v sshpass >/dev/null 2>&1; then
|
||||
echo "[!] 检测到 SSHPASS 但未安装 sshpass"
|
||||
echo " macOS: brew install hudochenkov/sshpass/sshpass"
|
||||
echo " Linux: apt-get install -y sshpass"
|
||||
exit 1
|
||||
fi
|
||||
export SSHPASS
|
||||
SSH=(sshpass -e ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
RSYNC_SSH="sshpass -e ssh -p $SERVER_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
else
|
||||
SSH=(ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o ServerAliveInterval=30)
|
||||
RSYNC_SSH="ssh -p $SERVER_PORT -o StrictHostKeyChecking=no -o ServerAliveInterval=30"
|
||||
fi
|
||||
|
||||
remote() {
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "$@"
|
||||
}
|
||||
|
||||
# ---------- 1. 远端目录准备 ----------
|
||||
echo "[1/3] 准备远端目录 ${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}"
|
||||
remote "mkdir -p '${REMOTE_DIR}'"
|
||||
|
||||
# 优先使用 homebrew rsync(macOS 内置版本 2.6.9 太旧)
|
||||
RSYNC_BIN="/opt/homebrew/bin/rsync"
|
||||
[ -x "$RSYNC_BIN" ] || RSYNC_BIN="rsync"
|
||||
|
||||
# ---------- 2. rsync ----------
|
||||
echo "[2/3] rsync ${PROJECT_ROOT}/ -> ${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
"$RSYNC_BIN" -avz --delete --partial \
|
||||
-e "$RSYNC_SSH" \
|
||||
--exclude '.git/' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.log' \
|
||||
--exclude '**/.idea/' \
|
||||
--exclude '**/.cursor/' \
|
||||
--exclude '**/.vscode/' \
|
||||
--exclude '**/node_modules/' \
|
||||
--exclude '**/target/' \
|
||||
--exclude '**/unpackage/cache/' \
|
||||
--exclude '**/unpackage/dist/dev/' \
|
||||
--exclude 'integral-resell/houtai/runtime/logs/' \
|
||||
--exclude 'integral-resell/houtai/runtime/sessions/' \
|
||||
--exclude 'integral-resell/houtai/runtime/login/' \
|
||||
--exclude 'integral-resell/houtai/runtime/webman.pid' \
|
||||
--exclude 'single-shop-22/backend/logs/' \
|
||||
--exclude 'single-shop-22/backend/crmebimage/' \
|
||||
--exclude 'single-shop-22/backend-adminend/dist/' \
|
||||
--exclude 'MER-2.2_2601/' \
|
||||
--exclude 'db/mysql-recover-20260514/' \
|
||||
--exclude 'db/yangtangyoupin.sql' \
|
||||
--exclude 'deploy/docker/.env' \
|
||||
--exclude 'deploy/docker/integral-resell/.env' \
|
||||
--exclude 'deploy/docker/scripts/server.env' \
|
||||
"${PROJECT_ROOT}/" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
|
||||
# ---------- 3. 远端 env 文件提示 ----------
|
||||
echo "[3/3] 检查远端环境文件"
|
||||
remote "test -f ${REMOTE_DIR}/deploy/docker/.env || echo '[!] 远端缺少 deploy/docker/.env,请按下面提示创建'"
|
||||
remote "test -f ${REMOTE_DIR}/deploy/docker/integral-resell/.env || echo '[!] 远端缺少 deploy/docker/integral-resell/.env'"
|
||||
|
||||
echo
|
||||
echo "==============================================="
|
||||
echo " rsync 完成"
|
||||
echo " 远端目录: ${REMOTE_DIR}"
|
||||
echo "==============================================="
|
||||
|
||||
# ---------- 后续动作 ----------
|
||||
case "${1:-${AUTO_UP:-}}" in
|
||||
up|yes|YES)
|
||||
echo "[远端] 执行 docker compose build && up -d"
|
||||
"$SCRIPT_DIR/remote-up.sh" up
|
||||
;;
|
||||
build)
|
||||
"$SCRIPT_DIR/remote-up.sh" build
|
||||
;;
|
||||
logs)
|
||||
shift || true
|
||||
"$SCRIPT_DIR/remote-up.sh" logs "$@"
|
||||
;;
|
||||
"")
|
||||
echo "下一步:"
|
||||
echo " $0 up # 同步并启动"
|
||||
echo " $0 build # 仅构建"
|
||||
echo " $0 logs # 查看日志"
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
54
deploy/docker/single-shop/admin-api.Dockerfile
Normal file
54
deploy/docker/single-shop/admin-api.Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# =============================================================
|
||||
# 积分商城 管理端 API(miao-admin-2.2.jar)
|
||||
# JAR 由宿主机 bind-mount 进来(/app/app.jar),无需 Maven 编译
|
||||
# 宿主机路径:${SINGLE_ADMIN_JAR} → /app/app.jar
|
||||
# FTP 更新 JAR 后:docker compose --env-file .env restart single-admin-api
|
||||
# =============================================================
|
||||
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
LANG=C.UTF-8 LC_ALL=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 切换阿里云 Ubuntu 镜像源(服务器访问 archive.ubuntu.com 超时)
|
||||
RUN sed -i \
|
||||
-e 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
-e 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
/etc/apt/sources.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata ca-certificates curl \
|
||||
fontconfig fonts-dejavu fonts-wqy-zenhei \
|
||||
&& ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app /config /usr/local/crmeb/crmebimage /app/log
|
||||
|
||||
# 堆大小(可通过 compose environment 覆盖)
|
||||
ENV JAVA_HEAP_OPTS="-Xms128m -Xmx256m"
|
||||
|
||||
# Spring Boot 2.2.6 + Java 17 必须的模块开放参数
|
||||
ENV JAVA_MODULE_OPTS="\
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
|
||||
--add-opens java.base/java.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.io=ALL-UNNAMED \
|
||||
--add-opens java.base/java.math=ALL-UNNAMED \
|
||||
--add-opens java.base/sun.net.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.net=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
# /app/app.jar 由 docker-compose volumes bind-mount 进来
|
||||
EXPOSE 30032
|
||||
|
||||
# 等价于:nohup java -Xms128m -Xmx256m -jar miao-admin-2.2.jar > admin.log &
|
||||
ENTRYPOINT ["sh","-c","exec java \
|
||||
$JAVA_HEAP_OPTS \
|
||||
$JAVA_MODULE_OPTS \
|
||||
-Dfile.encoding=UTF-8 \
|
||||
-Duser.timezone=$TZ \
|
||||
-jar /app/app.jar \
|
||||
--spring.profiles.active=${SPRING_PROFILES_ACTIVE:-docker} \
|
||||
--spring.config.additional-location=file:/config/ \
|
||||
--server.port=${SERVER_PORT:-30032}"]
|
||||
61
deploy/docker/single-shop/admin-web.Dockerfile
Normal file
61
deploy/docker/single-shop/admin-web.Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# =============================================================
|
||||
# 积分商城 管理后台前端(Vue 2 SPA)
|
||||
# 纯 Nginx 运行时镜像,不含 Node 构建阶段
|
||||
# 静态文件由宿主机 bind-mount 进来(${SINGLE_ADMIN_WEB_DIR}:/usr/share/nginx/html)
|
||||
# 宿主机目录示例:/www/wwwroot/leilei-jfadmin.czchunfang.com/
|
||||
# 更新方式:rsync 新 dist 到宿主机目录 → 无需重建镜像
|
||||
# =============================================================
|
||||
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 切换阿里云 Alpine 镜像源
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache tzdata \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
# Nginx 反代配置
|
||||
# /api/ 和 /adminapi/ 代理到 single-admin-api 容器
|
||||
RUN cat > /etc/nginx/conf.d/default.conf <<'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://single-admin-api:30032/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /adminapi/ {
|
||||
proxy_pass http://single-admin-api:30032/adminapi/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
EXPOSE 80
|
||||
71
deploy/docker/single-shop/application-docker.yml
Normal file
71
deploy/docker/single-shop/application-docker.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# =============================================================
|
||||
# 积分商城 Docker 部署专用 Spring profile
|
||||
# 通过 --spring.config.additional-location=file:/config/ + --spring.profiles.active=docker
|
||||
# 加载本文件,并由环境变量覆盖关键参数
|
||||
# =============================================================
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:-30032}
|
||||
|
||||
crmeb:
|
||||
imagePath: /usr/local/crmeb/
|
||||
domain: https://h5y2c.com
|
||||
captchaOn: false
|
||||
asyncConfig: true
|
||||
demoSite: false
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: ${MYSQL_DATABASE}
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT:3306}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false&allowPublicKeyRetrieval=true
|
||||
username: ${MYSQL_USERNAME}
|
||||
password: ${MYSQL_PASSWORD}
|
||||
druid:
|
||||
initial-size: 5
|
||||
min-idle: 5
|
||||
max-active: 50
|
||||
max-wait: 60000
|
||||
validation-query: SELECT 1
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
redis:
|
||||
host: ${REDIS_HOST:redis}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: 10000
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200
|
||||
max-wait: -1
|
||||
max-idle: 10
|
||||
min-idle: 0
|
||||
time-between-eviction-runs: -1
|
||||
second:
|
||||
database: ${REDIS_SECOND_DATABASE:1}
|
||||
|
||||
# 订单同步(无 MER 时填默认)
|
||||
sync:
|
||||
source-id: ${SYNC_SOURCE_ID:}
|
||||
target-mer-id: ${SYNC_TARGET_MER_ID:0}
|
||||
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: info
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: /app/log
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
swagger:
|
||||
basic:
|
||||
enable: false
|
||||
check: false
|
||||
54
deploy/docker/single-shop/front-api.Dockerfile
Normal file
54
deploy/docker/single-shop/front-api.Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# =============================================================
|
||||
# 积分商城 用户端 API(miao-front-2.2.jar)
|
||||
# JAR 由宿主机 bind-mount 进来(/app/app.jar),无需 Maven 编译
|
||||
# 宿主机路径:${SINGLE_FRONT_JAR} → /app/app.jar
|
||||
# FTP 更新 JAR 后:docker compose --env-file .env restart single-front-api
|
||||
# =============================================================
|
||||
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
LANG=C.UTF-8 LC_ALL=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 切换阿里云 Ubuntu 镜像源(服务器访问 archive.ubuntu.com 超时)
|
||||
RUN sed -i \
|
||||
-e 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
-e 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
/etc/apt/sources.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata ca-certificates curl \
|
||||
fontconfig fonts-dejavu fonts-wqy-zenhei \
|
||||
&& ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app /config /usr/local/crmeb/crmebimage /app/log
|
||||
|
||||
# 堆大小(可通过 compose environment 覆盖)
|
||||
ENV JAVA_HEAP_OPTS="-Xms128m -Xmx256m"
|
||||
|
||||
# Spring Boot 2.2.6 + Java 17 必须的模块开放参数
|
||||
ENV JAVA_MODULE_OPTS="\
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
|
||||
--add-opens java.base/java.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.io=ALL-UNNAMED \
|
||||
--add-opens java.base/java.math=ALL-UNNAMED \
|
||||
--add-opens java.base/sun.net.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.net=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
# /app/app.jar 由 docker-compose volumes bind-mount 进来
|
||||
EXPOSE 30033
|
||||
|
||||
# 等价于:nohup java -Xms128m -Xmx256m -jar miao-front-2.2.jar > front.log &
|
||||
ENTRYPOINT ["sh","-c","exec java \
|
||||
$JAVA_HEAP_OPTS \
|
||||
$JAVA_MODULE_OPTS \
|
||||
-Dfile.encoding=UTF-8 \
|
||||
-Duser.timezone=$TZ \
|
||||
-jar /app/app.jar \
|
||||
--spring.profiles.active=${SPRING_PROFILES_ACTIVE:-docker} \
|
||||
--spring.config.additional-location=file:/config/ \
|
||||
--server.port=${SERVER_PORT:-30033}"]
|
||||
55
deploy/docker/single-shop/h5.Dockerfile
Normal file
55
deploy/docker/single-shop/h5.Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
||||
# =============================================================
|
||||
# 积分商城 用户端 H5(uni-app SPA)
|
||||
# 纯 Nginx 运行时镜像,不含 Node 构建阶段
|
||||
# 静态文件由宿主机 bind-mount 进来(${SINGLE_H5_DIR}:/usr/share/nginx/html)
|
||||
# 宿主机目录示例:/www/wwwroot/leilei-jf.czchunfang.com/
|
||||
# 更新方式:rsync 新 dist 到宿主机目录 → 无需重建镜像
|
||||
# =============================================================
|
||||
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 切换阿里云 Alpine 镜像源
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache tzdata \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
# Nginx 反代配置
|
||||
# API 请求代理到 single-front-api 容器(Docker 内网,不经宝塔 Nginx)
|
||||
RUN cat > /etc/nginx/conf.d/default.conf <<'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 前台 API(单点登录/商品/订单等)
|
||||
location /api/ {
|
||||
proxy_pass http://single-front-api:30033/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
EXPOSE 80
|
||||
37
deploy/docker/single-shop/nginx-admin-web.conf
Normal file
37
deploy/docker/single-shop/nginx-admin-web.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
# 仅供参考: 内容已内联到 admin-web.Dockerfile
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://single-admin-api:30032/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /adminapi/ {
|
||||
proxy_pass http://single-admin-api:30032/adminapi/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
30
deploy/docker/single-shop/nginx-h5.conf
Normal file
30
deploy/docker/single-shop/nginx-h5.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
# 仅供参考: 内容已内联到 h5.Dockerfile
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://single-front-api:30031/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
7
deploy/docker/ssl-cert/.gitignore
vendored
Normal file
7
deploy/docker/ssl-cert/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# 忽略证书文件,不入库
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cer
|
||||
*.p12
|
||||
*.pfx
|
||||
31
deploy/docker/step1-integral-byhlc112/.env.example
Normal file
31
deploy/docker/step1-integral-byhlc112/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# =============================================================
|
||||
# 步骤一:寄卖商城环境变量 — 宝应宏煜春商贸 byhlc112
|
||||
# cp .env.example .env 并填入真实密码
|
||||
# .env 不入库
|
||||
# =============================================================
|
||||
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ---------- Redis(容器内) ----------
|
||||
REDIS_PASSWORD=change-me-redis
|
||||
|
||||
# ---------- H5 对外域名(浏览器可达) ----------
|
||||
INTEGRAL_TITLE=宝应宏煜春商贸
|
||||
INTEGRAL_API_PUBLIC_URL=https://admin.h5y2c.com
|
||||
INTEGRAL_IMG_PUBLIC_URL=https://admin.h5y2c.com
|
||||
INTEGRAL_H5_PUBLIC_URL=https://h5y2c.com/
|
||||
INTEGRAL_SN_ID=17533260260529
|
||||
INTEGRAL_APP_STR=ZFyTNQTWEkCBbyhlc1120529
|
||||
INTEGRAL_CONTRACT_PAGE=10012
|
||||
|
||||
# ---------- 宿主机暴露端口 ----------
|
||||
INTEGRAL_H5_PORT=18080
|
||||
# webman API 直连端口(宝塔 Nginx admin.h5y2c.com → 此端口)
|
||||
RESELL_API_PORT=18085
|
||||
|
||||
# ---------- 宿主机目录映射(bind mount,与原部署路径一致)----------
|
||||
# 寄卖商城 H5 静态文件目录(手动改 JS/configs.js 直接生效,无需重建镜像)
|
||||
RESELL_H5_DIR=/www/wwwroot/h5y2c.com
|
||||
# webman 后台完整应用目录(FTP 上传新 webman.bin/public/ 后 restart 容器即可更新)
|
||||
# 上传图片、public/upload 等均包含在此目录内,无需单独挂载
|
||||
RESELL_HOUTAI_DIR=/www/wwwroot/admin.h5y2c.com
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user