Jelajahi Sumber

bug修复和功能优化

徐滕 3 minggu lalu
induk
melakukan
5d769d8041
44 mengubah file dengan 2287 tambahan dan 236 penghapusan
  1. 186 0
      README.md
  2. 1 1
      code-copilot/changes/hotel-alipay-secret-externalize/spec.md
  3. 368 0
      deploy-manual.sh
  4. 114 0
      deploy.sh
  5. 1 1
      docker/Dockerfile.admin
  6. 1 1
      docker/Dockerfile.flow
  7. 2 3
      docker/docker-compose.yml
  8. 5 3
      forge-admin-ui/package.json
  9. 71 56
      forge-admin-ui/pnpm-lock.yaml
  10. 36 8
      forge-admin-ui/src/App.vue
  11. 4 0
      forge-admin-ui/src/components/ai-form/AiCrudPage.vue
  12. 1 2
      forge-admin-ui/src/composables/useDict.js
  13. 86 31
      forge-admin-ui/src/composables/useWatermark.js
  14. 84 11
      forge-admin-ui/src/views/hotel/dish.vue
  15. 1 1
      forge-admin-ui/src/views/hotel/dishSpec.vue
  16. 13 4
      forge-admin-ui/src/views/hotel/order.vue
  17. 0 2
      forge-admin-ui/vite.config.js
  18. 16 75
      forge-h5-ui/src/pages/hotel/customer/menu.vue
  19. 16 0
      forge-h5-ui/src/store/modules/hotel-order.js
  20. 19 0
      forge-server/db/migration/V1.0.120__add_sys_normal_disable_dict.sql
  21. 11 0
      forge-server/db/migration/V1.0.121__add_dish_meal_type_field.sql
  22. 9 0
      forge-server/db/migration/V1.0.122__convert_dish_meal_type_to_english.sql
  23. 13 0
      forge-server/db/migration/V1.0.123__change_dish_status_to_numeric.sql
  24. 1 1
      forge-server/forge-admin-server/src/main/resources/application-dev.example.yml
  25. 2 2
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  26. 1 1
      forge-server/forge-app-server/src/main/resources/application-dev.example.yml
  27. 3 3
      forge-server/forge-app-server/src/main/resources/application-dev.yml
  28. 8 5
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java
  29. 4 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDish.java
  30. 1 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/DishBatchOperationDTO.java
  31. 4 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/HotelDishDTO.java
  32. 16 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishMapper.java
  33. 13 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishService.java
  34. 25 8
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishServiceImpl.java
  35. 4 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishVO.java
  36. 25 1
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishMapper.xml
  37. 11 11
      forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md
  38. 1 1
      forge-server/forge-business/forge-hotel/酒店订单支付系统开发文档.md
  39. 13 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/controller/SysDictDataController.java
  40. 3 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/mapper/SysDictDataMapper.java
  41. 2 0
      forge-server/forge-framework/forge-starter-parent/forge-starter-tenant/src/main/java/com/mdframe/forge/starter/tenant/config/TenantProperties.java
  42. 282 0
      forge-start.sh
  43. 430 0
      install-env.sh
  44. 380 0
      部署指南-手动部署.md

+ 186 - 0
README.md

@@ -483,6 +483,192 @@ mvn clean install -DskipTests
 
 生产环境 Nginx 配置参考 [NGINX_CONFIG.md](NGINX_CONFIG.md)。
 
+### 8. Docker 部署(推荐)
+
+```bash
+cd docker
+cp .env.example .env
+docker compose up -d
+```
+
+访问:http://localhost(前端)、http://localhost/doc.html(API 文档)
+
+默认账号:`admin` / `123456`
+
+### 9. 手动部署(生产环境)
+
+适用于无法使用 Docker 或需要更精细控制的场景。
+
+#### 环境准备
+
+```bash
+# CentOS / RHEL / Anolis OS
+# 1. 安装 JDK 17
+sudo yum install -y java-17-openjdk java-17-openjdk-devel
+
+# 2. 安装 Node.js 20
+curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
+sudo yum install -y nodejs
+npm install -g pnpm@8
+
+# 3. 安装 MySQL 8.0
+sudo yum install -y mysql-server
+sudo systemctl start mysqld
+sudo systemctl enable mysqld
+
+# 4. 安装 Redis
+sudo yum install -y redis
+sudo systemctl start redis
+sudo systemctl enable redis
+
+# 5. 安装 Nginx
+sudo yum install -y nginx
+sudo systemctl start nginx
+sudo systemctl enable nginx
+```
+
+#### 初始化数据库
+
+```bash
+# 创建数据库
+mysql -u root -p -e "CREATE DATABASE forge_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
+
+# 导入初始数据(使用项目提供的初始化脚本)
+bash forge/scripts/db/init-db.sh --host 127.0.0.1 --port 3306 --database forge_admin --user root --password 你的密码
+```
+
+#### 构建后端
+
+```bash
+cd forge-server
+mvn clean package -DskipTests -Pprod
+```
+
+构建产物:
+- `forge-admin-server/target/forge-admin-server.jar` - 主后台服务
+- `forge-flow/forge-flow-server/target/forge-flow-server.jar` - 流程服务
+
+#### 配置生产环境
+
+```bash
+# 复制配置模板
+cp forge/forge-admin-server/src/main/resources/application-dev.example.yml \
+   forge/forge-admin-server/src/main/resources/application-prod.yml
+
+# 编辑配置文件,修改数据库、Redis 等连接信息
+vim forge/forge-admin-server/src/main/resources/application-prod.yml
+```
+
+#### 启动服务
+
+```bash
+# 启动主后台服务
+nohup java -Xms512m -Xmx1024m \
+  -Dspring.profiles.active=prod \
+  -jar forge/forge-admin-server/target/forge-admin-server.jar \
+  > /var/log/forge-admin.log 2>&1 &
+
+# 启动流程服务(可选)
+nohup java -Xms256m -Xmx512m \
+  -Dspring.profiles.active=prod \
+  -jar forge/forge-flow/forge-flow-server/target/forge-flow-server.jar \
+  > /var/log/forge-flow.log 2>&1 &
+```
+
+#### 构建并部署前端
+
+```bash
+# 构建前端
+cd forge-admin-ui
+pnpm install
+pnpm build
+
+# 部署到 Nginx
+sudo cp -r dist/* /usr/share/nginx/html/forge/
+```
+
+#### 配置 Nginx
+
+```nginx
+server {
+    listen 80;
+    server_name your-domain.com;
+
+    # 前端静态文件
+    location /forge {
+        alias /usr/share/nginx/html/forge;
+        try_files $uri $uri/ /forge/index.html;
+    }
+
+    # 后端 API 代理
+    location /api {
+        proxy_pass http://localhost:8580;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+
+    # 流程服务 API 代理
+    location /flow {
+        proxy_pass http://localhost:8081;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+    }
+}
+```
+
+```bash
+# 测试配置并重启 Nginx
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+#### 服务管理脚本
+
+创建 `/opt/forge/start.sh`:
+
+```bash
+#!/bin/bash
+# 启动 forge-admin
+nohup java -Xms512m -Xmx1024m \
+  -Dspring.profiles.active=prod \
+  -jar /opt/forge/forge-admin-server.jar \
+  > /var/log/forge-admin.log 2>&1 &
+
+# 启动 forge-flow
+nohup java -Xms256m -Xmx512m \
+  -Dspring.profiles.active=prod \
+  -jar /opt/forge/forge-flow-server.jar \
+  > /var/log/forge-flow.log 2>&1 &
+
+echo "服务已启动"
+```
+
+创建 `/opt/forge/stop.sh`:
+
+```bash
+#!/bin/bash
+pkill -f forge-admin-server.jar
+pkill -f forge-flow-server.jar
+echo "服务已停止"
+```
+
+```bash
+sudo chmod +x /opt/forge/start.sh /opt/forge/stop.sh
+```
+
+#### 查看日志
+
+```bash
+# 实时查看日志
+tail -f /var/log/forge-admin.log
+tail -f /var/log/forge-flow.log
+
+# 查看 Nginx 日志
+tail -f /var/log/nginx/access.log
+tail -f /var/log/nginx/error.log
+```
+
 ---
 
 ## 📋 功能模块

+ 1 - 1
code-copilot/changes/hotel-alipay-secret-externalize/spec.md

@@ -181,7 +181,7 @@ hotel:
     private-key: ${FORGE_HOTEL_ALIPAY_PRIVATE_KEY:}
     alipay-public-key: ${FORGE_HOTEL_ALIPAY_PUBLIC_KEY:}
     gateway-url: ${FORGE_HOTEL_ALIPAY_GATEWAY_URL:https://openapi-sandbox.dl.alipaydev.com/gateway.do}
-    notify-url: ${FORGE_HOTEL_ALIPAY_NOTIFY_URL:http://pc948923.natappfree.cc/hotel/open/pay/notify}
+    notify-url: ${FORGE_HOTEL_ALIPAY_NOTIFY_URL:http://qbaa6e66.natappfree.cc/hotel/open/pay/notify}
     return-url: ${FORGE_HOTEL_ALIPAY_RETURN_URL:http://192.168.10.4:3001/#/pages/hotel/customer/pay}   # 仅 app-server
     sandbox: true
 ```

+ 368 - 0
deploy-manual.sh

@@ -0,0 +1,368 @@
+#!/bin/bash
+# ============================================
+# Forge Admin 手动部署脚本(16GB+ 内存优化版)
+# 适用系统: CentOS 7/8 / Anolis OS / RHEL
+# ============================================
+
+set -e
+
+# ========================
+# 配置变量(按需修改)
+# ========================
+MYSQL_PASSWORD="root"
+REDIS_PASSWORD="root"
+PROJECT_DIR="/mnt/project/forge_admin"
+DEPLOY_DIR="/opt/forge"
+GIT_REPO="http://3081089em4.wicp.vip/user5/forge_admin.git"
+
+echo "=========================================="
+echo "  Forge Admin 手动部署 - 开始"
+echo "  目标目录: ${DEPLOY_DIR}"
+echo "=========================================="
+
+# ========================
+# 1. 安装基础工具
+# ========================
+echo "[1/9] 安装基础工具..."
+sudo yum install -y wget curl git vim
+
+# ========================
+# 2. 安装 JDK 17 + Maven
+# ========================
+echo "[2/10] 安装 JDK 17 + Maven..."
+if java -version 2>&1 | grep -q "17"; then
+    echo "  ✓ JDK 17 已安装"
+else
+    sudo yum install -y java-17-openjdk java-17-openjdk-devel
+    echo "  ✓ JDK 17 安装完成"
+fi
+java -version
+
+# 安装 Maven
+if ! command -v mvn &> /dev/null; then
+    echo "  安装 Maven..."
+    # 方案1: yum 直接安装(Anolis/CentOS 自带)
+    set +e
+    sudo yum install -y maven 2>/dev/null
+    MAVEN_YUM=$?
+    set -e
+    
+    if [ $MAVEN_YUM -eq 0 ] && command -v mvn &> /dev/null; then
+        echo "  ✓ Maven 通过 yum 安装完成: $(mvn -version | head -1)"
+    else
+        # 方案2: 从国内镜像下载
+        echo "  yum 安装不可用,从镜像下载..."
+        set +e
+        curl -L --retry 3 --connect-timeout 30 \
+            https://mirrors.aliyun.com/apache/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz \
+            -o /tmp/maven.tar.gz
+        if [ ! -s /tmp/maven.tar.gz ]; then
+            curl -L --retry 3 --connect-timeout 30 \
+                https://mirrors.huaweicloud.com/apache/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz \
+                -o /tmp/maven.tar.gz
+        fi
+        set -e
+        
+        if [ -s /tmp/maven.tar.gz ]; then
+            sudo tar -xzf /tmp/maven.tar.gz -C /opt/
+            sudo ln -sf /opt/apache-maven-3.9.9/bin/mvn /usr/local/bin/mvn
+            rm -f /tmp/maven.tar.gz
+            echo "  ✓ Maven 安装完成: $(mvn -version | head -1)"
+        else
+            echo "  ✗ Maven 安装失败,请手动执行:"
+            echo "    sudo yum install -y maven"
+            exit 1
+        fi
+    fi
+else
+    echo "  ✓ Maven 已安装: $(mvn -version | head -1)"
+fi
+
+# ========================
+# 3. 安装 Node.js 20 + pnpm
+# ========================
+echo "[3/10] 安装 Node.js 20..."
+if command -v node &> /dev/null && node -v | grep -q "v20"; then
+    echo "  ✓ Node.js 20 已安装: $(node -v)"
+else
+    curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
+    sudo yum install -y nodejs
+    echo "  ✓ Node.js 安装完成: $(node -v)"
+fi
+
+if ! command -v pnpm &> /dev/null; then
+    npm install -g pnpm@8
+    echo "  ✓ pnpm 安装完成: $(pnpm -v)"
+else
+    echo "  ✓ pnpm 已安装: $(pnpm -v)"
+fi
+
+# ========================
+# 4. 安装 MySQL 8.0
+# ========================
+echo "[4/10] 安装 MySQL 8.0..."
+if command -v mysql &> /dev/null; then
+    echo "  ✓ MySQL 已安装: $(mysql --version)"
+else
+    sudo yum install -y mysql-server
+    echo "  ✓ MySQL 安装完成"
+fi
+
+sudo systemctl start mysqld
+sudo systemctl enable mysqld
+
+# 初始化 MySQL(设置 root 密码 + 创建数据库)
+echo "  配置 MySQL..."
+sudo mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '${MYSQL_PASSWORD}'; FLUSH PRIVILEGES;" 2>/dev/null || true
+
+sudo mysql -u root -p"${MYSQL_PASSWORD}" -e "CREATE DATABASE IF NOT EXISTS forge_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" 2>/dev/null || \
+sudo mysql -e "CREATE DATABASE IF NOT EXISTS forge_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
+echo "  ✓ 数据库 forge_admin 就绪"
+
+# ========================
+# 5. 安装 Redis
+# ========================
+echo "[5/10] 安装 Redis..."
+if command -v redis-server &> /dev/null; then
+    echo "  ✓ Redis 已安装"
+else
+    sudo yum install -y redis
+    echo "  ✓ Redis 安装完成"
+fi
+
+sudo systemctl start redis
+sudo systemctl enable redis
+
+# 设置 Redis 密码
+if ! grep -q "^requirepass" /etc/redis.conf 2>/dev/null; then
+    echo "requirepass ${REDIS_PASSWORD}" | sudo tee -a /etc/redis.conf
+    sudo systemctl restart redis
+fi
+echo "  ✓ Redis 启动完成"
+
+# ========================
+# 6. 安装 Nginx
+# ========================
+echo "[6/10] 安装 Nginx..."
+if command -v nginx &> /dev/null; then
+    echo "  ✓ Nginx 已安装"
+else
+    sudo yum install -y nginx
+    echo "  ✓ Nginx 安装完成"
+fi
+
+sudo systemctl start nginx
+sudo systemctl enable nginx
+echo "  ✓ Nginx 启动完成"
+
+# ========================
+# 7. 克隆代码
+# ========================
+echo "[7/10] 克隆项目代码..."
+sudo mkdir -p $(dirname ${PROJECT_DIR})
+cd $(dirname ${PROJECT_DIR})
+
+if [ -d "forge_admin" ]; then
+    echo "  项目目录已存在,更新代码..."
+    cd forge_admin
+    git pull
+else
+    git clone ${GIT_REPO}
+    cd forge_admin
+fi
+echo "  ✓ 代码就绪"
+
+# ========================
+# 8. 构建后端
+# ========================
+echo "[8/10] 构建后端(约 3-5 分钟)..."
+cd ${PROJECT_DIR}/forge-server
+
+# 创建生产配置文件
+mkdir -p ${PROJECT_DIR}/forge-server/forge-admin-server/src/main/resources
+cat > ${PROJECT_DIR}/forge-server/forge-admin-server/src/main/resources/application-prod.yml << EOF
+spring:
+  datasource:
+    type: com.zaxxer.hikari.HikariDataSource
+    dynamic:
+      primary: master
+      strict: true
+      datasource:
+        master:
+          type: \${spring.datasource.type}
+          driverClassName: com.mysql.cj.jdbc.Driver
+          url: jdbc:mysql://localhost:3306/forge_admin?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
+          username: root
+          password: ${MYSQL_PASSWORD}
+      hikari:
+        maxPoolSize: 20
+        minIdle: 10
+        connectionTimeout: 30000
+        validationTimeout: 5000
+        idleTimeout: 600000
+        maxLifetime: 1800000
+        keepaliveTime: 30000
+
+spring.data:
+  redis:
+    host: localhost
+    port: 6379
+    database: 0
+    password: ${REDIS_PASSWORD}
+    timeout: 10s
+    ssl.enabled: false
+    redisson:
+      config: |
+        singleServerConfig:
+          address: "redis://localhost:6379"
+          database: 0
+          password: ${REDIS_PASSWORD}
+          connectTimeout: 10000
+          timeout: 3000
+          retryAttempts: 3
+          retryInterval: 1500
+          connectionPoolSize: 64
+          connectionMinimumIdleSize: 10
+        threads: 16
+        nettyThreads: 32
+
+hotel:
+  qr:
+    base-url: http://localhost
+    path: "/#/pages/hotel/scan-bind"
+  pay:
+    mock-enabled: true
+EOF
+
+# 构建
+mvn clean package -DskipTests -Pprod
+echo "  ✓ 后端构建完成"
+
+# ========================
+# 9. 构建前端
+# ========================
+echo "[9/10] 构建前端(约 2-3 分钟)..."
+cd ${PROJECT_DIR}/forge-admin-ui
+pnpm install
+pnpm build
+echo "  ✓ 前端构建完成"
+
+# ========================
+# 部署文件
+# ========================
+echo ""
+echo "=========================================="
+echo "  部署文件到 ${DEPLOY_DIR}"
+echo "=========================================="
+
+sudo mkdir -p ${DEPLOY_DIR}
+
+# 复制后端 jar
+sudo cp ${PROJECT_DIR}/forge-server/forge-admin-server/target/forge-admin-server.jar ${DEPLOY_DIR}/
+sudo cp ${PROJECT_DIR}/forge-server/forge-flow/forge-flow-server/target/forge-flow-server.jar ${DEPLOY_DIR}/
+
+# 复制前端
+sudo mkdir -p ${DEPLOY_DIR}/frontend
+sudo cp -r ${PROJECT_DIR}/forge-admin-ui/dist/* ${DEPLOY_DIR}/frontend/
+
+# 配置 Nginx
+sudo cat > /etc/nginx/conf.d/forge.conf << 'NGINXEOF'
+server {
+    listen 80;
+    server_name _;
+
+    # 前端静态文件
+    location /forge {
+        alias /opt/forge/frontend;
+        try_files $uri $uri/ /forge/index.html;
+    }
+
+    # 根路径重定向到 /forge
+    location = / {
+        return 302 /forge;
+    }
+
+    # 后端 API 代理
+    location /api {
+        proxy_pass http://localhost:8580;
+        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;
+    }
+
+    # 流程服务 API 代理
+    location /flow {
+        proxy_pass http://localhost:8081;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+}
+NGINXEOF
+
+sudo nginx -t
+sudo systemctl reload nginx
+echo "  ✓ Nginx 配置完成"
+
+# 创建启动脚本
+sudo cat > ${DEPLOY_DIR}/start.sh << STARTEOF
+#!/bin/bash
+echo "启动 Forge Admin 服务..."
+
+# 启动 forge-admin(16GB 内存给 1GB)
+nohup java -Xms512m -Xmx1024m \
+  -Dspring.profiles.active=prod \
+  -jar ${DEPLOY_DIR}/forge-admin-server.jar > /var/log/forge-admin.log 2>&1 &
+echo "  forge-admin 启动中... PID: \$!"
+
+# 启动 forge-flow
+nohup java -Xms256m -Xmx512m \
+  -Dspring.profiles.active=prod \
+  -jar ${DEPLOY_DIR}/forge-flow-server.jar > /var/log/forge-flow.log 2>&1 &
+echo "  forge-flow 启动中... PID: \$!"
+
+echo ""
+echo "等待服务启动..."
+sleep 15
+echo "查看日志: tail -f /var/log/forge-admin.log"
+echo "访问地址: http://localhost/forge"
+STARTEOF
+sudo chmod +x ${DEPLOY_DIR}/start.sh
+
+# 创建停止脚本
+sudo cat > ${DEPLOY_DIR}/stop.sh << 'STOPEOF'
+#!/bin/bash
+echo "停止 Forge Admin 服务..."
+pkill -f forge-admin-server.jar && echo "  forge-admin 已停止" || echo "  forge-admin 未运行"
+pkill -f forge-flow-server.jar && echo "  forge-flow 已停止" || echo "  forge-flow 未运行"
+STOPEOF
+sudo chmod +x ${DEPLOY_DIR}/stop.sh
+
+# 创建重启脚本
+sudo cat > ${DEPLOY_DIR}/restart.sh << 'RESTARTEOF'
+#!/bin/bash
+bash /opt/forge/stop.sh
+sleep 3
+bash /opt/forge/start.sh
+RESTARTEOF
+sudo chmod +x ${DEPLOY_DIR}/restart.sh
+
+echo ""
+echo "=========================================="
+echo "  ✅ 部署完成!"
+echo "=========================================="
+echo ""
+echo "  启动服务:  sudo ${DEPLOY_DIR}/start.sh"
+echo "  停止服务:  sudo ${DEPLOY_DIR}/stop.sh"
+echo "  重启服务:  sudo ${DEPLOY_DIR}/restart.sh"
+echo ""
+echo "  查看日志:"
+echo "    tail -f /var/log/forge-admin.log"
+echo "    tail -f /var/log/forge-flow.log"
+echo ""
+echo "  访问地址: http://localhost/forge"
+echo "  账号: admin"
+echo "  密码: 123456"
+echo ""
+echo "=========================================="

+ 114 - 0
deploy.sh

@@ -0,0 +1,114 @@
+#!/bin/bash
+# ============================================
+# Forge Admin 一键部署脚本(空服务器专用)
+# 适用系统: CentOS 7/8 / Anolis OS / RHEL
+# 使用方法: 复制全部内容粘贴到服务器终端执行
+# ============================================
+
+set -e
+
+echo "=========================================="
+echo "  Forge Admin 一键部署 - 开始"
+echo "=========================================="
+
+# ========================
+# 1. 创建 2G Swap 防 OOM
+# ========================
+echo "[1/6] 创建 Swap 分区..."
+if [ ! -f /swapfile ]; then
+    sudo fallocate -l 2G /swapfile
+    sudo chmod 600 /swapfile
+    sudo mkswap /swapfile
+    sudo swapon /swapfile
+    echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
+    echo "  ✓ Swap 创建成功"
+else
+    echo "  ✓ Swap 已存在,跳过"
+fi
+free -h | grep Swap
+
+# ========================
+# 2. 安装 Docker
+# ========================
+echo "[2/6] 安装 Docker..."
+if command -v docker &> /dev/null; then
+    echo "  ✓ Docker 已安装: $(docker --version)"
+else
+    sudo yum install -y yum-utils
+    sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
+    sudo yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
+    sudo systemctl start docker
+    sudo systemctl enable docker
+    echo "  ✓ Docker 安装完成: $(docker --version)"
+fi
+
+# ========================
+# 3. 安装 Git
+# ========================
+echo "[3/6] 安装 Git..."
+if command -v git &> /dev/null; then
+    echo "  ✓ Git 已安装: $(git --version)"
+else
+    sudo yum install -y git
+    echo "  ✓ Git 安装完成"
+fi
+
+# ========================
+# 4. 克隆代码到 /mnt/project
+# ========================
+echo "[4/6] 克隆项目代码到 /mnt/project..."
+sudo mkdir -p /mnt/project
+cd /mnt/project
+
+if [ -d "forge_admin" ]; then
+    echo "  ✓ 项目目录已存在,检查是否为 git 仓库..."
+    cd forge_admin
+    if [ -d ".git" ]; then
+        echo "  ✓ 是 git 仓库,执行 git pull 更新..."
+        git pull
+    else
+        echo "  ✗ 不是 git 仓库,删除后重新克隆..."
+        cd /mnt/project
+        rm -rf forge_admin
+        git clone http://3081089em4.wicp.vip/user5/forge_admin.git
+        cd forge_admin
+        echo "  ✓ 代码克隆完成"
+    fi
+else
+    git clone http://3081089em4.wicp.vip/user5/forge_admin.git
+    cd forge_admin
+    echo "  ✓ 代码克隆完成"
+fi
+
+# ========================
+# 5. 配置环境变量
+# ========================
+echo "[5/6] 配置环境变量..."
+cd /mnt/project/forge_admin/docker
+if [ ! -f .env ]; then
+    cp .env.example .env
+    echo "  ✓ .env 已创建(使用默认配置)"
+else
+    echo "  ✓ .env 已存在,跳过"
+fi
+
+# ========================
+# 6. 构建并启动
+# ========================
+echo "[6/6] 构建并启动服务(首次构建约 15-20 分钟,请耐心等待)..."
+docker compose up -d --build
+
+echo ""
+echo "=========================================="
+echo "  部署完成!"
+echo "=========================================="
+echo ""
+echo "查看服务状态:  docker compose ps"
+echo "查看启动日志:  docker compose logs -f"
+echo ""
+echo "访问地址: http://$(curl -s ifconfig.me)/forge"
+echo "账号: admin"
+echo "密码: 123456"
+echo ""
+echo "⚠️  确保阿里云安全组已放行 80 端口"
+echo "=========================================="

+ 1 - 1
docker/Dockerfile.admin

@@ -11,7 +11,7 @@ FROM eclipse-temurin:17-jre
 WORKDIR /app
 COPY --from=builder /build/forge-admin-server/target/forge-admin-server.jar app.jar
 
-ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseSerialGC"
+ENV JAVA_OPTS="-Xms256m -Xmx512m"
 ENV SPRING_PROFILES_ACTIVE=prod
 ENV MYSQL_HOST=mysql
 ENV MYSQL_PORT=3306

+ 1 - 1
docker/Dockerfile.flow

@@ -11,7 +11,7 @@ FROM eclipse-temurin:17-jre
 WORKDIR /app
 COPY --from=builder /build/forge-flow/forge-flow-server/target/forge-flow-server.jar app.jar
 
-ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseSerialGC"
+ENV JAVA_OPTS="-Xms256m -Xmx512m"
 ENV SPRING_PROFILES_ACTIVE=prod
 ENV MYSQL_HOST=mysql
 ENV MYSQL_PORT=3306

+ 2 - 3
docker/docker-compose.yml

@@ -25,7 +25,6 @@ services:
       - "${MYSQL_PORT:-3306}:3306"
     volumes:
       - mysql_data:/var/lib/mysql
-      - ../forge/forge-admin/sql:/初始化脚本.sql
     command: >
       --character-set-server=utf8mb4
       --collation-server=utf8mb4_unicode_ci
@@ -85,7 +84,7 @@ services:
       REDIS_HOST: redis
       REDIS_PORT: 6379
       REDIS_PASSWORD: ${REDIS_PASSWORD:-forge123456}
-      JAVA_OPTS: -Xms128m -Xmx256m -XX:+UseSerialGC
+      JAVA_OPTS: -Xms256m -Xmx512m
     ports:
       - "${FLOW_PORT:-8081}:8081"
     networks:
@@ -127,7 +126,7 @@ services:
       FORGE_CRYPTO_PERSISTENCE_LEGACY_KEY: "${FORGE_CRYPTO_PERSISTENCE_LEGACY_KEY:-}"
       FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY_ID: "${FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY_ID:-}"
       FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY: "${FORGE_CRYPTO_PERSISTENCE_ACTIVE_KEY:-}"
-      JAVA_OPTS: -Xms128m -Xmx256m -XX:+UseSerialGC
+      JAVA_OPTS: -Xms256m -Xmx512m
     volumes:
       - crypto_secrets:/var/lib/forge/secrets
     ports:

+ 5 - 3
forge-admin-ui/package.json

@@ -5,15 +5,16 @@
   "private": true,
   "scripts": {
     "dev": "vite",
-    "build": "NODE_OPTIONS=--max-old-space-size=40961 vite build",
+    "build": "cross-env NODE_OPTIONS=--max-old-space-size=40961 vite build",
     "preview": "vite preview",
     "lint:fix": "eslint --fix",
     "test": "vitest run",
     "test:watch": "vitest",
     "test:ui": "vitest --ui",
     "up": "taze major -I",
-    "build:stage": "NODE_OPTIONS=--max-old-space-size=40961 vite build && npm run deploy:stage",
-    "deploy:stage": "esno deploy/index.js"
+    "build:stage": "cross-env NODE_OPTIONS=--max-old-space-size=40961 vite build && npm run deploy:stage",
+    "deploy:stage": "esno deploy/index.js",
+    "postinstall": "node -e \"try{const fs=require('fs'),p='node_modules/vue3-barcode/package.json',j=JSON.parse(fs.readFileSync(p,'utf8'));if(!j.exports['.'].default){j.exports['.'].default='./dist/vue3-barcode.es.js';fs.writeFileSync(p,JSON.stringify(j,null,2));console.log('[postinstall] vue3-barcode exports patched')}catch(e){}\""
   },
   "dependencies": {
     "@arco-design/color": "^0.4.0",
@@ -81,6 +82,7 @@
     "@vitejs/plugin-vue-jsx": "^5.1.0",
     "@vitest/ui": "^2.1.9",
     "@vue/test-utils": "^2.4.6",
+    "cross-env": "^10.1.0",
     "eslint": "^9.34.0",
     "eslint-plugin-format": "^1.0.1",
     "esno": "^4.8.0",

File diff ditekan karena terlalu besar
+ 71 - 56
forge-admin-ui/pnpm-lock.yaml


+ 36 - 8
forge-admin-ui/src/App.vue

@@ -32,8 +32,12 @@
           <LayoutSetting v-if="layoutSettingVisible" class="fixed right-12 top-1/2 z-999" />
         </router-view>
 
-        <!-- 全局水印 -->
-        <div v-if="watermarkConfig.enable" class="watermark-layer" :style="watermarkStyle" />
+        <!-- 全局水印(canvas 原地重绘,不产生 Network 记录) -->
+        <canvas
+          ref="watermarkCanvasRef"
+          class="watermark-layer"
+          :style="watermarkStyle"
+        />
         <GlobalLoadingOverlay />
       </n-message-provider>
     </n-dialog-provider>
@@ -42,7 +46,7 @@
 
 <script setup>
 import { darkTheme, dateZhCN, zhCN } from 'naive-ui'
-import { computed, defineAsyncComponent, markRaw, onMounted, shallowRef, watch } from 'vue'
+import { computed, defineAsyncComponent, markRaw, nextTick, onMounted, onUnmounted, ref, shallowRef, watch, watchEffect } from 'vue'
 // 初始化响应式字体功能
 import { useRoute } from 'vue-router'
 import { LayoutSetting } from '@/components'
@@ -127,10 +131,19 @@ const keepAliveNames = computed(() => {
 })
 
 // 使用水印功能
-const { watermarkConfig, getWatermarkStyle } = useWatermark()
-
-// 水印样式
+const { watermarkConfig, getWatermarkStyle, setCanvas } = useWatermark()
 const watermarkStyle = computed(() => getWatermarkStyle())
+const watermarkCanvasRef = ref(null)
+
+/** 水印 canvas 绑定:始终渲染,通过样式控制显隐;配置加载后用 nextTick 确保 DOM 就绪 */
+function bindWatermarkCanvas() {
+  nextTick(() => {
+    if (watermarkCanvasRef.value) {
+      setCanvas(watermarkCanvasRef.value)
+    }
+  })
+}
+
 onMounted(() => {
   initResponsiveFont(() => {
     // 更新Naive UI主题配置,使组件库字体也响应式变化
@@ -138,6 +151,21 @@ onMounted(() => {
       fontSize: `calc(14px * var(--font-scale, 1))`,
     })
   })
+  bindWatermarkCanvas()
+})
+
+// 配置异步加载后 enable 变为 true,canvas 从无到有,需要重新绑定
+watch(() => watermarkConfig.value.enable, (enabled) => {
+  if (enabled) {
+    bindWatermarkCanvas()
+  }
+  else {
+    setCanvas(null)
+  }
+})
+
+onUnmounted(() => {
+  setCanvas(null)
 })
 
 // 监听主题变化
@@ -226,8 +254,8 @@ watchEffect(() => {
   position: fixed;
   top: 0;
   left: 0;
-  width: 100%;
-  height: 100%;
+  width: 100vw;
+  height: 100vh;
   pointer-events: none;
   z-index: 1000;
 }

+ 4 - 0
forge-admin-ui/src/components/ai-form/AiCrudPage.vue

@@ -4981,6 +4981,10 @@ watch(() => stableSerialize(props.publicQuery || {}), () => {
   width: 100%;
 }
 
+.ai-crud-edit-form :deep(.ai-form-control--switch > *) {
+  width: auto;
+}
+
 .ai-crud-edit-form :deep(.ai-form-control--radio),
 .ai-crud-edit-form :deep(.ai-form-control--radioButton),
 .ai-crud-edit-form :deep(.ai-form-control--checkbox) {

+ 1 - 2
forge-admin-ui/src/composables/useDict.js

@@ -26,8 +26,7 @@ const dictPendingCache = new Map()
  */
 async function loadDictData(dictType) {
   try {
-    const res = await request.get('/system/dict/data/list', {
-      params: { dictType },
+    const res = await request.get(`/system/dict/data/type/${dictType}`, {
       needTip: false,
     })
 

+ 86 - 31
forge-admin-ui/src/composables/useWatermark.js

@@ -77,9 +77,9 @@ function formatDate(date, format) {
 }
 
 /**
- * 创建水印
+ * 创建单个水印瓦片 canvas(不转 dataURL,直接返回 canvas 供平铺绘制)
  */
-function createWatermarkCanvas(config) {
+function createWatermarkTile(config) {
   const canvas = document.createElement('canvas')
   const ctx = canvas.getContext('2d')
 
@@ -133,7 +133,7 @@ function createWatermarkCanvas(config) {
 
   ctx.restore()
 
-  return canvas.toDataURL('image/png')
+  return canvas
 }
 
 /**
@@ -160,8 +160,13 @@ export function useWatermark() {
   const userStore = useUserStore()
   const authStore = useAuthStore()
   const watermarkConfig = ref({ ...defaultConfig })
-  const watermarkUrl = ref('')
   let refreshTimer = null
+  // 全屏 canvas 元素引用(由 App.vue 传入)
+  let overlayCanvas = null
+  let overlayCtx = null
+  // 缓存单个瓦片 canvas,仅当内容/配置变化时重新创建
+  let tileCanvas = null
+  let tileKey = ''
 
   // 计算实际显示的水印内容
   const displayContent = computed(() => {
@@ -198,29 +203,53 @@ export function useWatermark() {
   }
 
   /**
-   * 更新水印
+   * 更新水印(在 canvas 上原地重绘,不产生 Network 记录)
    */
   const updateWatermark = () => {
-    if (!watermarkConfig.value.enable) {
-      watermarkUrl.value = ''
+    if (!watermarkConfig.value.enable || !overlayCtx) {
       return
     }
 
-    // 如果没有显示内容,不显示水印
+    // 如果没有显示内容,清空画布
     const content = displayContent.value
     if (!content) {
-      watermarkUrl.value = ''
+      overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
+      tileCanvas = null
+      tileKey = ''
       return
     }
 
-    // 创建带实际显示内容的水印配置
-    const configWithContent = {
-      ...watermarkConfig.value,
-      content,
+    // 生成瓦片唯一 key(内容+配置+时间戳,每秒变化会重建瓦片)
+    const timestamp = watermarkConfig.value.showTimestamp
+      ? formatDate(new Date(), watermarkConfig.value.timestampFormat)
+      : ''
+    const newTileKey = `${content}|${timestamp}|${watermarkConfig.value.fontSize}|${watermarkConfig.value.fontColor}|${watermarkConfig.value.opacity}|${watermarkConfig.value.rotate}|${watermarkConfig.value.gapX}|${watermarkConfig.value.gapY}`
+
+    // 瓦片 key 变化时重建(时间戳每秒变化,瓦片随之重建 — canvas 原地重绘无网络开销)
+    if (newTileKey !== tileKey) {
+      const configWithContent = {
+        ...watermarkConfig.value,
+        content: timestamp ? `${content}\n${timestamp}` : content,
+        showTimestamp: false, // 时间戳已拼入 content,禁止 createWatermarkTile 再添加一次
+      }
+      tileCanvas = createWatermarkTile(configWithContent)
+      tileKey = newTileKey
     }
 
-    const dataUrl = createWatermarkCanvas(configWithContent)
-    watermarkUrl.value = dataUrl
+    // 在全屏 canvas 上平铺瓦片
+    const w = overlayCanvas.width
+    const h = overlayCanvas.height
+    overlayCtx.clearRect(0, 0, w, h)
+
+    if (tileCanvas) {
+      const tw = tileCanvas.width
+      const th = tileCanvas.height
+      for (let x = 0; x < w; x += tw) {
+        for (let y = 0; y < h; y += th) {
+          overlayCtx.drawImage(tileCanvas, x, y)
+        }
+      }
+    }
   }
 
   /**
@@ -231,7 +260,7 @@ export function useWatermark() {
     if (watermarkConfig.value.enable && watermarkConfig.value.showTimestamp) {
       refreshTimer = setInterval(() => {
         updateWatermark()
-      }, 1000) // 每秒刷新一次
+      }, 1000) // 每秒刷新时间戳(canvas 原地重绘,不产生 Network 记录)
     }
   }
 
@@ -246,24 +275,50 @@ export function useWatermark() {
   }
 
   /**
-   * 获取水印样式
+   * 设置全屏 canvas 元素(由 App.vue 挂载后调用)
    */
-  const getWatermarkStyle = () => {
-    if (!watermarkConfig.value.enable || !watermarkUrl.value) {
-      return {}
+  const setCanvas = (canvasEl) => {
+    overlayCanvas = canvasEl
+    if (canvasEl) {
+      overlayCtx = canvasEl.getContext('2d')
+      // 同步 canvas 尺寸到容器(display:none 时尺寸为 0,等可见后再绘制)
+      const rect = canvasEl.parentElement?.getBoundingClientRect()
+      if (rect && rect.width > 0 && rect.height > 0) {
+        canvasEl.width = rect.width
+        canvasEl.height = rect.height
+        updateWatermark()
+      }
+    }
+    else {
+      overlayCtx = null
     }
+  }
+
+  /**
+   * 获取水印样式(返回稳定引用,禁用时 display:none 隐藏但保留 DOM)
+   */
+  const hiddenStyle = { display: 'none' }
+  let visibleStyle = null
+  let visibleStyleZIndex = -1
 
-    return {
-      position: 'fixed',
-      top: 0,
-      left: 0,
-      width: '100%',
-      height: '100%',
-      pointerEvents: 'none',
-      zIndex: watermarkConfig.value.zIndex,
-      backgroundImage: `url(${watermarkUrl.value})`,
-      backgroundRepeat: 'repeat',
+  const getWatermarkStyle = () => {
+    if (!watermarkConfig.value.enable) {
+      return hiddenStyle
+    }
+    // 仅在 zIndex 变化时创建新样式对象
+    if (!visibleStyle || watermarkConfig.value.zIndex !== visibleStyleZIndex) {
+      visibleStyle = {
+        position: 'fixed',
+        top: 0,
+        left: 0,
+        width: '100vw',
+        height: '100vh',
+        pointerEvents: 'none',
+        zIndex: watermarkConfig.value.zIndex,
+      }
+      visibleStyleZIndex = watermarkConfig.value.zIndex
     }
+    return visibleStyle
   }
 
   // 监听配置变化
@@ -299,11 +354,11 @@ export function useWatermark() {
 
   return {
     watermarkConfig,
-    watermarkUrl,
     displayContent,
     loadWatermarkConfig,
     updateWatermark,
     getWatermarkStyle,
+    setCanvas,
     // 注册自定义内容解析器
     registerContentParser: (code, parser) => {
       contentParsers[code] = parser

+ 84 - 11
forge-admin-ui/src/views/hotel/dish.vue

@@ -14,6 +14,9 @@
       :search-schema="searchSchema"
       :columns="tableColumns"
       :edit-schema="editSchema"
+      :before-submit="beforeSubmit"
+      :before-render-form="beforeRenderForm"
+      :before-render-detail="beforeRenderDetail"
       row-key="id"
       add-button-text="新增菜品"
     />
@@ -217,18 +220,70 @@ loadCategories()
 
 // 状态选项
 const dishStatusOptions = [
-  { label: '在售', value: 'ON_SALE' },
-  { label: '下架', value: 'OFF_SHELF' },
+  { label: '在售', value: '1' },
+  { label: '下架', value: '0' },
 ]
 
+// 餐段类型选项
+const mealTypeOptions = [
+  { label: '早餐', value: 'breakfast' },
+  { label: '午餐', value: 'lunch' },
+  { label: '晚餐', value: 'dinner' },
+  { label: '夜宵', value: 'night' },
+]
+
+// 餐段类型标签映射
+const mealTypeLabelMap = {
+  breakfast: '早餐',
+  lunch: '午餐',
+  dinner: '晚餐',
+  night: '夜宵',
+}
+
+/** 将逗号分隔的 mealType 字符串转为标签文本 */
+function formatMealType(mealType) {
+  if (!mealType) return '全天'
+  return mealType.split(',').map(t => mealTypeLabelMap[t] || t).join('、')
+}
+
+/** 提交前钩子:将 mealType 数组转为逗号分隔字符串 */
+function beforeSubmit(data) {
+  if (Array.isArray(data.mealType)) {
+    data.mealType = data.mealType.length > 0 ? data.mealType.join(',') : null
+  }
+  return data
+}
+
+/** 详情渲染前钩子:将 mealType 逗号分隔字符串转为数组(loadDetailOnEdit 场景) */
+function beforeRenderDetail(data) {
+  if (data && typeof data.mealType === 'string') {
+    data.mealType = data.mealType ? data.mealType.split(',') : []
+  }
+  else if (data && !data.mealType) {
+    data.mealType = []
+  }
+  return data
+}
+
+/** 表单渲染前钩子:将 mealType 逗号分隔字符串转为数组(非 loadDetailOnEdit 场景) */
+function beforeRenderForm(data) {
+  if (data && typeof data.mealType === 'string') {
+    data.mealType = data.mealType ? data.mealType.split(',') : []
+  }
+  else if (data && !data.mealType) {
+    data.mealType = []
+  }
+  return data
+}
+
 // 状态颜色映射
 const statusColorMap = {
-  ON_SALE: 'success',
-  OFF_SHELF: 'default',
+  '1': 'success',
+  '0': 'default',
 }
 const statusLabelMap = {
-  ON_SALE: '在售',
-  OFF_SHELF: '下架',
+  '1': '在售',
+  '0': '下架',
 }
 
 // 搜索表单配置
@@ -272,6 +327,12 @@ const tableColumns = computed(() => [
     },
   },
   { prop: 'categoryName', label: '分类', width: 120 },
+  {
+    prop: 'mealType',
+    label: '适用餐段',
+    width: 150,
+    render: row => h('span', {}, formatMealType(row.mealType)),
+  },
   { prop: 'price', label: '价格(元)', width: 100 },
   { prop: 'sales', label: '月销', width: 80 },
   {
@@ -300,18 +361,18 @@ const tableColumns = computed(() => [
     render: (row) => {
       const actions = []
       // 编辑(仅下架状态可编辑)
-      if (row.status === 'OFF_SHELF') {
+      if (row.status === '0') {
         actions.push(h('a', {
           class: 'text-primary cursor-pointer hover:opacity-80',
           onClick: () => crudRef.value?.handleEdit(row),
         }, '编辑'))
       }
       // 上架/下架
-      if (row.status === 'ON_SALE') {
+      if (row.status === '1') {
         actions.push(h('a', {
           class: 'text-warning cursor-pointer hover:opacity-80',
           onClick: async () => {
-            await updateDishStatus(row.id, 'OFF_SHELF')
+            await updateDishStatus(row.id, '0')
             crudRef.value?.refresh()
           },
         }, '下架'))
@@ -320,7 +381,7 @@ const tableColumns = computed(() => [
         actions.push(h('a', {
           class: 'text-success cursor-pointer hover:opacity-80',
           onClick: async () => {
-            await updateDishStatus(row.id, 'ON_SALE')
+            await updateDishStatus(row.id, '1')
             crudRef.value?.refresh()
           },
         }, '上架'))
@@ -375,6 +436,18 @@ const editSchema = computed(() => [
     props: { placeholder: '请选择分类', options: categoryOptions.value },
   },
   {
+    field: 'mealType',
+    label: '适用餐段',
+    type: 'select',
+    defaultValue: null,
+    props: { 
+      placeholder: '多选餐段,不选则全天可用', 
+      options: mealTypeOptions,
+      multiple: true,
+      clearable: true,
+    },
+  },
+  {
     field: 'price',
     label: '价格(元)',
     type: 'inputNumber',
@@ -427,7 +500,7 @@ const editSchema = computed(() => [
     field: 'status',
     label: '状态',
     type: 'radio',
-    defaultValue: 'ON_SALE',
+    defaultValue: '1',
     props: { options: dishStatusOptions },
   },
   {

+ 1 - 1
forge-admin-ui/src/views/hotel/dishSpec.vue

@@ -112,7 +112,7 @@ const dishOptions = ref([])
 
 async function loadDishOptions() {
   try {
-    const res = await getDishPage({ pageNum: 1, pageSize: 10000, status: 'ON_SALE' })
+    const res = await getDishPage({ pageNum: 1, pageSize: 10000, status: '1' })
     if (res.code === 200) {
       dishOptions.value = (res.data?.records || []).map(d => ({
         label: d.name,

+ 13 - 4
forge-admin-ui/src/views/hotel/order.vue

@@ -307,14 +307,23 @@ const searchSchema = computed(() => [
   },
 ])
 
+/** 时间戳或字符串 → yyyy-MM-dd */
+function toDateString(val) {
+  if (typeof val === 'number') {
+    const d = new Date(val)
+    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+  }
+  return val
+}
+
 /** 搜索参数转换:daterange 数组 → createTimeStart/createTimeEnd */
 function transformSearchParams(params) {
   if (params.createTime && Array.isArray(params.createTime) && params.createTime.length === 2) {
-    params.createTimeStart = params.createTime[0]
-    params.createTimeEnd = params.createTime[1]
+    params.createTimeStart = toDateString(params.createTime[0])
+    params.createTimeEnd = toDateString(params.createTime[1])
     // 同步看板日期
-    dashboardDate.value = params.createTime[0]
-    loadDashboard(params.createTime[0])
+    dashboardDate.value = params.createTimeStart
+    loadDashboard(params.createTimeStart)
   }
   else {
     // 无日期筛选时默认查今天,同步看板

+ 0 - 2
forge-admin-ui/vite.config.js

@@ -55,8 +55,6 @@ export default defineConfig(({ mode, command }) => {
       alias: {
         '@': path.resolve(process.cwd(), 'src'),
         '~': path.resolve(process.cwd()),
-        // vue3-barcode 的 exports 缺少 default 条件,Vite 7 无法解析,手动指向 ES 文件
-        'vue3-barcode': path.resolve(process.cwd(), 'node_modules/vue3-barcode/dist/vue3-barcode.es.js'),
       },
     },
     define: {

+ 16 - 75
forge-h5-ui/src/pages/hotel/customer/menu.vue

@@ -31,24 +31,6 @@
       />
     </view>
 
-    <!-- 分类标签 -->
-    <scroll-view scroll-x class="m-cat-scroll" :show-scrollbar="false">
-      <view class="m-cat-inner">
-        <view
-          class="m-cat-tag"
-          :class="{ 'm-cat-tag--active': activeCatId === 'all' }"
-          @click="changeCategory('all')"
-        >全部</view>
-        <view
-          v-for="cat in categoryList"
-          :key="cat.id"
-          class="m-cat-tag"
-          :class="{ 'm-cat-tag--active': activeCatId === cat.id }"
-          @click="changeCategory(cat.id)"
-        >{{ cat.icon ? cat.icon + ' ' : '' }}{{ cat.name }}</view>
-      </view>
-    </scroll-view>
-
     <!-- 菜品列表 -->
     <scroll-view scroll-y class="m-dish-scroll" :show-scrollbar="false">
       <view v-if="dishLoading" class="m-loading">
@@ -78,7 +60,7 @@
           v-for="dish in filteredDishes"
           :key="dish.id"
           class="m-dish-card"
-          :class="{ 'm-dish-card--soldout': dish.soldOut === 1 || dish.status !== 'ON_SALE' }"
+          :class="{ 'm-dish-card--soldout': dish.soldOut === 1 || dish.status !== '1' }"
           @click="goToDetail(dish)"
         >
           <view class="m-dish-img">
@@ -93,7 +75,7 @@
                 <text class="m-dish-yen">¥</text>{{ dish.price }}
               </view>
               <view
-                v-if="dish.soldOut === 1 || dish.status !== 'ON_SALE'"
+                v-if="dish.soldOut === 1 || dish.status !== '1'"
                 class="m-dish-soldout"
               >已售罄</view>
               <view
@@ -133,7 +115,7 @@
 </template>
 
 <script setup>
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
 import { useHotelOrderStore } from '@/store'
 import HotelTabBar from '@/components/hotel/HotelTabBar.vue'
 import api from '@/api'
@@ -147,10 +129,8 @@ const deliveryFee = computed(() => orderStore.hotelConfig.deliveryFee || 0)
 const deliveryTime = computed(() => orderStore.hotelConfig.deliveryTime || '25-35')
 const recommendText = ref('')
 
-// 搜索 & 分类
+// 搜索
 const keyword = ref('')
-const activeCatId = ref('all')
-const categoryList = ref([])
 
 // 菜品
 const dishList = ref([])
@@ -179,21 +159,17 @@ const filteredDishes = computed(() => {
 })
 
 onMounted(() => {
-  loadCategories()
-  loadDishes()
   loadBusinessHours()
   loadPauseStatus()
+  loadDishes()
 })
 
-/** 加载分类 */
-async function loadCategories() {
-  try {
-    const res = await api.hotelCategoryEnabled(orderStore.tenantId)
-    categoryList.value = res?.data || res || []
-  } catch (e) {
-    console.error('加载分类失败:', e)
+// 营业时段加载后,currentMealType 变化时重新拉取菜品
+watch(() => orderStore.currentMealType, (newVal, oldVal) => {
+  if (newVal !== oldVal && newVal != null) {
+    loadDishes()
   }
-}
+})
 
 /** 加载营业状态 */
 async function loadBusinessHours() {
@@ -224,13 +200,14 @@ async function loadPauseStatus() {
   }
 }
 
-/** 加载菜品 */
+/** 加载菜品(自动按当前营业时段过滤) */
 async function loadDishes() {
   dishLoading.value = true
   try {
-    const params = { tenantId: orderStore.tenantId, pageNum: 1, pageSize: 100, status: 'ON_SALE' }
-    if (activeCatId.value !== 'all') {
-      params.categoryId = activeCatId.value
+    const params = { tenantId: orderStore.tenantId, pageNum: 1, pageSize: 100, status: '1' }
+    // 根据当前营业时段过滤菜品
+    if (orderStore.currentMealType) {
+      params.mealType = orderStore.currentMealType
     }
     const res = await api.hotelDishPage(params)
     const data = res?.data || res
@@ -242,13 +219,6 @@ async function loadDishes() {
   }
 }
 
-/** 切换分类 */
-function changeCategory(catId) {
-  activeCatId.value = catId
-  keyword.value = ''
-  loadDishes()
-}
-
 /** 搜索 */
 function searchDish() {
   loadDishes()
@@ -313,7 +283,7 @@ function buildCartItem(dish, specs, additions, qty) {
 
 /** 跳转菜品详情 */
 function goToDetail(dish) {
-  if (dish.soldOut === 1 || dish.status !== 'ON_SALE') return
+  if (dish.soldOut === 1 || dish.status !== '1') return
   if (isPaused.value) {
     uni.showToast({ title: '餐厅已暂停接单', icon: 'none' })
     return
@@ -420,35 +390,6 @@ function goToCart() {
   background: transparent;
 }
 
-/* 分类标签 */
-.m-cat-scroll {
-  white-space: nowrap;
-  padding: 20rpx 28rpx 16rpx;
-}
-
-.m-cat-inner {
-  display: inline-flex;
-  gap: 16rpx;
-}
-
-.m-cat-tag {
-  flex-shrink: 0;
-  padding: 12rpx 28rpx;
-  border-radius: 32rpx;
-  font-size: 24rpx;
-  font-weight: 500;
-  color: var(--h-txt-sub, #666);
-  background: #fff;
-  border: 1rpx solid var(--h-bdr, #eee);
-  transition: all 0.15s;
-}
-
-.m-cat-tag--active {
-  background: var(--h-pri, #2D5016);
-  color: #fff;
-  border-color: var(--h-pri, #2D5016);
-}
-
 /* 菜品列表 */
 .m-dish-scroll {
   height: auto;

+ 16 - 0
forge-h5-ui/src/store/modules/hotel-order.js

@@ -40,6 +40,9 @@ export const useHotelOrderStore = defineStore('hotel-order', {
 
     /** 当前营业状态(来自后端 BusinessHoursStatusVO,不持久化) */
     businessHours: null,
+
+    /** 当前所属餐段类型(breakfast/lunch/dinner/night,用于过滤菜品) */
+    currentMealType: null,
   }),
 
   getters: {
@@ -163,6 +166,19 @@ export const useHotelOrderStore = defineStore('hotel-order', {
     /** 设置营业状态(存后端 VO 原样) */
     setBusinessHours(v) {
       this.businessHours = v
+      // 根据当前时间推导所属餐段
+      if (v && v.open && v.currentMealName) {
+        // 从 mealName 映射到 mealType
+        const nameToTypeMap = {
+          '早餐': 'breakfast',
+          '午餐': 'lunch',
+          '晚餐': 'dinner',
+          '夜宵': 'night',
+        }
+        this.currentMealType = nameToTypeMap[v.currentMealName] || null
+      } else {
+        this.currentMealType = null
+      }
     },
   },
 

+ 19 - 0
forge-server/db/migration/V1.0.120__add_sys_normal_disable_dict.sql

@@ -0,0 +1,19 @@
+-- ============================================================
+-- 添加 sys_normal_disable 通用状态字典(启用/禁用)
+-- 二维码状态列等通用场景使用
+-- ============================================================
+
+-- 字典类型
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '通用状态', 'sys_normal_disable', 1, '通用启用禁用状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'sys_normal_disable' AND tenant_id = 1);
+
+-- 字典数据:0=正常(启用)
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '正常', '0', 'sys_normal_disable', NULL, 'success', 'Y', 1, '正常状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_normal_disable' AND dict_value = '0' AND tenant_id = 1);
+
+-- 字典数据:1=停用(禁用)
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '停用', '1', 'sys_normal_disable', NULL, 'danger', 'N', 1, '停用状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'sys_normal_disable' AND dict_value = '1' AND tenant_id = 1);

+ 11 - 0
forge-server/db/migration/V1.0.121__add_dish_meal_type_field.sql

@@ -0,0 +1,11 @@
+-- ============================================================
+-- 为 hotel_dish 表添加 meal_type 字段(餐段类型)
+-- 用于控制菜品在哪个营业时段可被选择
+-- ============================================================
+
+-- 添加 meal_type 字段
+ALTER TABLE hotel_dish 
+ADD COLUMN meal_type VARCHAR(20) DEFAULT NULL COMMENT '餐段类型(breakfast=早餐,lunch=午餐,dinner=晚餐,night=夜宵,NULL=全天)' AFTER category_id;
+
+-- 将现有菜品设置为全天可用(NULL表示不限制餐段)
+UPDATE hotel_dish SET meal_type = NULL WHERE del_flag = 0;

+ 9 - 0
forge-server/db/migration/V1.0.122__convert_dish_meal_type_to_english.sql

@@ -0,0 +1,9 @@
+-- V1.0.122: 将 hotel_dish 表的 meal_type 中文值统一转换为英文值
+-- 早餐->breakfast, 午餐->lunch, 晚餐->dinner, 夜宵->night
+-- 单值如 "晚餐" -> "dinner",多值如 "午餐,晚餐" -> "lunch,dinner"
+-- 使用 REPLACE 统一处理,无需区分单值/多值
+
+UPDATE hotel_dish SET meal_type = REPLACE(meal_type, '早餐', 'breakfast') WHERE meal_type LIKE '%早餐%' AND del_flag = 0;
+UPDATE hotel_dish SET meal_type = REPLACE(meal_type, '午餐', 'lunch')     WHERE meal_type LIKE '%午餐%' AND del_flag = 0;
+UPDATE hotel_dish SET meal_type = REPLACE(meal_type, '晚餐', 'dinner')    WHERE meal_type LIKE '%晚餐%' AND del_flag = 0;
+UPDATE hotel_dish SET meal_type = REPLACE(meal_type, '夜宵', 'night')     WHERE meal_type LIKE '%夜宵%' AND del_flag = 0;

+ 13 - 0
forge-server/db/migration/V1.0.123__change_dish_status_to_numeric.sql

@@ -0,0 +1,13 @@
+-- V1.0.123: 菜品状态值从 ON_SALE/OFF_SHELF 改为 1/0
+-- 1=在售(上架), 0=下架
+
+-- 1. 转换已有数据
+UPDATE hotel_dish SET status = '1' WHERE status = 'ON_SALE' AND del_flag = 0;
+UPDATE hotel_dish SET status = '0' WHERE status = 'OFF_SHELF' AND del_flag = 0;
+
+-- 2. 修改列默认值
+ALTER TABLE hotel_dish MODIFY COLUMN `status` varchar(16) NOT NULL DEFAULT '1' COMMENT '状态(1=在售, 0=下架)';
+
+-- 3. 更新字典数据
+UPDATE sys_dict_data SET dict_value = '1', dict_label = '在售' WHERE dict_type = 'hotel_dish_status' AND dict_value = 'ON_SALE' AND tenant_id = 1;
+UPDATE sys_dict_data SET dict_value = '0', dict_label = '下架' WHERE dict_type = 'hotel_dish_status' AND dict_value = 'OFF_SHELF' AND tenant_id = 1;

+ 1 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.example.yml

@@ -92,7 +92,7 @@ hotel:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
-    base-url: https://192.168.10.4:3001
+    base-url: https://192.168.10.7:3001
     # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
     # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
     path: "/#/pages/hotel/scan-bind"

+ 2 - 2
forge-server/forge-admin-server/src/main/resources/application-dev.yml

@@ -94,7 +94,7 @@ hotel:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
-    base-url: http://192.168.10.4:3001
+    base-url: http://192.168.10.7:3001
     # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
     # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
     path: "/#/pages/hotel/scan-bind"
@@ -113,6 +113,6 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8580端口)
-    notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://qbaa6e66.natappfree.cc/hotel/open/pay/notify
     # 是否沙箱环境
     sandbox: true

+ 1 - 1
forge-server/forge-app-server/src/main/resources/application-dev.example.yml

@@ -73,7 +73,7 @@ hotel:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
-    base-url: https://192.168.10.4:3001
+    base-url: https://192.168.10.7:3001
     # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
     # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
     path: "/#/pages/hotel/scan-bind"

+ 3 - 3
forge-server/forge-app-server/src/main/resources/application-dev.yml

@@ -73,7 +73,7 @@ hotel:
     # 二维码扫码基础URL(指向H5应用,顾客/员工扫码后打开H5页面)
     # 开发环境使用 HTTPS + 局域网IP(手机扫码需要安全上下文才能调用摄像头)
     # 生产环境请改为实际H5域名(必须 HTTPS)
-    base-url: http://192.168.10.4:3001
+    base-url: http://192.168.10.7:3001
     # 扫码落地路径:开发环境保持 H5 载体的 hash 路由旧值,既有二维码与扫码流程零变化
     # 生产环境(application.yml)默认 /r/,切换小程序载体时才会生效
     path: "/#/pages/hotel/scan-bind"
@@ -92,8 +92,8 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8583端口)
-    notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://qbaa6e66.natappfree.cc/hotel/open/pay/notify
     # 支付完成后跳转回 H5 页面(支付页会自动检测已支付状态并跳转订单详情页)
-    return-url: http://192.168.10.4:3001/#/pages/hotel/customer/pay
+    return-url: http://192.168.10.7:3001/#/pages/hotel/customer/pay
     # 是否沙箱环境
     sandbox: true

+ 8 - 5
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java

@@ -131,22 +131,25 @@ public class HotelCustomerController {
 
     /**
      * 顾客端菜品分页(菜单页使用)。
+     * <p>
+     * 根据当前时间所属的餐段(mealType)过滤菜品:
+     * - 如果传入了 mealType,则返回该餐段的菜品 + 全天菜品(meal_type IS NULL)
+     * - 如果没有传入 mealType,则返回所有在售菜品
      *
      * @param tenantId  租户ID
      * @param pageQuery 分页参数
-     * @param query     查询条件(status=ON_SALE, categoryId 等)
+     * @param query     查询条件(status=1, categoryId, mealType 等)
      * @return 菜品分页数据
      */
     @GetMapping("/dishPage")
-    public RespInfo<IPage<HotelDishVO>> dishPage(@RequestParam Long tenantId,
-                                                  PageQuery pageQuery,
-                                                  HotelDish query) {
+    public RespInfo<IPage<HotelDishVO>> dishPage(@RequestParam Long tenantId,PageQuery pageQuery,HotelDish query) {
         requireTenantId(tenantId);
         IPage<HotelDishVO> result = TenantContextHolder.executeWithTenant(tenantId,
                 new java.util.function.Supplier<IPage<HotelDishVO>>() {
                     @Override
                     public IPage<HotelDishVO> get() {
-                        return dishService.dishPage(pageQuery, query);
+                        // 使用顾客端专用接口,支持按餐段过滤
+                        return dishService.customerDishPage(pageQuery, query);
                     }
                 });
         return RespInfo.success(result);

+ 4 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelDish.java

@@ -32,6 +32,9 @@ public class HotelDish extends TenantEntity {
     /** 分类ID */
     private Long categoryId;
 
+    /** 餐段类型(breakfast=早餐,lunch=午餐,dinner=晚餐,night=夜宵,NULL=全天) */
+    private String mealType;
+
     /** 基础价格(元) */
     private BigDecimal price;
 
@@ -53,7 +56,7 @@ public class HotelDish extends TenantEntity {
     /** 月销量 */
     private Integer sales;
 
-    /** 状态:ON_SALE-在售, OFF_SHELF-下架 */
+    /** 状态:1-在售, 0-下架 */
     private String status;
 
     /** 是否售罄(0=正常, 1=售罄) */

+ 1 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/DishBatchOperationDTO.java

@@ -18,6 +18,6 @@ public class DishBatchOperationDTO implements Serializable {
     /** 菜品ID列表 */
     private List<Long> ids;
 
-    /** 操作类型: ON_SALE-上架, OFF_SHELF-下架, SOLD_OUT-售罄, RESTORE-恢复上架, DELETE-删除 */
+    /** 操作类型: 1-上架, 0-下架, SOLD_OUT-售罄, RESTORE-恢复上架, DELETE-删除 */
     private String operation;
 }

+ 4 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/dto/HotelDishDTO.java

@@ -25,6 +25,9 @@ public class HotelDishDTO implements Serializable {
     /** 分类ID */
     private Long categoryId;
 
+    /** 餐段类型(breakfast=早餐,lunch=午餐,dinner=晚餐,night=夜宵,NULL=全天) */
+    private String mealType;
+
     /** 基础价格(元) */
     private BigDecimal price;
 
@@ -43,7 +46,7 @@ public class HotelDishDTO implements Serializable {
     /** 预计制作时长(分钟) */
     private Integer prepTime;
 
-    /** 状态:ON_SALE-在售, OFF_SHELF-下架 */
+    /** 状态:1-在售, 0-下架 */
     private String status;
 
     /** 是否售罄(0=正常, 1=售罄) */

+ 16 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelDishMapper.java

@@ -58,4 +58,20 @@ public interface HotelDishMapper extends BaseMapper<HotelDish> {
      * @return 影响行数
      */
     int incrementSales(@Param("dishId") Long dishId, @Param("quantity") int quantity);
+
+    /**
+     * 顾客端菜品分页查询(按当前营业时段过滤)。
+     * <p>
+     * 根据当前时间所属的餐段(mealType)过滤菜品:
+     * - 如果传入了 mealType,则返回该餐段的菜品 + 全天菜品(meal_type IS NULL)
+     * - 如果没有传入 mealType,则返回所有在售菜品
+     *
+     * @param page       分页对象
+     * @param tenantId   租户ID
+     * @param query      查询条件(status=1, categoryId, mealType 等)
+     * @return 菜品分页数据
+     */
+    IPage<HotelDishVO> selectCustomerPage(Page<HotelDishVO> page,
+                                          @Param("tenantId") Long tenantId,
+                                          @Param("query") HotelDish query);
 }

+ 13 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelDishService.java

@@ -27,6 +27,19 @@ public interface HotelDishService {
     IPage<HotelDishVO> dishPage(PageQuery pageQuery, HotelDish query);
 
     /**
+     * 顾客端菜品分页查询(按当前营业时段过滤)。
+     * <p>
+     * 根据当前时间所属的餐段(mealType)过滤菜品:
+     * - 如果传入了 mealType,则返回该餐段的菜品 + 全天菜品(meal_type IS NULL)
+     * - 如果没有传入 mealType,则返回所有在售菜品
+     *
+     * @param pageQuery 分页参数
+     * @param query     查询条件(status=1, categoryId, mealType 等)
+     * @return 菜品分页数据
+     */
+    IPage<HotelDishVO> customerDishPage(PageQuery pageQuery, HotelDish query);
+
+    /**
      * 查询菜品详情(含规格组和加料)。
      *
      * @param id 菜品ID

+ 25 - 8
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelDishServiceImpl.java

@@ -53,6 +53,23 @@ public class HotelDishServiceImpl implements HotelDishService {
         return dishMapper.selectAdminPage(page, resolveTenantId(), query);
     }
 
+    /**
+     * 顾客端菜品分页查询(按当前营业时段过滤)。
+     * <p>
+     * 根据当前时间所属的餐段(mealType)过滤菜品:
+     * - 如果传入了 mealType,则返回该餐段的菜品 + 全天菜品(meal_type IS NULL)
+     * - 如果没有传入 mealType,则返回所有在售菜品
+     *
+     * @param pageQuery 分页参数
+     * @param query     查询条件(status=1, categoryId, mealType 等)
+     * @return 菜品分页数据
+     */
+    @Override
+    public IPage<HotelDishVO> customerDishPage(PageQuery pageQuery, HotelDish query) {
+        Page<HotelDishVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return dishMapper.selectCustomerPage(page, resolveTenantId(), query);
+    }
+
     @Override
     public HotelDishVO dishDetail(Long id) {
         if (id == null) {
@@ -92,7 +109,7 @@ public class HotelDishServiceImpl implements HotelDishService {
         HotelDish entity = new HotelDish();
         BeanUtils.copyProperties(dto, entity, "id", "specGroups", "additions", "keyword", "categoryIdQuery", "statusQuery");
         if (!StringUtils.hasText(entity.getStatus())) {
-            entity.setStatus("ON_SALE");
+            entity.setStatus("1");
         }
         if (entity.getSales() == null) {
             entity.setSales(0);
@@ -176,8 +193,8 @@ public class HotelDishServiceImpl implements HotelDishService {
         HotelDish entity = requireDish(id);
         entity.setStatus(status);
         dishMapper.updateById(entity);
-        String opType = "ON_SALE".equals(status) ? "ON_SALE" : "OFF_SHELF";
-        String desc = "ON_SALE".equals(status) ? "上架" : "下架";
+        String opType = "1".equals(status) ? "ON_SALE" : "OFF_SHELF";
+        String desc = "1".equals(status) ? "上架" : "下架";
         saveLog(id, entity.getName(), opType, null, null, desc);
         log.info("菜品状态变更: {} -> {}", entity.getName(), status);
     }
@@ -197,7 +214,7 @@ public class HotelDishServiceImpl implements HotelDishService {
     public void dishRestore(Long id) {
         HotelDish entity = requireDish(id);
         entity.setSoldOut(0);
-        entity.setStatus("ON_SALE");
+        entity.setStatus("1");
         dishMapper.updateById(entity);
         saveLog(id, entity.getName(), "RESTORE", null, null, "恢复上架");
         log.info("恢复上架: {}", entity.getName());
@@ -214,11 +231,11 @@ public class HotelDishServiceImpl implements HotelDishService {
         }
         for (Long id : dto.getIds()) {
             switch (dto.getOperation()) {
-                case "ON_SALE":
-                    dishUpdateStatus(id, "ON_SALE");
+                case "1":
+                    dishUpdateStatus(id, "1");
                     break;
-                case "OFF_SHELF":
-                    dishUpdateStatus(id, "OFF_SHELF");
+                case "0":
+                    dishUpdateStatus(id, "0");
                     break;
                 case "SOLD_OUT":
                     dishMarkSoldOut(id);

+ 4 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishVO.java

@@ -24,6 +24,9 @@ public class HotelDishVO {
     /** 分类ID */
     private Long categoryId;
 
+    /** 餐段类型(breakfast=早餐,lunch=午餐,dinner=晚餐,night=夜宵,NULL=全天) */
+    private String mealType;
+
     /** 分类名称(关联查询) */
     private String categoryName;
 
@@ -48,7 +51,7 @@ public class HotelDishVO {
     /** 月销量 */
     private Integer sales;
 
-    /** 状态:ON_SALE-在售, OFF_SHELF-下架 */
+    /** 状态:1-在售, 0-下架 */
     private String status;
 
     /** 是否售罄(0=正常, 1=售罄) */

+ 25 - 1
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelDishMapper.xml

@@ -7,6 +7,7 @@
         <result column="tenant_id" property="tenantId" />
         <result column="name" property="name" />
         <result column="category_id" property="categoryId" />
+        <result column="meal_type" property="mealType" />
         <result column="category_name" property="categoryName" />
         <result column="price" property="price" />
         <result column="image" property="image" />
@@ -27,6 +28,7 @@
 
     <sql id="DishColumns">
         d.id, d.tenant_id, d.name, d.category_id,
+        d.meal_type,
         c.name AS category_name,
         d.price, d.image, d.images, d.description, d.tags,
         d.prep_time, d.sales, d.status, d.sold_out,
@@ -50,6 +52,10 @@
         <if test="query.name != null and query.name != ''">
             AND d.name LIKE CONCAT('%', #{query.name}, '%')
         </if>
+        <if test="query.mealType != null and query.mealType != ''">
+            <!-- 支持多选:meal_type 存储逗号分隔值如 "breakfast,lunch",NULL 表示全天可用 -->
+            AND (FIND_IN_SET(#{query.mealType}, d.meal_type) > 0 OR d.meal_type IS NULL)
+        </if>
         ORDER BY d.sort_order ASC, d.sales DESC
     </select>
 
@@ -71,7 +77,7 @@
         LEFT JOIN hotel_dish_category c ON c.id = d.category_id AND c.del_flag = 0
         WHERE d.tenant_id = #{tenantId}
           AND d.del_flag = 0
-          AND d.status = 'ON_SALE'
+          AND d.status = '1'
         ORDER BY d.sales DESC
         LIMIT #{limit}
     </select>
@@ -85,4 +91,22 @@
           AND del_flag = 0
     </update>
 
+    <!-- 顾客端菜品分页查询(按营业时段过滤) -->
+    <select id="selectCustomerPage" resultMap="DishVOMap">
+        SELECT <include refid="DishColumns" />
+        FROM hotel_dish d
+        LEFT JOIN hotel_dish_category c ON c.id = d.category_id AND c.del_flag = 0
+        WHERE d.tenant_id = #{tenantId}
+          AND d.del_flag = 0
+          AND d.status = '1'
+        <if test="query.categoryId != null">
+            AND d.category_id = #{query.categoryId}
+        </if>
+        <if test="query.mealType != null and query.mealType != ''">
+            <!-- 当前餐段的菜品 + 全天菜品(meal_type IS NULL),兼容中英文值 -->
+            AND (FIND_IN_SET(#{query.mealType}, d.meal_type) > 0 OR d.meal_type IS NULL)
+        </if>
+        ORDER BY d.sort_order ASC, d.sales DESC
+    </select>
+
 </mapper>

+ 11 - 11
forge-server/forge-business/forge-hotel/酒店二维码模块开发文档.md

@@ -332,10 +332,10 @@ String qrCodeUrl = QrCodeUtils.buildQrCodeUrl(qrBaseUrl, qrBasePath, entity.getS
 | yml 未配置 | `@Value` 空串默认值 | 不崩,进下一层 |
 | 空串回退 | `QrCodeUtils` 内回退到 `QR_BASE_PATH` | URL 与改造前**逐字符相同** |
 
-当前 dev 实际生成的 URL(`hotel.qr.base-url = http://192.168.10.4:3001`)—— **与改造前完全一致**:
+当前 dev 实际生成的 URL(`hotel.qr.base-url = http://192.168.10.7:3001`)—— **与改造前完全一致**:
 
 ```
-http://192.168.10.4:3001/#/pages/hotel/scan-bind?c=ABCD1234&t=1&s=xxxx
+http://192.168.10.7:3001/#/pages/hotel/scan-bind?c=ABCD1234&t=1&s=xxxx
 ```
 
 > ⚠️ `hotel.qr.base-url` 的 `@Value` **无默认值**(L69),且改造前**两份生产 `application.yml` 完全没有 `hotel` 块** → 以生产 profile 启动直接崩。已补齐为 `${FORGE_HOTEL_QR_BASE_URL:}`(空串而非缺失,保证能启动;但空串下生成的二维码无域名不可扫,部署时必须传入真实域名)。
@@ -634,16 +634,16 @@ Store:hotel-order(Pinia + persistedstate)
 
 ### 9.2 application.yml 配置项
 
-| 配置 | dev 当前值 | prod 当前值 | 说明 |
-|---|---|---|---|
-| `hotel.qr.base-url` | `http://192.168.10.4:3001` | `${FORGE_HOTEL_QR_BASE_URL:}` | 二维码基础 URL。✅ **已落地**(生产从「整块缺失」补齐) |
-| `hotel.qr.path` | `"/#/pages/hotel/scan-bind"` | `${FORGE_HOTEL_QR_PATH:/r/}` | ✅ **已落地**(原为「待新增」),替代硬编码的 `HotelQrConstants.QR_BASE_PATH`;dev 保持旧值→既有二维码 URL 逐字符不变 |
-| `hotel.pay.mock-enabled` | `true` | `${FORGE_HOTEL_PAY_MOCK_ENABLED:false}` | ✅ **已落地**(原为「待新增」),Mock 支付开关;读取方 `pay/config/HotelPayConfig.java` |
-| `hotel.alipay.app-id` | `9021000166699753`(沙箱) | 待填正式 appid | 支付宝应用。⚠️ 沙箱 APPID 与**小程序 appid 不属同一体系**,不能直接填入 `manifest.json` 的 `mp-alipay.appid` |
+| 配置 | dev 当前值                                               | prod 当前值 | 说明 |
+|---|-------------------------------------------------------|---|---|
+| `hotel.qr.base-url` | `http://192.168.10.7:3001`                            | `${FORGE_HOTEL_QR_BASE_URL:}` | 二维码基础 URL。✅ **已落地**(生产从「整块缺失」补齐) |
+| `hotel.qr.path` | `"/#/pages/hotel/scan-bind"`                          | `${FORGE_HOTEL_QR_PATH:/r/}` | ✅ **已落地**(原为「待新增」),替代硬编码的 `HotelQrConstants.QR_BASE_PATH`;dev 保持旧值→既有二维码 URL 逐字符不变 |
+| `hotel.pay.mock-enabled` | `true`                                                | `${FORGE_HOTEL_PAY_MOCK_ENABLED:false}` | ✅ **已落地**(原为「待新增」),Mock 支付开关;读取方 `pay/config/HotelPayConfig.java` |
+| `hotel.alipay.app-id` | `9021000166699753`(沙箱)                                | 待填正式 appid | 支付宝应用。⚠️ 沙箱 APPID 与**小程序 appid 不属同一体系**,不能直接填入 `manifest.json` 的 `mp-alipay.appid` |
 | `hotel.alipay.gateway-url` | `https://openapi-sandbox.dl.alipaydev.com/gateway.do` | 线上网关 | 支付宝网关 |
-| `hotel.alipay.sandbox` | `true` | `false` | 沙箱开关 |
-| `hotel.alipay.notify-url` | natapp 免费域名 | 备案域名 | ⚠️ natapp 重启即失效,回调不稳 |
-| `hotel.alipay.return-url` | `http://192.168.10.4:3001/#/pages/hotel/customer/pay` | 待改 | ⚠️ **仅 `forge-app-server` 有**;hash 路由使支付宝同步回跳**丢参数**(第 3 批修) |
+| `hotel.alipay.sandbox` | `true`                                                | `false` | 沙箱开关 |
+| `hotel.alipay.notify-url` | natapp 免费域名                                           | 备案域名 | ⚠️ natapp 重启即失效,回调不稳 |
+| `hotel.alipay.return-url` | `http://192.168.10.7:3001/#/pages/hotel/customer/pay` | 待改 | ⚠️ **仅 `forge-app-server` 有**;hash 路由使支付宝同步回跳**丢参数**(第 3 批修) |
 
 > 🔴 **六份 yml 必须同步**(已核对一致):`forge-admin-server` 与 `forge-app-server` 各有 `application.yml` / `application-dev.yml` / `application-dev.example.yml` 共 **6 份**,均已写入上表前三项。
 >

+ 1 - 1
forge-server/forge-business/forge-hotel/酒店订单支付系统开发文档.md

@@ -565,7 +565,7 @@ hotel:
   alipay:
     app-id: 9021000166699753                  # 沙箱 APPID
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
-    notify-url: http://t2aaba9f.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://qbaa6e66.natappfree.cc/hotel/open/pay/notify
     sandbox: true
 ```
 

+ 13 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/controller/SysDictDataController.java

@@ -99,4 +99,17 @@ public class SysDictDataController {
         boolean result = dictDataService.deleteDictDataByIds(dictCodes);
         return result ? RespInfo.success() : RespInfo.error("批量删除失败");
     }
+
+    /**
+     * 清除字典数据缓存(全部或指定类型)
+     */
+    @PostMapping("/clearCache")
+    public RespInfo<Void> clearCache(@RequestParam(required = false) String dictType) {
+        if (dictType != null && !dictType.isBlank()) {
+            dictDataService.clearDictDataCache(dictType);
+        } else {
+            dictDataService.clearDictDataCache();
+        }
+        return RespInfo.success();
+    }
 }

+ 3 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/mapper/SysDictDataMapper.java

@@ -5,6 +5,8 @@ import com.mdframe.forge.plugin.system.entity.SysDictData;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.List;
+
 /**
  * 字典数据Mapper接口
  */
@@ -22,4 +24,5 @@ public interface SysDictDataMapper extends BaseMapper<SysDictData> {
     int countByDictTypeAndValue(@Param("dictType") String dictType,
                                 @Param("dictValue") String dictValue,
                                 @Param("excludeDictCode") Long excludeDictCode);
+
 }

+ 2 - 0
forge-server/forge-framework/forge-starter-parent/forge-starter-tenant/src/main/java/com/mdframe/forge/starter/tenant/config/TenantProperties.java

@@ -70,5 +70,7 @@ public class TenantProperties {
         ignoreTables.add("worker_node");
         ignoreTables.add("sys_id_sequence");
         ignoreTables.add("ai_page_template");
+        ignoreTables.add("sys_dict_type");
+        ignoreTables.add("sys_dict_data");
     }
 }

File diff ditekan karena terlalu besar
+ 282 - 0
forge-start.sh


+ 430 - 0
install-env.sh

@@ -0,0 +1,430 @@
+#!/bin/bash
+# ============================================
+# Forge Admin 服务器环境安装脚本
+# 安装 JRE 17 + MySQL 8.0 + Redis 7 + Nginx
+# 前提:安装包已放在 /mnt/rar/ 目录
+# ============================================
+
+set -e
+
+PKG_DIR="/mnt/rar"
+INSTALL_DIR="/opt/forge-env"
+
+# 颜色输出
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+info()  { echo -e "${GREEN}[INFO]${NC} $1"; }
+warn()  { echo -e "${YELLOW}[WARN]${NC} $1"; }
+error() { echo -e "${RED}[ERROR]${NC} $1"; }
+
+echo "=========================================="
+echo "  Forge Admin 环境安装"
+echo "  安装包目录: ${PKG_DIR}"
+echo "  安装目标:   ${INSTALL_DIR}"
+echo "=========================================="
+
+# 检查安装包
+for f in \
+    "OpenJDK17U-jre_x64_linux_hotspot_17.0.20.1_1.tar.gz" \
+    "mysql-8.0.46-linux-glibc2.28-x86_64.tar.xz" \
+    "redis-7.0.15.tar.gz" \
+    "nginx-1.25.4.tar.gz"; do
+    if [ ! -f "${PKG_DIR}/${f}" ]; then
+        error "缺少安装包: ${f}"
+        exit 1
+    fi
+done
+info "所有安装包已就绪"
+
+# 安装编译工具(Redis 和 Nginx 需要)
+info "安装编译工具 gcc/make/pcre-devel/zlib-devel/openssl-devel..."
+yum install -y gcc make pcre-devel zlib-devel openssl-devel xz 2>/dev/null || {
+    error "yum 安装编译工具失败,请检查网络或 yum 源"
+    exit 1
+}
+
+mkdir -p ${INSTALL_DIR}
+
+# ============================================
+# 1. 安装 JRE 17
+# ============================================
+echo ""
+echo "=========================================="
+echo "  [1/4] 安装 JRE 17"
+echo "=========================================="
+
+JRE_DIR="${INSTALL_DIR}/jre-17"
+if [ -d "${JRE_DIR}" ] && [ -x "${JRE_DIR}/bin/java" ]; then
+    info "JRE 17 已安装: $(${JRE_DIR}/bin/java -version 2>&1 | head -1)"
+else
+    info "解压 JRE 17..."
+    tar -xzf "${PKG_DIR}/OpenJDK17U-jre_x64_linux_hotspot_17.0.20.1_1.tar.gz" -C ${INSTALL_DIR}
+    # 找到解压后的目录并重命名
+    EXTRACTED=$(find ${INSTALL_DIR} -maxdepth 1 -name "jdk-17*" -o -name "jre-17*" | head -1)
+    if [ -z "${EXTRACTED}" ]; then
+        # 可能解压到了 jdk-17.0.20.1+1-jre 之类的目录
+        EXTRACTED=$(find ${INSTALL_DIR} -maxdepth 1 -type d | grep -i "17" | head -1)
+    fi
+    if [ -n "${EXTRACTED}" ] && [ "${EXTRACTED}" != "${JRE_DIR}" ]; then
+        mv "${EXTRACTED}" "${JRE_DIR}"
+    fi
+
+    # 配置环境变量
+    if ! grep -q "JAVA_HOME=${JRE_DIR}" /etc/profile; then
+        echo "" >> /etc/profile
+        echo "# JRE 17 for Forge Admin" >> /etc/profile
+        echo "export JAVA_HOME=${JRE_DIR}" >> /etc/profile
+        echo 'export PATH=$JAVA_HOME/bin:$PATH' >> /etc/profile
+    fi
+    export JAVA_HOME=${JRE_DIR}
+    export PATH=${JRE_DIR}/bin:$PATH
+
+    info "JRE 17 安装完成: $(java -version 2>&1 | head -1)"
+fi
+
+# ============================================
+# 2. 安装 MySQL 8.0(二进制包方式)
+# ============================================
+echo ""
+echo "=========================================="
+echo "  [2/4] 安装 MySQL 8.0"
+echo "=========================================="
+
+MYSQL_DIR="${INSTALL_DIR}/mysql"
+MYSQL_DATA="${INSTALL_DIR}/mysql-data"
+MYSQL_PORT="${MYSQL_PORT:-3306}"
+
+if [ -d "${MYSQL_DIR}/bin/mysqld" ]; then
+    info "MySQL 8.0 已安装: $(${MYSQL_DIR}/bin/mysqld --version)"
+else
+    info "解压 MySQL 8.0(约 850MB,请耐心等待)..."
+    tar -xJf "${PKG_DIR}/mysql-8.0.46-linux-glibc2.28-x86_64.tar.xz" -C ${INSTALL_DIR}
+    EXTRACTED=$(find ${INSTALL_DIR} -maxdepth 1 -type d -name "mysql-8*" | head -1)
+    if [ -n "${EXTRACTED}" ] && [ "${EXTRACTED}" != "${MYSQL_DIR}" ]; then
+        mv "${EXTRACTED}" "${MYSQL_DIR}"
+    fi
+
+    # 创建 mysql 用户
+    if ! id mysql &>/dev/null; then
+        useradd -r -s /sbin/nologin mysql
+        info "创建 mysql 用户"
+    fi
+
+    # 创建数据目录
+    mkdir -p ${MYSQL_DATA}
+    chown -R mysql:mysql ${MYSQL_DIR} ${MYSQL_DATA}
+
+    # 初始化数据库
+    info "初始化 MySQL 数据库..."
+    ${MYSQL_DIR}/bin/mysqld --initialize-insecure \
+        --user=mysql \
+        --basedir=${MYSQL_DIR} \
+        --datadir=${MYSQL_DATA} 2>&1 | tail -3
+
+    # 创建 my.cnf 配置
+    cat > /etc/my.cnf << EOF
+[mysqld]
+basedir=${MYSQL_DIR}
+datadir=${MYSQL_DATA}
+port=${MYSQL_PORT}
+socket=/tmp/mysql.sock
+character-set-server=utf8mb4
+collation-server=utf8mb4_unicode_ci
+default-authentication-plugin=mysql_native_password
+innodb_buffer_pool_size=256M
+max_connections=100
+symbolic-links=0
+
+[mysqld_safe]
+log-error=/var/log/mysqld.log
+pid-file=/var/run/mysqld/mysqld.pid
+
+[client]
+port=${MYSQL_PORT}
+socket=/tmp/mysql.sock
+default-character-set=utf8mb4
+EOF
+
+    # 创建 systemd 服务
+    cat > /etc/systemd/system/mysqld.service << EOF
+[Unit]
+Description=MySQL 8.0 Server
+After=network.target
+
+[Service]
+Type=forking
+User=mysql
+Group=mysql
+ExecStart=${MYSQL_DIR}/bin/mysqld_safe --defaults-file=/etc/my.cnf
+ExecStop=${MYSQL_DIR}/bin/mysqladmin -u root shutdown
+Restart=on-failure
+LimitNOFILE=65535
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    systemctl daemon-reload
+    systemctl enable mysqld
+    systemctl start mysqld
+    sleep 3
+
+    # 设置 root 密码 + 创建数据库
+    info "配置 MySQL root 密码和数据库..."
+    ${MYSQL_DIR}/bin/mysql -u root --socket=/tmp/mysql.sock -e "
+        ALTER USER 'root'@'localhost' IDENTIFIED BY 'forge123456';
+        CREATE DATABASE IF NOT EXISTS forge_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+        FLUSH PRIVILEGES;
+    " 2>/dev/null || {
+        # 如果上面失败,尝试无密码方式
+        ${MYSQL_DIR}/bin/mysql -u root --socket=/tmp/mysql.sock -e "
+            CREATE DATABASE IF NOT EXISTS forge_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
+        "
+    }
+
+    # 创建软链接方便使用
+    ln -sf ${MYSQL_DIR}/bin/mysql /usr/local/bin/mysql 2>/dev/null || true
+    ln -sf ${MYSQL_DIR}/bin/mysqld /usr/local/bin/mysqld 2>/dev/null || true
+
+    info "MySQL 8.0 安装完成: $(${MYSQL_DIR}/bin/mysqld --version)"
+    info "root 密码: forge123456"
+    info "数据库: forge_admin"
+fi
+
+# ============================================
+# 3. 安装 Redis 7(源码编译)
+# ============================================
+echo ""
+echo "=========================================="
+echo "  [3/4] 安装 Redis 7"
+echo "=========================================="
+
+REDIS_DIR="${INSTALL_DIR}/redis"
+
+if [ -d "${REDIS_DIR}/bin/redis-server" ]; then
+    info "Redis 已安装: $(${REDIS_DIR}/bin/redis-server --version)"
+else
+    info "解压并编译 Redis 7..."
+    cd /tmp
+    tar -xzf "${PKG_DIR}/redis-7.0.15.tar.gz"
+    cd redis-7.0.15
+    make -j$(nproc) 2>&1 | tail -5
+    make PREFIX=${REDIS_DIR} install 2>&1 | tail -3
+
+    # 清理编译临时文件
+    cd /tmp && rm -rf redis-7.0.15
+
+    # 创建 redis 用户
+    if ! id redis &>/dev/null; then
+        useradd -r -s /sbin/nologin redis
+    fi
+
+    # 创建配置和数据目录
+    mkdir -p ${REDIS_DIR}/etc ${REDIS_DIR}/data
+    chown -R redis:redis ${REDIS_DIR}
+
+    # 生成 redis.conf
+    cat > ${REDIS_DIR}/etc/redis.conf << EOF
+bind 127.0.0.1
+port 6379
+requirepass forge123456
+daemonize yes
+pidfile /var/run/redis/redis.pid
+logfile /var/log/redis/redis.log
+dir ${REDIS_DIR}/data
+appendonly yes
+appendfilename "appendonly.aof"
+maxmemory 256mb
+maxmemory-policy allkeys-lru
+tcp-keepalive 300
+timeout 300
+EOF
+
+    mkdir -p /var/run/redis /var/log/redis
+    chown -R redis:redis /var/run/redis /var/log/redis
+
+    # 创建 systemd 服务
+    cat > /etc/systemd/system/redis.service << EOF
+[Unit]
+Description=Redis 7.0
+After=network.target
+
+[Service]
+Type=forking
+User=redis
+Group=redis
+ExecStart=${REDIS_DIR}/bin/redis-server ${REDIS_DIR}/etc/redis.conf
+ExecStop=${REDIS_DIR}/bin/redis-cli -a forge123456 shutdown
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    systemctl daemon-reload
+    systemctl enable redis
+    systemctl start redis
+
+    info "Redis 7 安装完成: $(${REDIS_DIR}/bin/redis-server --version)"
+    info "密码: forge123456"
+fi
+
+# ============================================
+# 4. 安装 Nginx(源码编译)
+# ============================================
+echo ""
+echo "=========================================="
+echo "  [4/4] 安装 Nginx"
+echo "=========================================="
+
+NGINX_DIR="${INSTALL_DIR}/nginx"
+
+if [ -d "${NGINX_DIR}/sbin/nginx" ]; then
+    info "Nginx 已安装: $(${NGINX_DIR}/sbin/nginx -v 2>&1)"
+else
+    info "解压并编译 Nginx..."
+    cd /tmp
+    tar -xzf "${PKG_DIR}/nginx-1.25.4.tar.gz"
+    cd nginx-1.25.4
+    ./configure \
+        --prefix=${NGINX_DIR} \
+        --with-http_ssl_module \
+        --with-http_gzip_static_module \
+        --with-http_realip_module \
+        2>&1 | tail -3
+    make -j$(nproc) 2>&1 | tail -3
+    make install 2>&1 | tail -3
+
+    # 清理编译临时文件
+    cd /tmp && rm -rf nginx-1.25.4
+
+    # 创建 nginx 用户
+    if ! id nginx &>/dev/null; then
+        useradd -r -s /sbin/nologin nginx
+    fi
+
+    # 备份默认配置,写入 Forge 配置
+    mv ${NGINX_DIR}/conf/nginx.conf ${NGINX_DIR}/conf/nginx.conf.bak 2>/dev/null || true
+    cat > ${NGINX_DIR}/conf/nginx.conf << 'NGINXEOF'
+worker_processes auto;
+error_log logs/error.log warn;
+pid logs/nginx.pid;
+
+events {
+    worker_connections 1024;
+}
+
+http {
+    include       mime.types;
+    default_type  application/octet-stream;
+    sendfile      on;
+    keepalive_timeout 65;
+    client_max_body_size 20m;
+
+    gzip on;
+    gzip_types text/plain application/json application/javascript text/css;
+    gzip_min_length 1000;
+
+    server {
+        listen 80;
+        server_name _;
+
+        # 前端静态资源
+        location /forge/ {
+            alias   /opt/forge/frontend/;
+            index  index.html index.htm;
+            try_files $uri $uri/ /forge/index.html;
+        }
+
+        # 根路径重定向
+        location = / {
+            return 302 /forge;
+        }
+
+        # 流程服务 API
+        location /forge-api/api/flow/ {
+            proxy_send_timeout 3000;
+            proxy_read_timeout 3000;
+            proxy_connect_timeout 3000;
+            proxy_pass http://127.0.0.1:8081/api/flow/;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        }
+
+        # 后端主服务 API
+        location /forge-api/ {
+            proxy_send_timeout 3000;
+            proxy_read_timeout 3000;
+            proxy_connect_timeout 3000;
+            proxy_pass http://127.0.0.1:8580/;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        }
+    }
+}
+NGINXEOF
+
+    # 创建 systemd 服务
+    cat > /etc/systemd/system/nginx.service << EOF
+[Unit]
+Description=Nginx HTTP Server
+After=network.target
+
+[Service]
+Type=forking
+ExecStart=${NGINX_DIR}/sbin/nginx
+ExecStop=${NGINX_DIR}/sbin/nginx -s quit
+ExecReload=${NGINX_DIR}/sbin/nginx -s reload
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    systemctl daemon-reload
+    systemctl enable nginx
+    systemctl start nginx
+
+    info "Nginx 安装完成: $(${NGINX_DIR}/sbin/nginx -v 2>&1)"
+fi
+
+# ============================================
+# 安装完成总结
+# ============================================
+echo ""
+echo "=========================================="
+echo "  ✅ 全部安装完成!"
+echo "=========================================="
+echo ""
+echo "  安装目录: ${INSTALL_DIR}"
+echo ""
+echo "  JRE 17:   ${JRE_DIR}/bin/java"
+echo "            版本: $(java -version 2>&1 | head -1)"
+echo ""
+echo "  MySQL:    ${MYSQL_DIR}/bin/mysqld"
+echo "            版本: $(${MYSQL_DIR}/bin/mysqld --version)"
+echo "            数据: ${MYSQL_DATA}"
+echo "            root密码: forge123456"
+echo "            数据库: forge_admin"
+echo ""
+echo "  Redis:    ${REDIS_DIR}/bin/redis-server"
+echo "            版本: $(${REDIS_DIR}/bin/redis-server --version)"
+echo "            密码: forge123456"
+echo ""
+echo "  Nginx:    ${NGINX_DIR}/sbin/nginx"
+echo "            版本: $(${NGINX_DIR}/sbin/nginx -v 2>&1)"
+echo ""
+echo "  服务管理命令:"
+echo "    systemctl status mysqld"
+echo "    systemctl status redis"
+echo "    systemctl status nginx"
+echo ""
+echo "  下一步:"
+echo "    1. 将 Windows 构建好的 jar 和 dist 上传到 /opt/forge/"
+echo "    2. 启动后端服务"
+echo "=========================================="

+ 380 - 0
部署指南-手动部署.md

@@ -0,0 +1,380 @@
+# Forge Admin 手动部署指南
+
+> 适用于阿里云 ECS / CentOS / Alibaba Cloud Linux 等环境,不依赖 Docker。
+
+---
+
+## 一、服务器环境要求
+
+| 项目 | 要求 |
+|------|------|
+| 操作系统 | Alibaba Cloud Linux 4 / CentOS 7+ / Ubuntu 20+ |
+| CPU | 2 核及以上 |
+| 内存 | 2GB 及以上(推荐 4GB) |
+| 磁盘 | 20GB 及以上 |
+| 开放端口 | 80(Nginx)、3306(MySQL,可选远程)、8580(后端,可选) |
+
+---
+
+## 二、环境组件安装
+
+### 2.1 JRE 17
+
+```bash
+# 解压即用,无需安装
+tar -xzf OpenJDK17U-jre_x64_linux_hotspot_17.0.20.1_1.tar.gz -C /opt/forge-env/
+mv /opt/forge-env/jdk-17.0.20.1+1-jre /opt/forge-env/jre-17
+
+# 配置环境变量
+cat >> /etc/profile << 'EOF'
+export JAVA_HOME=/opt/forge-env/jre-17
+export PATH=$JAVA_HOME/bin:$PATH
+EOF
+source /etc/profile
+
+# 验证
+java -version
+```
+
+### 2.2 MySQL 8.0
+
+```bash
+# 解压
+tar -xzf mysql-8.0.46-linux-glibc2.28-x86_64.tar.xz -C /opt/forge-env/
+mv /opt/forge-env/mysql-8.0.46-linux-glibc2.28-x86_64 /opt/forge-env/mysql
+
+# 创建用户和数据目录
+useradd -r -s /sbin/nologin mysql 2>/dev/null || true
+mkdir -p /opt/forge-env/mysql-data /var/run/mysqld /var/log
+chown -R mysql:mysql /opt/forge-env/mysql-data /var/run/mysqld
+touch /var/log/mysqld.log && chown mysql:mysql /var/log/mysqld.log
+
+# 写配置文件(lower_case_table_names=1 解决 Windows 导出表名大小写问题)
+cat > /etc/my.cnf << 'EOF'
+[mysqld]
+basedir=/opt/forge-env/mysql
+datadir=/opt/forge-env/mysql-data
+socket=/tmp/mysql.sock
+port=3306
+user=mysql
+log-error=/var/log/mysqld.log
+pid-file=/var/run/mysqld/mysqld.pid
+lower_case_table_names=1
+
+[client]
+socket=/tmp/mysql.sock
+EOF
+
+# 初始化(--lower-case-table-names=1 必须在初始化时指定,之后不可更改)
+/opt/forge-env/mysql/bin/mysqld --initialize-insecure \
+  --user=mysql \
+  --basedir=/opt/forge-env/mysql \
+  --datadir=/opt/forge-env/mysql-data \
+  --lower-case-table-names=1
+
+# 启动
+/opt/forge-env/mysql/bin/mysqld_safe --defaults-file=/etc/my.cnf &
+sleep 5
+
+# 设密码 + 建库 + 远程访问
+mysql -u root --socket=/tmp/mysql.sock -e "
+  ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
+  CREATE DATABASE IF NOT EXISTS forge_admin DEFAULT CHARACTER SET utf8mb4;
+  CREATE USER 'root'@'%' IDENTIFIED BY 'root';
+  GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
+  FLUSH PRIVILEGES;
+"
+
+# 验证
+mysql -u root -proot --socket=/tmp/mysql.sock -e "SELECT 'MySQL OK';"
+```
+
+> **重要**:如果数据库是从 Windows 导出的,必须在初始化时加 `--lower-case-table-names=1`,否则 Linux 下大写表名(如 `QRTZ_TRIGGERS`)会找不到。
+
+### 2.3 Redis 7
+
+```bash
+# 编译安装(需要 gcc、make)
+tar -xzf redis-7.0.15.tar.gz
+cd redis-7.0.15
+make && make install PREFIX=/opt/forge-env/redis
+
+# 创建配置目录
+mkdir -p /opt/forge-env/redis/etc
+
+# 写配置文件(无密码)
+cat > /opt/forge-env/redis/etc/redis.conf << 'EOF'
+bind 0.0.0.0
+port 6379
+daemonize yes
+pidfile /var/run/redis_6379.pid
+logfile /var/log/redis.log
+dir /opt/forge-env/redis/data
+EOF
+
+mkdir -p /opt/forge-env/redis/data
+
+# 启动
+/opt/forge-env/redis/bin/redis-server /opt/forge-env/redis/etc/redis.conf
+
+# 验证
+/opt/forge-env/redis/bin/redis-cli ping   # 应返回 PONG
+```
+
+### 2.4 Nginx
+
+```bash
+# 编译安装(需要 gcc、make、pcre-devel、zlib-devel、openssl-devel)
+tar -xzf nginx-1.25.4.tar.gz
+cd nginx-1.25.4
+./configure --prefix=/opt/forge-env/nginx --with-http_ssl_module --with-http_gzip_static_module
+make && make install
+
+# 写配置文件
+cat > /opt/forge-env/nginx/conf/nginx.conf << 'EOF'
+worker_processes  auto;
+
+events {
+    worker_connections  1024;
+}
+
+http {
+    include       mime.types;
+    default_type  application/octet-stream;
+    sendfile      on;
+    keepalive_timeout  65;
+    gzip on;
+
+    server {
+        listen       80;
+        server_name  _;
+
+        # 前端静态文件
+        location /forge/ {
+            alias   /opt/forge/frontend/;
+            index   index.html;
+            try_files $uri $uri/ /forge/index.html;
+        }
+
+        # 后端 API 代理
+        location /forge-api/ {
+            proxy_pass http://127.0.0.1:8580/;
+            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_connect_timeout 60s;
+            proxy_read_timeout 120s;
+        }
+
+        # 流程引擎 API 代理
+        location /forge-api/api/flow/ {
+            proxy_pass http://127.0.0.1:8081/api/flow/;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        }
+
+        # 根路径重定向到 /forge
+        location = / {
+            return 302 /forge;
+        }
+    }
+}
+EOF
+
+# 启动
+/opt/forge-env/nginx/sbin/nginx
+
+# 验证配置
+/opt/forge-env/nginx/sbin/nginx -t
+```
+
+---
+
+## 三、数据库导入
+
+### 3.1 从本地 Windows 导出
+
+在 Windows 上执行(本地 MySQL 端口 3307):
+
+```powershell
+mysqldump -h localhost -P 3307 -u root -proot forge_admin > D:\forge_admin.sql
+```
+
+### 3.2 上传到服务器
+
+```powershell
+scp D:\forge_admin.sql root@<服务器IP>:/tmp/
+```
+
+### 3.3 在服务器上导入
+
+```bash
+mysql -u root -proot --socket=/tmp/mysql.sock forge_admin < /tmp/forge_admin.sql
+```
+
+---
+
+## 四、后端构建与部署
+
+### 4.1 Windows 本地构建
+
+```powershell
+# 后端
+cd D:\java_project\forge-admin\forge-server
+mvn clean package -DskipTests
+
+# 前端
+cd D:\java_project\forge-admin\forge-admin-ui
+pnpm.cmd build
+```
+
+### 4.2 上传到服务器
+
+```powershell
+# 后端 jar 包
+scp D:\java_project\forge-admin\forge-server\forge-admin-server\target\forge-admin-server.jar root@<服务器IP>:/mnt/project/forge-admin/
+scp D:\java_project\forge-admin\forge-server\forge-flow\forge-flow-server\target\forge-flow-server.jar root@<服务器IP>:/mnt/project/forge-admin/
+scp D:\java_project\forge-admin\forge-server\forge-app-server\target\forge-app-server.jar root@<服务器IP>:/mnt/project/forge-admin/
+scp D:\java_project\forge-admin\forge-server\forge-report-server\target\forge-report-server.jar root@<服务器IP>:/mnt/project/forge-admin/
+
+# 前端 dist(注意:内容直接放 frontend 目录,不要带 dist 子目录)
+scp -r D:\java_project\forge-admin\forge-admin-ui\dist\* root@<服务器IP>:/opt/forge/frontend/
+```
+
+---
+
+## 五、服务启动与管理
+
+### 5.1 部署管理脚本
+
+将 `forge-start.sh` 上传到服务器:
+
+```powershell
+scp D:\java_project\forge-admin\forge-start.sh root@<服务器IP>:/mnt/project/forge-admin/
+```
+
+在服务器上赋予执行权限:
+
+```bash
+chmod +x /mnt/project/forge-admin/forge-start.sh
+```
+
+### 5.2 常用命令
+
+```bash
+cd /mnt/project/forge-admin
+
+# 查看所有服务状态
+sh forge-start.sh -status
+
+# 启动所有服务
+sh forge-start.sh -start
+
+# 停止所有服务
+sh forge-start.sh -stop
+
+# 重启所有服务
+sh forge-start.sh -restart
+
+# 单独重启某个服务
+sh forge-start.sh -admin    # 主后台 (8580)
+sh forge-start.sh -flow     # 流程引擎 (8081)
+sh forge-start.sh -app      # App 接口 (8583)
+sh forge-start.sh -report   # 大屏报表 (8582)
+```
+
+### 5.3 查看日志
+
+```bash
+# 实时查看主服务日志
+tail -f /var/log/forge/log-forge-admin-server-$(date +%Y-%m-%d).txt
+
+# 查看流程服务日志
+tail -f /var/log/forge/log-forge-flow-server-$(date +%Y-%m-%d).txt
+```
+
+---
+
+## 六、验证部署
+
+### 6.1 检查服务状态
+
+```bash
+# Java 服务
+ps aux | grep forge-
+
+# 端口监听
+ss -tlnp | grep -E '8580|8081|8583|8582|3306|6379|80'
+```
+
+### 6.2 访问前端
+
+浏览器打开:`http://<服务器IP>/forge`
+
+默认登录账号:`admin` / `123456`
+
+### 6.3 常见问题排查
+
+| 问题 | 原因 | 解决 |
+|------|------|------|
+| 502 Bad Gateway | 后端服务未启动 | `sh forge-start.sh -start` |
+| 404 Not Found | 前端文件路径不对 | 确保文件在 `/opt/forge/frontend/` 下,不是 `/opt/forge/frontend/dist/` |
+| QRTZ_TRIGGERS 不存在 | MySQL 大小写敏感 | 重新初始化 MySQL 加 `--lower-case-table-names=1` |
+| Redis AUTH 错误 | Redis 有密码但应用没配 | 去掉 Redis 密码或配置密码 |
+| 数据库连接失败 | MySQL 端口不对 | dev 配置是 3307,服务器是 3306,已通过 JVM 参数覆盖 |
+| hotel.alipay.app-id 缺失 | 缺少酒店模块配置 | 已通过 JVM 参数补充 |
+
+---
+
+## 七、目录结构总览
+
+```
+/opt/forge-env/           # 环境组件
+├── jre-17/               # JRE 17
+├── mysql/                # MySQL 8.0
+├── mysql-data/           # MySQL 数据目录
+├── redis/                # Redis 7
+└── nginx/                # Nginx
+
+/opt/forge/
+├── frontend/             # 前端静态文件(dist 内容直接放这里)
+│   ├── index.html
+│   ├── assets/
+│   └── ...
+
+/mnt/project/forge-admin/ # 后端 jar 包 + 管理脚本
+├── forge-admin-server.jar
+├── forge-flow-server.jar
+├── forge-app-server.jar
+├── forge-report-server.jar
+└── forge-start.sh
+
+/var/log/forge/           # 应用日志
+├── log-forge-admin-server-2026-09-11.txt
+├── log-forge-flow-server-2026-09-11.txt
+└── ...
+```
+
+---
+
+## 八、配置项说明
+
+脚本 `forge-start.sh` 中的 JVM 参数覆盖了以下配置:
+
+| 参数 | 值 | 说明 |
+|------|-----|------|
+| `spring.profiles.active` | prod | 生产环境 profile |
+| `spring.flyway.enabled` | false | 关闭 Flyway 自动迁移 |
+| `spring.datasource...url` | localhost:3306 | MySQL 端口(dev 是 3307) |
+| `spring.datasource...username` | root | 数据库用户 |
+| `spring.datasource...password` | root | 数据库密码 |
+| `spring.data.redis.host` | localhost | Redis 地址 |
+| `spring.data.redis.port` | 6379 | Redis 端口 |
+| `spring.data.redis.password` | (无) | Redis 无密码 |
+| `forge.flow.client.url` | http://127.0.0.1:8081 | 流程引擎地址 |
+| `hotel.alipay.*` | 沙箱配置 | 支付宝沙箱环境 |
+| `hotel.qr.base-url` | http://101.133.130.202 | 二维码基础 URL(改为实际服务器 IP) |
+| `hotel.pay.mock-enabled` | true | Mock 支付(生产环境改为 false) |
+
+> **上线前检查**:将 `hotel.pay.mock-enabled` 改为 `false`,将 `hotel.alipay.*` 改为正式环境配置,将 `hotel.qr.base-url` 改为实际域名。