This commit is contained in:
weizhong committed 2025-09-01 17:18:11 +08:00
commit 79c798ef30
63 files changed
+6476 -509

No files matched your search

+2
View File
@@ -1,2 +1,4 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
scripts/
test/
+2 -1
View File
@@ -325,4 +325,5 @@ docs/变更管理/
.playwright-mcp/
查找界面中涉及到的权限字符.md
docs/部署文档/nginx配置/hzdyjsweb.camasort.com.conf
首页完善的提示词.md
首页完善的提示词.md
scripts/
@@ -115,8 +115,8 @@ spring:
idleTimeout: 300000
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟(减少生命周期)
maxLifetime: 900000
# 多久检查一次连接的活性(增加间隔,减少检测频率)
keepaliveTime: 60000
# 多久检查一次连接的活性(增加间隔,减少检测频率,减少日志输出)
keepaliveTime: 300000
# 连接测试查询(验证连接有效性)
# connectionTestQuery: SELECT 11
@@ -0,0 +1,44 @@
# 生产环境配置 - 9900端口实例
server:
port: 9900
servlet:
context-path: /
# 实例标识
spring:
application:
name: RuoYi-Vue-Plus-9900
# 日志文件配置
logging:
file:
name: logs/ruoyi-admin-9900.log
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
# 监控中心配置 - 端口漂移
spring.boot.admin.client:
enabled: true
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
instance.port: 9900
instance.name: ruoyi-admin-9900
username: @monitor.username@
password: @monitor.password@
# SnailJob 配置 - 端口漂移
snail-job:
enabled: true
group: "ruoyi_group_9900"
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 172.16.0.67
port: 17888
namespace: "prod"
port: 29900
host:
rpc-type: grpc
@@ -0,0 +1,44 @@
# 生产环境配置 - 9901端口实例
server:
port: 9901
servlet:
context-path: /
# 实例标识
spring:
application:
name: RuoYi-Vue-Plus-9901
# 日志文件配置
logging:
file:
name: logs/ruoyi-admin-9901.log
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
# 监控中心配置 - 端口漂移
spring.boot.admin.client:
enabled: true
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
instance.port: 9901
instance.name: ruoyi-admin-9901
username: @monitor.username@
password: @monitor.password@
# SnailJob 配置 - 端口漂移
snail-job:
enabled: true
group: "ruoyi_group_9901"
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 172.16.0.67
port: 17888
namespace: "prod"
port: 29901
host:
rpc-type: grpc
@@ -0,0 +1,44 @@
# 生产环境配置 - 9902端口实例
server:
port: 9902
servlet:
context-path: /
# 实例标识
spring:
application:
name: RuoYi-Vue-Plus-9902
# 日志文件配置
logging:
file:
name: logs/ruoyi-admin-9902.log
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
# 监控中心配置 - 端口漂移
spring.boot.admin.client:
enabled: true
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
instance.port: 9902
instance.name: ruoyi-admin-9902
username: @monitor.username@
password: @monitor.password@
# SnailJob 配置 - 端口漂移
snail-job:
enabled: true
group: "ruoyi_group_9902"
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 172.16.0.67
port: 17888
namespace: "prod"
port: 29902
host:
rpc-type: grpc
@@ -0,0 +1,44 @@
# 生产环境配置 - 9903端口实例
server:
port: 9903
servlet:
context-path: /
# 实例标识
spring:
application:
name: RuoYi-Vue-Plus-9903
# 日志文件配置
logging:
file:
name: logs/ruoyi-admin-9903.log
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
# 监控中心配置 - 端口漂移
spring.boot.admin.client:
enabled: true
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
instance.port: 9903
instance.name: ruoyi-admin-9903
username: @monitor.username@
password: @monitor.password@
# SnailJob 配置 - 端口漂移
snail-job:
enabled: true
group: "ruoyi_group_9903"
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 172.16.0.67
port: 17888
namespace: "prod"
port: 29903
host:
rpc-type: grpc
@@ -0,0 +1,353 @@
--- # 临时文件存储位置 避免临时文件被系统清理报错
spring.servlet.multipart.location: /ruoyi/server/temp
--- # 监控中心配置
spring.boot.admin.client:
# 增加客户端开关
enabled: true
url: http://47.121.125.41:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
username: @monitor.username@
password: @monitor.password@
--- # snail-job 配置
snail-job:
enabled: true
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "ruoyi_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 47.121.125.41
port: 17888
# 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段
namespace: ${spring.profiles.active}
# 随主应用端口漂移
port: 2${server.port}
# 客户端ip指定
host:
# RPC类型: netty, grpc
rpc-type: grpc
--- # 数据源配置
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
dynamic:
# 性能分析插件(有性能损耗 不建议生产环境使用)
p6spy: false
# 设置默认的数据源或者数据源组,默认值即为 master
primary: master
# 严格模式 匹配不到数据源则报错
strict: true
datasource:
# 主库数据源
master:
type: ${spring.datasource.type}
driverClassName: com.mysql.cj.jdbc.Driver
# jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
# rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
url: jdbc:mysql://47.99.164.66:3306/hzdyjs_xg?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
username: root
password: hn02le.34lkdLKD
# # 从库数据源
# slave:
# lazy: true
# type: ${spring.datasource.type}
# driverClassName: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
# username:
# password:
# oracle:
# type: ${spring.datasource.type}
# driverClassName: oracle.jdbc.OracleDriver
# url: jdbc:oracle:thin:@//localhost:1521/XE
# username: ROOT
# password: root
# postgres:
# type: ${spring.datasource.type}
# driverClassName: org.postgresql.Driver
# url: jdbc:postgresql://localhost:5432/postgres?useUnicode=true&characterEncoding=utf8&useSSL=true&autoReconnect=true&reWriteBatchedInserts=true
# username: root
# password: root
# sqlserver:
# type: ${spring.datasource.type}
# driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
# url: jdbc:sqlserver://localhost:1433;DatabaseName=tempdb;SelectMethod=cursor;encrypt=false;rewriteBatchedStatements=true
# username: SA
# password: root
hikari:
# 最大连接池数量
maxPoolSize: 30
# 最小空闲线程数量
minIdle: 10
# 配置获取连接等待超时的时间
connectionTimeout: 30000
# 校验超时时间
validationTimeout: 5000
# 空闲连接存活最大时间,默认10分钟
idleTimeout: 600000
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟
maxLifetime: 1800000
# 多久检查一次连接的活性
keepaliveTime: 30000
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: 47.99.164.66
# 端口,默认为6379
port: 16379
# 数据库索引
database: 3
# redis 密码必须配置
password: Hzdyjs@123456
# 连接超时时间
timeout: 10s
# 是否开启ssl
ssl.enabled: false
# redisson 配置
redisson:
# redis key前缀
keyPrefix:
# 线程池数量
threads: 16
# Netty线程池数量
nettyThreads: 32
# 单节点配置
singleServerConfig:
# 客户端名称 不能用中文
clientName: RuoYi-Vue-Plus
# 最小空闲连接数
connectionMinimumIdleSize: 32
# 连接池大小
connectionPoolSize: 64
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
--- # mail 邮件发送
mail:
enabled: false
host: smtp.163.com
port: 465
# 是否需要用户名密码验证
auth: true
# 发送方,遵循RFC-822标准
from: xxx@163.com
# 用户名(注意:如果使用foxmail邮箱,此处user为qq号)
user: xxx@163.com
# 密码(注意,某些邮箱需要为SMTP服务单独设置密码,详情查看相关帮助)
pass: xxxxxxxxxx
# 使用 STARTTLS安全连接,STARTTLS是对纯文本通信协议的扩展。
starttlsEnable: true
# 使用SSL安全连接
sslEnable: true
# SMTP超时时长,单位毫秒,缺省值不超时
timeout: 0
# Socket连接超时值,单位毫秒,缺省值不超时
connectionTimeout: 0
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
sms:
# 配置源类型用于标定配置来源(interface,yaml)
config-type: yaml
# 用于标定yml中的配置是否开启短信拦截,接口配置不受此限制
restricted: true
# 短信拦截限制单手机号每分钟最大发送,只对开启了拦截的配置有效
minute-max: 1
# 短信拦截限制单手机号每日最大发送量,只对开启了拦截的配置有效
account-max: 30
# 以下配置来自于 org.dromara.sms4j.provider.config.BaseConfig类中
blends:
# 唯一ID 用于发送短信寻找具体配置 随便定义别用中文即可
# 可以同时存在两个相同厂商 例如: ali1 ali2 两个不同的阿里短信账号 也可用于区分租户
config1:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: alibaba
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
access-key-id: 您的accessKey
# 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent
access-key-id: 您的accessKey
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
--- # 三方授权
justauth:
# 前端外网访问地址
address: http://localhost:80
type:
maxkey:
# maxkey 服务器地址
# 注意 如下均配置均不需要修改 maxkey 已经内置好了数据
server-url: http://sso.maxkey.top
client-id: 876892492581044224
client-secret: x1Y5MTMwNzIwMjMxNTM4NDc3Mzche8
redirect-uri: ${justauth.address}/social-callback?source=maxkey
topiam:
# topiam 服务器地址
server-url: http://127.0.0.1:1989/api/v1/authorize/y0q************spq***********8ol
client-id: 449c4*********937************759
client-secret: ac7***********1e0************28d
redirect-uri: ${justauth.address}/social-callback?source=topiam
scopes: [ openid, email, phone, profile ]
qq:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=qq
union-id: false
weibo:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=weibo
gitee:
client-id: 91436b7940090d09c72c7daf85b959cfd5f215d67eea73acbf61b6b590751a98
client-secret: 02c6fcfd70342980cd8dd2f2c06c1a350645d76c754d7a264c4e125f9ba915ac
redirect-uri: ${justauth.address}/social-callback?source=gitee
dingtalk:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=dingtalk
baidu:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=baidu
csdn:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=csdn
coding:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=coding
coding-group-name: xx
oschina:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=oschina
alipay_wallet:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=alipay_wallet
alipay-public-key: MIIB**************DAQAB
wechat_open:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_open
wechat_mp:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_mp
wechat_enterprise:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=wechat_enterprise
agent-id: 1000002
gitlab:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=gitlab
gitea:
# 前端改动 https://gitee.com/JavaLionLi/plus-ui/pulls/204
# gitea 服务器地址
server-url: https://demo.gitea.com
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: ${justauth.address}/social-callback?source=gitea
system:
file-cache:
# 是否启用文件缓存
enabled: true
# 最大缓存文件数量
max-cache-size: 100
# 最大缓存大小(字节)- 500MB
max-cache-size-bytes: 524288000
# 缓存过期时间(毫秒)- 30分钟
expire-time-ms: 1800000
# 最大缓存单个文件大小(字节)- 50MB
max-single-file-size: 52428800
# LRU淘汰比例(百分比)
evict-ratio: 20
# 是否启用缓存统计
enable-stats: true
# 缓存清理间隔(毫秒)- 5分钟
cleanup-interval-ms: 300000
# 配置说明:
# 1. enabled: 控制是否启用文件缓存功能
# 2. max-cache-size: 缓存的最大文件数量
# 3. max-cache-size-bytes: 缓存的最大总大小(字节)
# 4. expire-time-ms: 缓存条目的过期时间(毫秒)
# 5. max-single-file-size: 单个文件的最大缓存大小(字节)
# 6. evict-ratio: LRU淘汰时移除的条目比例(百分比)
# 7. enable-stats: 是否启用缓存统计功能
# 8. cleanup-interval-ms: 自动清理过期条目的间隔(毫秒)
# 性能优化建议:
# - 对于内存充足的环境,可以适当增加 max-cache-size-bytes
# - 对于频繁访问的小文件,可以增加 max-cache-size
# - 对于大文件较多的环境,可以调整 max-single-file-size
# - 对于访问频率较低的环境,可以适当减少 expire-time-ms
--- # SQL日志配置(用于调试班级名额查询问题)
logging:
level:
# 开启 MyBatis SQL 日志
org.dromara.jcbt.mapper: DEBUG
org.dromara.jcbt.service: DEBUG
# 开启 MyBatis-Plus SQL 日志
com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor: DEBUG
# 开启 HikariCP 连接池日志(可选)
com.zaxxer.hikari: DEBUG
# 开启 P6Spy SQL 日志(更详细的SQL执行日志)
p6spy: INFO
pattern:
# 控制台日志格式,增加线程名和方法名
console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx"
# MyBatis 配置
mybatis-plus:
configuration:
# 开启 SQL 日志打印
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 开启驼峰命名转换
map-underscore-to-camel-case: true
# 开启缓存
cache-enabled: true
# 开启懒加载
lazy-loading-enabled: true
# 开启积极懒加载
aggressive-lazy-loading: false
--- # 高德地图配置
amap:
# 高德地图API密钥(请替换为您的实际密钥)
key: 920750e421cd6357ddf60ee1e929e0dd
# 高德地图API基础URL
url: https://restapi.amap.com
@@ -5,7 +5,7 @@ spring.servlet.multipart.location: /ruoyi/server/temp
spring.boot.admin.client:
# 增加客户端开关
enabled: true
url: http://47.121.125.41:9090/admin
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
@@ -22,7 +22,7 @@ snail-job:
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表
token: "SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT"
server:
host: 47.121.125.41
host: 172.16.0.67
port: 17888
# 命名空间UUID 详见 script/sql/ry_job.sql `sj_namespace`表`unique_id`字段
namespace: ${spring.profiles.active}
@@ -52,9 +52,9 @@ spring:
driverClassName: com.mysql.cj.jdbc.Driver
# jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
# rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
url: jdbc:mysql://47.99.164.66:3306/hzdyjs_xg?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
url: jdbc:mysql://172.16.0.67:3306/hzdyjs_xg?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
username: root
password: hn02le.34lkdLKD
password: Hzdyjs@Root@123456
# # 从库数据源
# slave:
# lazy: true
@@ -83,7 +83,7 @@ spring:
# password: root
hikari:
# 最大连接池数量
maxPoolSize: 30
maxPoolSize: 60
# 最小空闲线程数量
minIdle: 10
# 配置获取连接等待超时的时间
@@ -94,20 +94,20 @@ spring:
idleTimeout: 600000
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟
maxLifetime: 1800000
# 多久检查一次连接的活性
keepaliveTime: 30000
# 多久检查一次连接的活性(增加间隔,减少检测频率,减少日志输出)
keepaliveTime: 300000
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: 47.99.164.66
host: 172.16.0.67
# 端口,默认为6379
port: 16379
# 数据库索引
database: 3
# redis 密码必须配置
password: Hzdyjs@123456
password: Hzdyjs@redis@123456
# 连接超时时间
timeout: 10s
# 是否开启ssl
@@ -318,15 +318,16 @@ system:
--- # SQL日志配置(用于调试班级名额查询问题)
logging:
level:
# 开启 MyBatis SQL 日志
org.dromara.jcbt.mapper: DEBUG
org.dromara.jcbt.service: DEBUG
# 开启 MyBatis-Plus SQL 日志
com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor: DEBUG
# 开启 HikariCP 连接池日志(可选)
com.zaxxer.hikari: DEBUG
# 开启 P6Spy SQL 日志(更详细的SQL执行日志)
p6spy: INFO
# 生产环境关闭 MyBatis SQL 日志,避免性能影响和敏感信息泄露
# org.dromara.jcbt.mapper: DEBUG
# org.dromara.jcbt.service: DEBUG
# 生产环境关闭 MyBatis-Plus SQL 日志
# com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor: DEBUG
# 关闭 HikariCP 连接池DEBUG日志,减少keepalive检测日志
com.zaxxer.hikari: WARN
com.zaxxer.hikari.pool.HikariPool: WARN
# 生产环境关闭 P6Spy SQL 日志,避免性能影响
# p6spy: INFO
pattern:
# 控制台日志格式,增加线程名和方法名
console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx"
@@ -334,7 +335,7 @@ logging:
# MyBatis 配置
mybatis-plus:
configuration:
# 开启 SQL 日志打印
# 生产环境关闭 SQL 日志打印,避免性能影响和敏感信息泄露
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 开启驼峰命名转换
map-underscore-to-camel-case: true
@@ -345,7 +346,7 @@ mybatis-plus:
# 开启积极懒加载
aggressive-lazy-loading: false
--- # 高德地图配置
--- # 高德地图配置 生产环境需要修改成学校的配置
amap:
# 高德地图API密钥(请替换为您的实际密钥)
key: 920750e421cd6357ddf60ee1e929e0dd
@@ -41,6 +41,9 @@ logging:
org.springframework: warn
org.mybatis.spring.mapper: error
org.apache.fury: warn
# 关闭HikariCP连接池DEBUG日志,减少keepalive检测日志
com.zaxxer.hikari: warn
com.zaxxer.hikari.pool.HikariPool: warn
# 关闭SQL日志调试,减少SELECT 1心跳检测日志
# org.dromara.cw.mapper.CwApprovalRecordMapper: info
# org.dromara.cw.service.impl.CwApprovalRecordServiceImpl: info
@@ -165,4 +165,9 @@
<appender-ref ref="file_sql" />
</logger>
<!-- 关闭HikariCP连接池的DEBUG日志,减少keepalive检测日志 -->
<logger name="com.zaxxer.hikari" level="WARN"/>
<logger name="com.zaxxer.hikari.pool.HikariPool" level="WARN"/>
<logger name="com.zaxxer.hikari.HikariConfig" level="WARN"/>
</configuration>
@@ -38,7 +38,7 @@ spring.boot.admin.client:
# 增加客户端开关
enabled: true
# 设置 Spring Boot Admin Server 地址
url: http://127.0.0.1:9090/admin
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
@@ -0,0 +1,40 @@
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://47.99.164.66:3306/hzdyjs_xg?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: hn02le.34lkdLKD
hikari:
connection-timeout: 30000
validation-timeout: 5000
minimum-idle: 10
maximum-pool-size: 20
idle-timeout: 600000
max-lifetime: 900000
keepaliveTime: 30000
--- # snail-job 服务端配置
snail-job:
# 拉取重试数据的每批次的大小
retry-pull-page-size: 1000
# 拉取重试数据的每批次的大小
job-pull-page-size: 1000
# 服务器端口
server-port: 17888
# 日志保存时间(单位: day)
log-storage: 7
rpc-type: grpc
--- # 监控中心配置
spring.boot.admin.client:
# 增加客户端开关
enabled: true
url: http://47.121.125.41:9090/admin
instance:
service-host-type: IP
metadata:
username: ${spring.boot.admin.client.username}
userpassword: ${spring.boot.admin.client.password}
username: @monitor.username@
password: @monitor.password@
@@ -2,9 +2,9 @@ spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://47.99.164.66:3306/hzdyjs_xg?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
url: jdbc:mysql://172.16.0.67:3306/hzdyjs_xg?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: hn02le.34lkdLKD
password: Hzdyjs@Root@123456
hikari:
connection-timeout: 30000
validation-timeout: 5000
@@ -30,7 +30,7 @@ snail-job:
spring.boot.admin.client:
# 增加客户端开关
enabled: true
url: http://47.121.125.41:9090/admin
url: http://172.16.0.67:9090/admin
instance:
service-host-type: IP
metadata:
@@ -80,8 +80,9 @@ public class StglMyController extends BaseController {
@GetMapping("/registrations")
public TableDataInfo<MyRegistrationVo> getMyRegistrations(
@Parameter(description = "分页参数") PageQuery pageQuery,
@Parameter(description = "报名类型(1-社团 2-志愿团队 3-活动)") @RequestParam(required = false) String registrationType) {
return stglMyService.queryMyRegistrations(pageQuery, registrationType);
@Parameter(description = "报名类型(1-社团 2-志愿团队 3-活动)") @RequestParam(required = false) String registrationType,
@Parameter(description = "名称(模糊查询)") @RequestParam(required = false) String name) {
return stglMyService.queryMyRegistrations(pageQuery, registrationType, name);
}
/**
@@ -30,7 +30,7 @@ public class StglClassBo extends BaseEntity {
/**
* 学生姓名
*/
private String name;
private String studentName;
/**
* 性别
@@ -217,11 +217,18 @@ public class StglActivityVo implements Serializable {
/**
* 活动状态
* 注意:此字段返回的是根据时间动态计算后的状态
*/
@ExcelProperty(value = "活动状态", converter = ExcelDictConvert.class, index = 24)
@ExcelDictFormat(dictType = "stgl_activity_status")
private String activityStatus;
/**
* 原始活动状态(数据库中存储的实际状态)
* 用于权限控制和业务逻辑判断
*/
private String originalActivityStatus;
/**
* 是否为已办活动
*/
@@ -54,10 +54,12 @@ public interface StglMyMapper {
*
* @param userId 用户ID
* @param registrationType 报名类型(可选)
* @param name 名称(可选,模糊查询)
* @return 报名记录列表
*/
List<MyRegistrationVo> selectMyRegistrations(@Param("userId") Long userId,
@Param("registrationType") String registrationType);
@Param("registrationType") String registrationType,
@Param("name") String name);
/**
* 分页查询我的报名记录(社团+志愿团队+活动)
@@ -65,11 +67,13 @@ public interface StglMyMapper {
* @param page 分页对象
* @param userId 用户ID
* @param registrationType 报名类型(可选)
* @param name 名称(可选,模糊查询)
* @return 分页结果
*/
Page<MyRegistrationVo> selectMyRegistrationsPage(@Param("page") Page<MyRegistrationVo> page,
@Param("userId") Long userId,
@Param("registrationType") String registrationType);
@Param("registrationType") String registrationType,
@Param("name") String name);
/**
* 查询我的报名记录总数
@@ -47,9 +47,10 @@ public interface IStglMyService {
*
* @param pageQuery 分页参数
* @param registrationType 报名类型(可选)
* @param name 名称(可选,模糊查询)
* @return 报名记录分页信息
*/
TableDataInfo<MyRegistrationVo> queryMyRegistrations(PageQuery pageQuery, String registrationType);
TableDataInfo<MyRegistrationVo> queryMyRegistrations(PageQuery pageQuery, String registrationType, String name);
/**
* 对活动进行评分
@@ -126,8 +126,8 @@ public class StglClassServiceImpl implements IStglClassService {
if (StringUtils.isNotBlank(bo.getClassId())) {
params.put("classId", bo.getClassId());
}
if (StringUtils.isNotBlank(bo.getName())) {
params.put("studentName", bo.getName());
if (StringUtils.isNotBlank(bo.getStudentName())) {
params.put("studentName", bo.getStudentName());
}
if (StringUtils.isNotBlank(bo.getStudentNo())) {
params.put("studentNo", bo.getStudentNo());
@@ -145,7 +145,7 @@ public class StglClassServiceImpl implements IStglClassService {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<StglClass> lqw = Wrappers.lambdaQuery();
lqw.eq(StringUtils.isNotBlank(bo.getClassId()), StglClass::getClassId, bo.getClassId());
lqw.like(StringUtils.isNotBlank(bo.getName()), StglClass::getName, bo.getName());
lqw.like(StringUtils.isNotBlank(bo.getStudentName()), StglClass::getName, bo.getStudentName());
lqw.like(StringUtils.isNotBlank(bo.getStudentNo()), StglClass::getStudentNo, bo.getStudentNo());
return lqw;
}
@@ -72,10 +72,10 @@ public class StglMyServiceImpl implements IStglMyService {
* 分页查询我的报名记录
*/
@Override
public TableDataInfo<MyRegistrationVo> queryMyRegistrations(PageQuery pageQuery, String registrationType) {
public TableDataInfo<MyRegistrationVo> queryMyRegistrations(PageQuery pageQuery, String registrationType, String name) {
Long userId = LoginHelper.getUserId();
Page<MyRegistrationVo> result = stglMyMapper.selectMyRegistrationsPage(pageQuery.build(), userId, registrationType);
Page<MyRegistrationVo> result = stglMyMapper.selectMyRegistrationsPage(pageQuery.build(), userId, registrationType, name);
return TableDataInfo.build(result);
}
@@ -267,8 +267,8 @@
NULL as approval_time,
NULL as approval_by,
m.remark,
CASE WHEN m.status = '1' THEN '正常' ELSE '待审核' END as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_member_role' AND dict_value = CAST(m.role_type AS CHAR) AND tenant_id = '000000') as role,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_member_status' AND dict_value = CAST(m.status AS CHAR) AND tenant_id = '000000') as current_status,
m.role_type as role,
c.president_name as organizer,
c.establish_date as time_info,
'' as location,
@@ -279,6 +279,9 @@
FROM stgl_member m
INNER JOIN stgl_club c ON m.relation_id = c.id AND m.member_type = 1 AND m.tenant_id = '000000' AND c.tenant_id = '000000'
WHERE m.user_id = #{userId} AND m.deleted = 0 AND c.deleted = 0
<if test="name != null and name != ''">
AND c.club_name LIKE CONCAT('%', #{name}, '%')
</if>
)
UNION ALL
(
@@ -293,7 +296,7 @@
NULL as approval_time,
NULL as approval_by,
m.remark,
CASE WHEN m.status = '1' THEN '正常' ELSE '待审核' END as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_member_status' AND dict_value = CAST(m.status AS CHAR) AND tenant_id = '000000') as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_volunteer_role' AND dict_value = CAST(m.role_type AS CHAR) AND tenant_id = '000000') as role,
vt.leader_name as organizer,
vt.establish_date as time_info,
@@ -305,6 +308,9 @@
FROM stgl_member m
INNER JOIN stgl_volunteer_team vt ON m.relation_id = vt.id AND m.member_type = 2 AND m.tenant_id = '000000' AND vt.tenant_id = '000000'
WHERE m.user_id = #{userId} AND m.deleted = 0 AND vt.deleted = 0
<if test="name != null and name != ''">
AND vt.team_name LIKE CONCAT('%', #{name}, '%')
</if>
)
UNION ALL
(
@@ -319,7 +325,7 @@
ar.approval_time,
ar.approval_by,
ar.remark,
CASE WHEN ar.approval_status = '1' THEN '已报名' ELSE '待审核' END as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_approval_status' AND dict_value = CAST(ar.approval_status AS CHAR) AND tenant_id = '000000') as current_status,
'参与者' as role,
a.organizer_name as organizer,
a.start_time as time_info,
@@ -331,10 +337,18 @@
FROM stgl_activity_registration ar
INNER JOIN stgl_activity a ON ar.activity_id = a.id AND ar.tenant_id = '000000' AND a.tenant_id = '000000'
WHERE ar.user_id = #{userId} AND ar.deleted = 0 AND a.deleted = 0
<if test="name != null and name != ''">
AND a.activity_name LIKE CONCAT('%', #{name}, '%')
</if>
)
<if test="registrationType != null and registrationType != ''">
WHERE registration_type = #{registrationType}
</if>
<where>
<if test="registrationType != null and registrationType != ''">
AND registration_type = #{registrationType}
</if>
<if test="name != null and name != ''">
AND target_name LIKE CONCAT('%', #{name}, '%')
</if>
</where>
ORDER BY registration_time DESC
</select>
@@ -379,8 +393,8 @@
NULL as approval_time,
NULL as approval_by,
m.remark,
CASE WHEN m.status = '1' THEN '正常' ELSE '待审核' END as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_member_role' AND dict_value = CAST(m.role_type AS CHAR) AND tenant_id = '000000') as role,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_member_status' AND dict_value = CAST(m.status AS CHAR) AND tenant_id = '000000') as current_status,
m.role_type as role,
c.president_name as organizer,
c.establish_date as time_info,
'' as location,
@@ -391,6 +405,9 @@
FROM stgl_member m
INNER JOIN stgl_club c ON m.relation_id = c.id AND m.member_type = 1 AND m.tenant_id = '000000' AND c.tenant_id = '000000'
WHERE m.user_id = #{userId} AND m.deleted = 0 AND c.deleted = 0
<if test="name != null and name != ''">
AND c.club_name LIKE CONCAT('%', #{name}, '%')
</if>
)
UNION ALL
(
@@ -405,7 +422,7 @@
NULL as approval_time,
NULL as approval_by,
m.remark,
CASE WHEN m.status = '1' THEN '正常' ELSE '待审核' END as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_member_status' AND dict_value = CAST(m.status AS CHAR) AND tenant_id = '000000') as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_volunteer_role' AND dict_value = CAST(m.role_type AS CHAR) AND tenant_id = '000000') as role,
vt.leader_name as organizer,
vt.establish_date as time_info,
@@ -417,6 +434,9 @@
FROM stgl_member m
INNER JOIN stgl_volunteer_team vt ON m.relation_id = vt.id AND m.member_type = 2 AND m.tenant_id = '000000' AND vt.tenant_id = '000000'
WHERE m.user_id = #{userId} AND m.deleted = 0 AND vt.deleted = 0
<if test="name != null and name != ''">
AND vt.team_name LIKE CONCAT('%', #{name}, '%')
</if>
)
UNION ALL
(
@@ -431,7 +451,7 @@
ar.approval_time,
ar.approval_by,
ar.remark,
CASE WHEN ar.approval_status = '1' THEN '已报名' ELSE '待审核' END as current_status,
(SELECT dict_label FROM sys_dict_data WHERE dict_type = 'stgl_approval_status' AND dict_value = CAST(ar.approval_status AS CHAR) AND tenant_id = '000000') as current_status,
'参与者' as role,
a.organizer_name as organizer,
a.start_time as time_info,
@@ -443,11 +463,19 @@
FROM stgl_activity_registration ar
INNER JOIN stgl_activity a ON ar.activity_id = a.id AND ar.tenant_id = '000000' AND a.tenant_id = '000000'
WHERE ar.user_id = #{userId} AND ar.deleted = 0 AND a.deleted = 0
<if test="name != null and name != ''">
AND a.activity_name LIKE CONCAT('%', #{name}, '%')
</if>
)
) all_registrations
<if test="registrationType != null and registrationType != ''">
WHERE registration_type = #{registrationType}
</if>
<where>
<if test="registrationType != null and registrationType != ''">
AND registration_type = #{registrationType}
</if>
<if test="name != null and name != ''">
AND target_name LIKE CONCAT('%', #{name}, '%')
</if>
</where>
ORDER BY registration_time DESC
</select>
@@ -34,6 +34,7 @@
<result property="needApproval" column="need_approval"/>
<result property="activityRange" column="activity_range"/>
<result property="activityStatus" column="activity_status"/>
<result property="originalActivityStatus" column="original_activity_status"/>
<result property="isFinished" column="is_finished"/>
<result property="summary" column="summary"/>
<result property="summaryAttachments" column="summary_attachments"/>
@@ -81,7 +82,26 @@
ELSE sa.registration_status
END AS registration_status,
sa.max_participants, sa.max_teams, sa.team_size_min, sa.team_size_max, sa.need_approval, sa.activity_range,
sa.activity_status, sa.is_finished, sa.summary, sa.summary_attachments, sa.achievements, sa.achievements_attachments, sa.import_source,
-- 动态计算活动状态:审批通过后根据时间自动更新状态
CASE
-- 手动控制的状态保持不变(草稿、待审批、已取消、已驳回)
WHEN sa.activity_status IN ('0', '2', '6', '7') THEN sa.activity_status
-- 审批通过后的状态根据时间自动计算
WHEN sa.activity_status IN ('3', '4', '5') THEN
CASE
<![CDATA[WHEN NOW() < sa.start_time THEN '3']]> -- 已通过(未开始)
<![CDATA[WHEN NOW() >= sa.start_time AND NOW() <= sa.end_time THEN '4']]> -- 进行中
<![CDATA[WHEN NOW() > sa.end_time THEN '5']]> -- 已结束
ELSE sa.activity_status
END
-- 其他情况保持原状态
ELSE sa.activity_status
END AS activity_status,
-- 返回原始活动状态(用于权限控制)
sa.activity_status AS original_activity_status,
sa.is_finished, sa.summary, sa.summary_attachments, sa.achievements, sa.achievements_attachments, sa.import_source,
sa.remark, sa.create_by, sa.create_time, sa.update_by, sa.update_time,
-- 关联查询创建者姓名
su.nick_name AS create_by_name,
@@ -215,6 +235,26 @@
<if test="activity.organizerType != null and activity.organizerType != ''">
AND sa.organizer_type = #{activity.organizerType}
</if>
<if test="activity.activityStatus != null and activity.activityStatus != ''">
AND (
CASE
-- 手动控制的状态保持不变(草稿、待审批、已取消、已驳回)
WHEN sa.activity_status IN ('0', '2', '6', '7') THEN sa.activity_status
-- 审批通过后的状态根据时间自动计算
WHEN sa.activity_status IN ('3', '4', '5') THEN
CASE
<![CDATA[WHEN NOW() < sa.start_time THEN '3']]> -- 已通过(未开始)
<![CDATA[WHEN NOW() >= sa.start_time AND NOW() <= sa.end_time THEN '4']]> -- 进行中
<![CDATA[WHEN NOW() > sa.end_time THEN '5']]> -- 已结束
ELSE sa.activity_status
END
-- 其他情况保持原状态
ELSE sa.activity_status
END
) = #{activity.activityStatus}
</if>
<if test="activity.registrationStatus != null and activity.registrationStatus != ''">
AND (
CASE
@@ -81,13 +81,49 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
GROUP BY m.user_id
) volunteer_stats ON s.id = volunteer_stats.user_id
LEFT JOIN (
SELECT
ar.user_id,
COUNT(DISTINCT ar.activity_id) as activity_count
FROM stgl_activity_registration ar
WHERE ar.deleted = 0
AND ar.approval_status = 1
GROUP BY ar.user_id
-- 修改活动统计逻辑:同时统计个人报名和团队报名的活动数量
SELECT
user_id,
COUNT(DISTINCT activity_id) as activity_count
FROM (
-- 个人报名的活动
SELECT
ar.user_id,
ar.activity_id
FROM stgl_activity_registration ar
WHERE ar.deleted = 0
AND ar.approval_status = 1
AND ar.tenant_id = '000000'
UNION
-- 团队报名的活动(通过团队成员信息)
SELECT
JSON_UNQUOTE(JSON_EXTRACT(ati.team_members, CONCAT('$[', numbers.n, '].userId'))) as user_id,
ati.activity_id
FROM stgl_activity_team_info ati
CROSS JOIN (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19
) numbers
WHERE ati.deleted = 0
AND ati.tenant_id = '000000'
AND JSON_UNQUOTE(JSON_EXTRACT(ati.team_members, CONCAT('$[', numbers.n, '].userId'))) IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(ati.team_members, CONCAT('$[', numbers.n, '].userId'))) != ''
AND EXISTS (
SELECT 1 FROM stgl_activity_registration team_ar
WHERE team_ar.team_id = ati.id
AND team_ar.deleted = 0
AND team_ar.approval_status = 1
AND team_ar.registration_type = 2
AND team_ar.tenant_id = '000000'
)
) all_activities
WHERE user_id IS NOT NULL
AND user_id != ''
GROUP BY user_id
) activity_stats ON s.id = activity_stats.user_id
<where>
s.deleted = 0
@@ -174,7 +210,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND m.deleted = 0
AND m.status = 2
AND vt.deleted = 0
AND vt.status = 1
ORDER BY vt.create_time DESC
</select>
@@ -1136,29 +1136,60 @@ public class XgStudentMsgServiceImpl implements IXgStudentMsgService {
return;
}
// 1. 批量更新学生信息表的is_boarder字段为0,同时清空宿舍楼栋和房间号
// 1. 查询学生信息,检查哪些学生已经分配了宿舍
List<XgStudentMsg> students = baseMapper.selectList(
Wrappers.lambdaQuery(XgStudentMsg.class)
.in(XgStudentMsg::getId, studentIds)
.eq(XgStudentMsg::getDeleted, 0));
// 2. 过滤出可以取消床位申请的学生(宿舍楼栋、房间号、床位号都为空的学生)
List<Long> allowedStudentIds = new ArrayList<>();
List<Long> skippedStudentIds = new ArrayList<>();
for (XgStudentMsg student : students) {
// 检查宿舍楼栋、房间号、床位号是否都不为空
boolean hasAssignedBed = StringUtils.isNotBlank(student.getDormitoryBuilding()) ||
StringUtils.isNotBlank(student.getDormitoryRoom()) ||
StringUtils.isNotBlank(student.getExtField1()); // 床位号字段
if (hasAssignedBed) {
skippedStudentIds.add(student.getId());
log.warn("学生 {} (ID: {}) 已分配宿舍,跳过取消床位申请操作", student.getName(), student.getId());
} else {
allowedStudentIds.add(student.getId());
}
}
// 3. 如果没有学生可以取消床位申请,直接返回
if (allowedStudentIds.isEmpty()) {
log.warn("所有学生都已分配宿舍,无法执行取消床位申请操作");
return;
}
// 4. 批量更新允许取消床位申请的学生信息
LambdaQueryWrapper<XgStudentMsg> updateWrapper = Wrappers.lambdaQuery();
updateWrapper.in(XgStudentMsg::getId, studentIds);
updateWrapper.in(XgStudentMsg::getId, allowedStudentIds);
XgStudentMsg updateEntity = new XgStudentMsg();
updateEntity.setIsBoarder(0L);
updateEntity.setDormitoryBuilding(null); // 清空宿舍楼栋
updateEntity.setDormitoryRoom(null); // 清空宿舍房间号
int updateCount = baseMapper.update(updateEntity, updateWrapper);
log.info("批量取消床位 - 更新学生住宿状态并清空宿舍信息完成,共更新 {} 条学生记录", updateCount);
log.info("批量取消床位 - 更新学生住宿状态完成,共更新 {} 条学生记录,跳过 {} 条已分配宿舍的学生记录",
updateCount, skippedStudentIds.size());
// 2. 更新宿舍床位学生表,将状态改为1(已退宿),设置退宿时间为当前时间
LambdaQueryWrapper<XgDormBedStudent> bedUpdateWrapper = Wrappers.lambdaQuery();
bedUpdateWrapper.in(XgDormBedStudent::getStudentId, studentIds);
bedUpdateWrapper.eq(XgDormBedStudent::getStatus, "0"); // 只更新状态为正常的记录
// 5. 更新宿舍床位学生表,将状态改为1(已退宿),设置退宿时间为当前时间
// LambdaQueryWrapper<XgDormBedStudent> bedUpdateWrapper =
// Wrappers.lambdaQuery();
// bedUpdateWrapper.in(XgDormBedStudent::getStudentId, studentIds);
// bedUpdateWrapper.eq(XgDormBedStudent::getStatus, "0"); // 只更新状态为正常的记录
XgDormBedStudent bedUpdateEntity = new XgDormBedStudent();
bedUpdateEntity.setStatus("1"); // 设置为已退宿
bedUpdateEntity.setCheckoutTime(new Date()); // 设置退宿时间为当前时间
// XgDormBedStudent bedUpdateEntity = new XgDormBedStudent();
// bedUpdateEntity.setStatus("1"); // 设置为已退宿
// bedUpdateEntity.setCheckoutTime(new Date()); // 设置退宿时间为当前时间
int bedUpdateCount = xgDormBedStudentMapper.update(bedUpdateEntity, bedUpdateWrapper);
log.info("批量取消床位 - 更新宿舍床位状态完成,共更新 {} 条床位记录", bedUpdateCount);
// int bedUpdateCount = xgDormBedStudentMapper.update(bedUpdateEntity,
// bedUpdateWrapper);
// log.info("批量取消床位 - 更新宿舍床位状态完成,共更新 {} 条床位记录", bedUpdateCount);
}
/**
@@ -431,7 +431,23 @@ public class XljkConsultationAppointmentServiceImpl implements IXljkConsultation
slot.put("availableCount", totalDetailSlots - bookedCount); // 可用数量
slot.put("isExpired", isExpired); // 是否已过期
// TODO: 添加咨询师姓名等信息
// 查询咨询师姓名
if (schedule.getCounselorId() != null) {
try {
String sql = "SELECT nick_name FROM sys_user WHERE user_id = ?";
List<Map<String, Object>> result = jdbcTemplate.queryForList(sql, schedule.getCounselorId());
if (!result.isEmpty()) {
slot.put("counselorName", result.get(0).get("nick_name"));
} else {
slot.put("counselorName", null);
}
} catch (Exception e) {
log.warn("查询咨询师姓名失败, counselorId: {}, error: {}", schedule.getCounselorId(), e.getMessage());
slot.put("counselorName", null);
}
} else {
slot.put("counselorName", null);
}
availableSlots.add(slot);
}
@@ -0,0 +1,252 @@
# SSE关闭接口406错误解决方案
## 🚨 问题描述
用户在前台退出时,SSE关闭接口返回406 Not Acceptable错误:
```
Request URL: http://hzdyjsweb.camasort.com/prod-api/resource/sse/close
Request Method: GET
Status Code: 406 Not Acceptable
```
## 🔍 问题分析
### 1. 错误原因分析
**406 Not Acceptable错误**通常表示:
- 服务器无法根据请求的内容特性完成请求
- 请求的资源的内容特性无法满足请求头中的条件
- 用户认证信息缺失或无效
### 2. 根本原因
1. **认证时机问题**:
- 前端在logout时先调用SSE关闭接口,再调用logout接口
- 此时token可能已经失效或即将失效
- 后端`LoginHelper.getUserId()`可能返回null
2. **后端接口设计问题**:
- SSE关闭接口使用了`@SaIgnore`注解,但仍然需要获取用户信息
- 缺少对空值和异常的处理
- 没有优雅降级机制
3. **nginx代理配置问题**:
- SSE关闭接口可能没有正确的代理配置
- 缺少专门的错误处理
## 🛠️ 解决方案
### 方案一:修复后端SSE关闭接口(已实施)
#### 1. 优化错误处理
```java
/**
* 关闭 SSE 连接
*/
@SaIgnore
@GetMapping(value = "${sse.path}/close")
public R<Void> close() {
try {
// 尝试获取token和用户ID,如果获取失败则直接返回成功
String tokenValue = null;
Long userId = null;
try {
tokenValue = StpUtil.getTokenValue();
userId = LoginHelper.getUserId();
} catch (Exception e) {
// 如果获取用户信息失败,记录日志但不抛出异常
log.warn("Failed to get user info for SSE close: {}", e.getMessage());
return R.ok();
}
// 如果token或userId为空,直接返回成功
if (tokenValue == null || userId == null) {
log.info("Token or userId is null, skipping SSE disconnect");
return R.ok();
}
sseEmitterManager.disconnect(userId, tokenValue);
return R.ok();
} catch (Exception e) {
log.error("Error during SSE close: {}", e.getMessage(), e);
// 即使出错也返回成功,避免影响用户退出流程
return R.ok();
}
}
```
#### 2. 关键改进点
- **添加异常捕获**:捕获所有可能的异常
- **空值检查**:检查token和userId是否为空
- **优雅降级**:即使出错也返回成功状态
- **日志记录**:记录详细的错误信息用于调试
### 方案二:优化前端logout逻辑(已实施)
#### 1. 添加错误处理
```typescript
/**
* 注销
*/
export function logout() {
// 先尝试关闭SSE连接,但不阻塞logout流程
if (import.meta.env.VITE_APP_SSE === 'true') {
request({
url: '/resource/sse/close',
method: 'get'
}).catch((error) => {
// SSE关闭失败不影响用户退出,只记录日志
console.warn('SSE close failed during logout:', error);
});
}
// 执行正常的logout流程
return request({
url: '/auth/logout',
method: 'post'
});
}
```
#### 2. 关键改进点
- **非阻塞调用**:SSE关闭失败不影响logout流程
- **错误捕获**:捕获SSE关闭接口的错误
- **日志记录**:记录错误信息但不影响用户体验
### 方案三:更新nginx配置(已实施)
#### 1. 添加SSE关闭接口专用配置
```nginx
# SSE关闭接口专用配置
location ^~ /prod-api/resource/sse/close {
proxy_pass http://47.121.125.41:8080/resource/sse/close;
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配置(不需要SSE特殊配置)
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# CORS处理
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,clientid';
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
return 204;
}
}
```
#### 2. 配置说明
- **独立配置**:为SSE关闭接口提供独立的代理配置
- **合理超时**:设置30秒超时,适合普通API调用
- **CORS支持**:确保跨域请求正常工作
## 📋 部署步骤
### 1. 更新后端代码
```bash
# 重新编译后端项目
mvn clean compile
# 重启后端服务
systemctl restart your-backend-service
```
### 2. 更新前端代码
```bash
# 重新构建前端项目
npm run build
# 部署到nginx目录
cp -r dist/* /root/hzdyjsweb9091/dist/
```
### 3. 更新nginx配置
```bash
# 测试nginx配置
nginx -t
# 重新加载配置
nginx -s reload
```
## 🔧 验证方法
### 1. 功能验证
1. **正常登录**:确保用户能够正常登录
2. **SSE连接**:检查SSE连接是否正常建立
3. **正常退出**:测试用户退出流程
4. **错误处理**:检查控制台是否有错误信息
### 2. 错误场景测试
1. **Token失效退出**:在token即将失效时退出
2. **网络异常退出**:在网络不稳定的情况下退出
3. **并发退出**:多个用户同时退出
### 3. 日志检查
```bash
# 检查后端日志
tail -f /path/to/backend/logs/application.log | grep -i "sse"
# 检查nginx错误日志
tail -f /var/log/nginx/hzdyjsweb.camasort.com.error.log
```
## 🎯 预期效果
### 1. 问题解决
- ✅ 消除406错误
- ✅ 用户退出流程顺畅
- ✅ 不影响用户体验
### 2. 系统稳定性
- ✅ SSE连接正确关闭
- ✅ 资源正确释放
- ✅ 错误处理完善
### 3. 监控指标
- SSE关闭成功率:100%
- 用户退出成功率:100%
- 错误日志减少:显著减少
## 📚 相关文档
- [SSE生产环境配置问题解决方案](../SSE生产环境配置问题解决方案.md)
- [nginx配置文档](../../部署文档/nginx配置/)
- [前端环境变量配置建议](../../部署文档/nginx配置/前端环境变量配置建议.md)
## 🔄 后续优化
1. **监控告警**:添加SSE关闭失败监控
2. **性能优化**:优化SSE连接管理
3. **用户体验**:添加退出进度提示
4. **错误恢复**:实现自动重试机制
---
**更新时间**:2025-01-03
**维护人员**:系统管理员
**文档版本**:v1.0
@@ -0,0 +1,256 @@
# SSE生产环境配置问题解决方案
## 🚨 问题描述
在生产环境中,通过nginx配置后,前端无法正常接收SSE(Server-Sent Events)消息推送。
## 🔍 问题分析
### 1. 技术架构分析
**后端SSE实现**:
- 使用Spring Boot的`SseEmitter`实现
- SSE端点:`/resource/sse`
- 支持用户认证和Token验证
- 使用Redis进行消息广播
**前端SSE实现**:
- 使用Vue 3的`useEventSource`组合式API
- 自动重连机制(10次重试,3秒间隔)
- 消息处理:显示通知和存储到状态管理
**nginx代理配置**:
- 当前配置缺少专门的SSE代理处理
- 超时设置可能不够长
- 缺少SSE特定的响应头配置
### 2. 根本原因
1. **nginx配置不完整**:缺少专门的SSE代理配置
2. **超时设置不足**:SSE是长连接,需要更长的超时时间
3. **响应头配置缺失**:SSE需要特定的Content-Type和缓存控制
4. **代理优先级问题**:SSE路由可能被其他配置覆盖
## 🛠️ 解决方案
### 方案一:优化nginx配置(推荐)
#### 1. 添加专门的SSE代理配置
```nginx
# SSE专用代理配置(重要:必须放在其他API配置之前)
location ^~ /prod-api/resource/sse {
proxy_pass http://47.121.125.41:8080/resource/sse;
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;
# SSE特殊配置
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
# SSE需要更长的超时时间
proxy_connect_timeout 60s;
proxy_send_timeout 86400s; # 24小时
proxy_read_timeout 86400s; # 24小时
# SSE响应头设置
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_set_header Connection "keep-alive";
# CORS处理
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,clientid';
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
return 204;
}
}
```
#### 2. 关键配置说明
- **`proxy_buffering off`**:禁用nginx缓冲,确保SSE消息实时传输
- **`proxy_cache off`**:禁用缓存,避免消息被缓存
- **`proxy_read_timeout 86400s`**:设置24小时超时,支持长连接
- **`proxy_set_header Connection ""`**:清除Connection头,避免代理问题
- **`add_header Access-Control-Allow-Credentials true`**:支持跨域认证
### 方案二:修改前端配置
#### 1. 环境变量配置
```bash
# 推荐配置:通过nginx代理
VITE_APP_BASE_API = '/prod-api'
VITE_APP_SSE = true
VITE_APP_WEBSOCKET = false
```
#### 2. 备选配置:直接访问后端
```bash
# 备选配置:直接访问后端
VITE_APP_BASE_API = 'http://47.121.125.41:8080'
VITE_APP_SSE = true
VITE_APP_WEBSOCKET = false
```
## 📋 部署步骤
### 1. 更新nginx配置
```bash
# 备份原配置
cp /etc/nginx/conf.d/hzdyjsweb.camasort.com.conf /etc/nginx/conf.d/hzdyjsweb.camasort.com.conf.backup
# 更新配置文件
vim /etc/nginx/conf.d/hzdyjsweb.camasort.com.conf
# 测试配置
nginx -t
# 重新加载配置
nginx -s reload
```
### 2. 更新前端配置
```bash
# 进入前端项目目录
cd /path/to/frontend
# 更新环境变量
vim .env.production
# 重新构建
npm run build
# 部署到nginx目录
cp -r dist/* /root/hzdyjsweb9091/dist/
```
### 3. 重启服务
```bash
# 重启后端服务(如果需要)
systemctl restart your-backend-service
# 检查nginx状态
systemctl status nginx
```
## 🔧 故障排查
### 1. 检查nginx配置
```bash
# 测试nginx配置
nginx -t
# 查看nginx错误日志
tail -f /var/log/nginx/error.log
tail -f /var/log/nginx/hzdyjsweb.camasort.com.error.log
# 查看nginx访问日志
tail -f /var/log/nginx/hzdyjsweb.camasort.com.access.log
```
### 2. 检查SSE连接
#### 浏览器开发者工具
1. 打开F12开发者工具
2. 切换到Network面板
3. 筛选XHR/Fetch请求
4. 查找SSE连接(通常显示为EventStream)
5. 检查连接状态和响应头
#### 控制台错误信息
```javascript
// 检查SSE连接状态
console.log('SSE连接状态:', EventSource.readyState);
// 0: CONNECTING, 1: OPEN, 2: CLOSED
```
### 3. 测试SSE端点
#### 直接测试后端
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "clientid: e5cd7e4891bf95d1d19206ce24a7b32e" \
http://47.121.125.41:8080/resource/sse
```
#### 通过nginx测试
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
-H "clientid: e5cd7e4891bf95d1d19206ce24a7b32e" \
http://hzdyjsweb.camasort.com/prod-api/resource/sse
```
### 4. 检查后端SSE服务
#### 查看后端日志
```bash
# 查看应用日志
tail -f /path/to/backend/logs/application.log
# 查看SSE相关日志
grep -i "sse" /path/to/backend/logs/application.log
```
#### 检查Redis连接
```bash
# 连接Redis
redis-cli
# 检查SSE主题订阅
SUBSCRIBE global:sse
```
## 🎯 验证方法
### 1. 功能验证
1. **登录系统**:确保用户已登录
2. **触发SSE消息**:通过工作流或其他功能触发消息推送
3. **检查通知**:查看是否收到实时通知
4. **检查控制台**:查看SSE连接状态和消息
### 2. 性能验证
1. **连接稳定性**:长时间保持连接,检查是否断开
2. **消息延迟**:测试消息推送的实时性
3. **并发连接**:测试多用户同时连接的情况
### 3. 监控指标
- SSE连接数
- 消息推送成功率
- 连接断开频率
- 响应时间
## 📚 相关文档
- [nginx SSE代理配置官方文档](http://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [Spring Boot SSE官方文档](https://docs.spring.io/spring-framework/reference/web/webmvc/controller/ann-methods/sse.html)
- [Vue 3 EventSource使用指南](https://vueuse.org/core/useEventSource/)
## 🔄 后续优化
1. **添加SSE连接监控**:监控连接状态和性能
2. **实现连接池管理**:优化连接资源使用
3. **添加消息重试机制**:提高消息推送可靠性
4. **优化nginx配置**:根据实际使用情况调整超时和缓冲设置
---
**更新时间**:2025-01-03
**维护人员**:系统管理员
**文档版本**:v1.0
@@ -41,36 +41,95 @@ server {
add_header Pragma "no-cache";
}
# Warm-Flow工作流UI代理配置(重要:必须放在Vue路由配置之前,使用^~确保优先级)
location ^~ /prod-api/warm-flow-ui/ {
proxy_pass http://47.121.125.41:8080/warm-flow-ui/;
# SSE专用代理配置(重要:必须放在其他API配置之前)
location ^~ /prod-api/resource/sse {
proxy_pass http://47.121.125.41:8080/resource/sse;
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;
# 支持WebSocket和SSE
# SSE特殊配置
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
# 超时设置
# SSE需要更长的超时时间
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s; # 24小时
proxy_read_timeout 86400s; # 24小时
# SSE响应头设置
proxy_set_header Accept "text/event-stream";
proxy_set_header Cache-Control "no-cache";
proxy_set_header Connection "keep-alive";
# CORS处理
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,clientid';
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# SSE关闭接口专用配置
location ^~ /prod-api/resource/sse/close {
proxy_pass http://47.121.125.41:8080/resource/sse/close;
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配置(不需要SSE特殊配置)
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# CORS处理
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,clientid';
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# Warm-Flow工作流UI代理配置(重要:必须放在Vue路由配置之前)
location ^~ /prod-api/warm-flow-ui/ {
proxy_pass http://47.121.125.41:8080/warm-flow-ui/;
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;
# 支持WebSocket和SSE
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 86400s;
# CORS处理
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,clientid';
if ($request_method = 'OPTIONS') {
return 204;
}
}
# API代理配置(使用^~确保优先级,避免被Vue路由配置干扰)
location ^~ /prod-api/ {
proxy_pass http://47.121.125.41:8080/;
@@ -0,0 +1,135 @@
version: '3.8'
services:
# 9900端口实例
ruoyi-admin-9900:
image: openjdk:17-jre-slim
container_name: ruoyi-admin-9900
ports:
- "9900:9900"
volumes:
- ./ruoyi-admin.jar:/app/ruoyi-admin.jar
- ./logs:/app/logs
- ./temp:/app/temp
working_dir: /app
command: >
java -Xms512m -Xmx1024m
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs/
-Dspring.profiles.active=prod-9900
-Dserver.port=9900
-jar ruoyi-admin.jar
environment:
- SPRING_PROFILES_ACTIVE=prod-9900
- SERVER_PORT=9900
restart: unless-stopped
networks:
- ruoyi-network
# 9901端口实例
ruoyi-admin-9901:
image: openjdk:17-jre-slim
container_name: ruoyi-admin-9901
ports:
- "9901:9901"
volumes:
- ./ruoyi-admin.jar:/app/ruoyi-admin.jar
- ./logs:/app/logs
- ./temp:/app/temp
working_dir: /app
command: >
java -Xms512m -Xmx1024m
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs/
-Dspring.profiles.active=prod-9901
-Dserver.port=9901
-jar ruoyi-admin.jar
environment:
- SPRING_PROFILES_ACTIVE=prod-9901
- SERVER_PORT=9901
restart: unless-stopped
networks:
- ruoyi-network
# 9902端口实例
ruoyi-admin-9902:
image: openjdk:17-jre-slim
container_name: ruoyi-admin-9902
ports:
- "9902:9902"
volumes:
- ./ruoyi-admin.jar:/app/ruoyi-admin.jar
- ./logs:/app/logs
- ./temp:/app/temp
working_dir: /app
command: >
java -Xms512m -Xmx1024m
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs/
-Dspring.profiles.active=prod-9902
-Dserver.port=9902
-jar ruoyi-admin.jar
environment:
- SPRING_PROFILES_ACTIVE=prod-9902
- SERVER_PORT=9902
restart: unless-stopped
networks:
- ruoyi-network
# 9903端口实例
ruoyi-admin-9903:
image: openjdk:17-jre-slim
container_name: ruoyi-admin-9903
ports:
- "9903:9903"
volumes:
- ./ruoyi-admin.jar:/app/ruoyi-admin.jar
- ./logs:/app/logs
- ./temp:/app/temp
working_dir: /app
command: >
java -Xms512m -Xmx1024m
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs/
-Dspring.profiles.active=prod-9903
-Dserver.port=9903
-jar ruoyi-admin.jar
environment:
- SPRING_PROFILES_ACTIVE=prod-9903
- SERVER_PORT=9903
restart: unless-stopped
networks:
- ruoyi-network
# Nginx负载均衡器
nginx:
image: nginx:alpine
container_name: ruoyi-nginx
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- ruoyi-admin-9900
- ruoyi-admin-9901
- ruoyi-admin-9902
- ruoyi-admin-9903
restart: unless-stopped
networks:
- ruoyi-network
networks:
ruoyi-network:
driver: bridge
volumes:
logs:
temp:
@@ -0,0 +1,116 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;
# 基础配置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 上游服务器配置
upstream ruoyi_backend {
# 轮询负载均衡
server ruoyi-admin-9900:9900 weight=1 max_fails=3 fail_timeout=30s;
server ruoyi-admin-9901:9901 weight=1 max_fails=3 fail_timeout=30s;
server ruoyi-admin-9902:9902 weight=1 max_fails=3 fail_timeout=30s;
server ruoyi-admin-9903:9903 weight=1 max_fails=3 fail_timeout=30s;
# 健康检查
keepalive 32;
}
# 主服务器配置
server {
listen 80;
server_name localhost;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
proxy_pass http://ruoyi_backend;
}
# API请求
location / {
proxy_pass http://ruoyi_backend;
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_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# 缓冲设置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# 健康检查端点
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# 状态监控页面
location /nginx_status {
stub_status on;
access_log off;
allow 127.0.0.1;
deny all;
}
}
# 监控和日志配置
server {
listen 8080;
server_name localhost;
location / {
return 200 "Nginx Load Balancer Status\n";
add_header Content-Type text/plain;
}
location /status {
stub_status on;
access_log off;
}
}
}
@@ -0,0 +1,368 @@
#!/bin/bash
# 扩展服务启动停止一体化脚本
# 使用方法: ./start-extend-services.sh [start|stop|restart|status] [service_name]
# 服务名称: monitor, snailjob, all
# 配置变量
MONITOR_JAR="ruoyi-monitor-admin.jar"
SNAILJOB_JAR="ruoyi-snailjob-server.jar"
JAVA_OPTS="-Xms2048m -Xmx2048m -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=1024m"
JAVA_OPTS="$JAVA_OPTS -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
JAVA_OPTS="$JAVA_OPTS -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs/"
# 服务配置
declare -A SERVICES
SERVICES[monitor]="9090:ruoyi-monitor-admin:prod"
SERVICES[snailjob]="8800:ruoyi-snailjob-server:prod"
# 日志目录
LOG_DIR="logs"
mkdir -p $LOG_DIR
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# 检查JAR文件是否存在
check_jar() {
local service=$1
local jar_file=""
case $service in
monitor)
jar_file="./$MONITOR_JAR"
;;
snailjob)
jar_file="./$SNAILJOB_JAR"
;;
esac
if [ ! -f "$jar_file" ]; then
print_message $RED "错误: $jar_file 文件不存在!"
print_message $YELLOW "请先编译项目: mvn clean package -Pprod"
return 1
fi
return 0
}
# 启动单个服务
start_service() {
local service=$1
local config=${SERVICES[$service]}
local port=$(echo $config | cut -d: -f1)
local app_name=$(echo $config | cut -d: -f2)
local profile=$(echo $config | cut -d: -f3)
local pid_file="logs/${app_name}.pid"
local log_file="logs/${app_name}.log"
# 检查是否已经运行
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if ps -p $pid > /dev/null 2>&1; then
print_message $YELLOW "服务 $service 已经在运行中 (PID: $pid)"
return
else
# PID文件存在但进程不存在,清理PID文件
rm -f "$pid_file"
fi
fi
# 检查JAR文件
if ! check_jar $service; then
return 1
fi
print_message $GREEN "正在启动服务 $service (端口: $port, 配置文件: $profile)..."
# 启动应用
case $service in
monitor)
local jar_path="../ruoyi-extend/ruoyi-monitor-admin/target/$MONITOR_JAR"
nohup java $JAVA_OPTS \
-Dspring.profiles.active=$profile \
-Dserver.port=$port \
-jar "$jar_path" \
> "$log_file" 2>&1 &
;;
snailjob)
local jar_path="../ruoyi-extend/ruoyi-snailjob-server/target/$SNAILJOB_JAR"
nohup java $JAVA_OPTS \
-Dspring.profiles.active=$profile \
-Dserver.port=$port \
-jar "$jar_path" \
> "$log_file" 2>&1 &
;;
esac
local new_pid=$!
echo $new_pid > "$pid_file"
# 等待启动
sleep 5
# 检查启动状态
if ps -p $new_pid > /dev/null 2>&1; then
print_message $GREEN "服务 $service 启动成功 (PID: $new_pid)"
print_message $GREEN "访问地址: http://localhost:$port"
print_message $GREEN "日志文件: $log_file"
# 显示服务特定信息
case $service in
monitor)
print_message $BLUE "监控中心: http://localhost:$port/admin"
;;
snailjob)
print_message $BLUE "任务调度: http://localhost:$port/snail-job"
;;
esac
else
print_message $RED "服务 $service 启动失败,请检查日志文件: $log_file"
rm -f "$pid_file"
fi
}
# 停止单个服务
stop_service() {
local service=$1
local config=${SERVICES[$service]}
local app_name=$(echo $config | cut -d: -f2)
local pid_file="logs/${app_name}.pid"
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if ps -p $pid > /dev/null 2>&1; then
print_message $YELLOW "正在停止服务 $service (PID: $pid)..."
kill $pid
# 等待进程结束
local count=0
while ps -p $pid > /dev/null 2>&1 && [ $count -lt 30 ]; do
sleep 1
count=$((count + 1))
done
# 强制杀死进程
if ps -p $pid > /dev/null 2>&1; then
print_message $YELLOW "强制停止服务 $service..."
kill -9 $pid
fi
rm -f "$pid_file"
print_message $GREEN "服务 $service 已停止"
else
print_message $YELLOW "服务 $service 未运行"
rm -f "$pid_file"
fi
else
print_message $YELLOW "服务 $service 未运行"
fi
}
# 重启单个服务
restart_service() {
local service=$1
print_message $YELLOW "重启服务 $service..."
stop_service $service
sleep 3
start_service $service
}
# 查看服务状态
status_service() {
local service=$1
local config=${SERVICES[$service]}
local port=$(echo $config | cut -d: -f1)
local app_name=$(echo $config | cut -d: -f2)
local pid_file="logs/${app_name}.pid"
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if ps -p $pid > /dev/null 2>&1; then
local memory=$(ps -o rss= -p $pid | awk '{print $1/1024 " MB"}')
local uptime=$(ps -o etime= -p $pid)
print_message $GREEN "服务 $service: 运行中 (PID: $pid, 端口: $port, 内存: $memory, 运行时间: $uptime)"
else
print_message $RED "服务 $service: 异常 (PID文件存在但进程不存在)"
rm -f "$pid_file"
fi
else
print_message $YELLOW "服务 $service: 未运行 (端口: $port)"
fi
}
# 启动所有服务
start_all() {
print_message $GREEN "启动所有扩展服务..."
for service in "${!SERVICES[@]}"; do
start_service $service
sleep 3
done
}
# 停止所有服务
stop_all() {
print_message $YELLOW "停止所有扩展服务..."
for service in "${!SERVICES[@]}"; do
stop_service $service
done
}
# 重启所有服务
restart_all() {
print_message $YELLOW "重启所有扩展服务..."
stop_all
sleep 5
start_all
}
# 查看所有服务状态
status_all() {
print_message $GREEN "所有扩展服务状态:"
for service in "${!SERVICES[@]}"; do
status_service $service
done
}
# 编译项目
build_project() {
print_message $BLUE "开始编译扩展服务项目..."
# 编译monitor-admin
print_message $YELLOW "编译 ruoyi-monitor-admin..."
cd ../ruoyi-extend/ruoyi-monitor-admin
mvn clean package -Pprod
if [ $? -eq 0 ]; then
print_message $GREEN "ruoyi-monitor-admin 编译成功"
else
print_message $RED "ruoyi-monitor-admin 编译失败"
return 1
fi
# 编译snailjob-server
print_message $YELLOW "编译 ruoyi-snailjob-server..."
cd ../ruoyi-snailjob-server
mvn clean package -Pprod
if [ $? -eq 0 ]; then
print_message $GREEN "ruoyi-snailjob-server 编译成功"
else
print_message $RED "ruoyi-snailjob-server 编译失败"
return 1
fi
# 返回原目录
cd ../../部署文档/杭州第一技师部署文档
print_message $GREEN "所有扩展服务编译完成"
}
# 显示帮助信息
show_help() {
echo "扩展服务启动停止一体化脚本使用方法:"
echo " $0 [命令] [服务名称]"
echo ""
echo "命令:"
echo " start 启动服务"
echo " stop 停止服务"
echo " restart 重启服务"
echo " status 查看服务状态"
echo " build 编译项目"
echo ""
echo "服务名称:"
echo " monitor 启动监控中心服务 (端口: 9090)"
echo " snailjob 启动任务调度服务 (端口: 8800)"
echo " all 操作所有服务"
echo ""
echo "示例:"
echo " $0 start monitor # 启动监控中心服务"
echo " $0 start snailjob # 启动任务调度服务"
echo " $0 start all # 启动所有服务"
echo " $0 stop all # 停止所有服务"
echo " $0 status all # 查看所有服务状态"
echo " $0 build # 编译所有扩展服务"
echo ""
echo "服务访问地址:"
echo " 监控中心: http://localhost:9090/admin"
echo " 任务调度: http://localhost:8800/snail-job"
}
# 主函数
main() {
local action=$1
local service=$2
# 检查参数
if [ -z "$action" ]; then
show_help
exit 1
fi
case $action in
start)
if [ "$service" = "all" ]; then
start_all
elif [ -n "${SERVICES[$service]}" ]; then
start_service $service
else
print_message $RED "无效的服务名称: $service"
show_help
exit 1
fi
;;
stop)
if [ "$service" = "all" ]; then
stop_all
elif [ -n "${SERVICES[$service]}" ]; then
stop_service $service
else
print_message $RED "无效的服务名称: $service"
show_help
exit 1
fi
;;
restart)
if [ "$service" = "all" ]; then
restart_all
elif [ -n "${SERVICES[$service]}" ]; then
restart_service $service
else
print_message $RED "无效的服务名称: $service"
show_help
exit 1
fi
;;
status)
if [ "$service" = "all" ]; then
status_all
elif [ -n "${SERVICES[$service]}" ]; then
status_service $service
else
print_message $RED "无效的服务名称: $service"
show_help
exit 1
fi
;;
build)
build_project
;;
*)
print_message $RED "无效的命令: $action"
show_help
exit 1
;;
esac
}
# 执行主函数
main "$@"
@@ -0,0 +1,296 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
REM 多实例启动脚本 (Windows版本)
REM 使用方法: start-multi-instance.bat [start|stop|restart|status] [instance_name]
REM 实例名称: 9900, 9901, 9902, 9903, all
REM 配置变量
set JAR_NAME=ruoyi-admin.jar
set JAVA_OPTS=-Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
set JAVA_OPTS=%JAVA_OPTS% -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai
set JAVA_OPTS=%JAVA_OPTS% -XX:+UseG1GC -XX:MaxGCPauseMillis=200
set JAVA_OPTS=%JAVA_OPTS% -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs\
REM 日志目录
if not exist logs mkdir logs
REM 颜色输出函数
:print_message
set color=%1
set message=%2
if "%color%"=="RED" (
echo [31m%message%[0m
) else if "%color%"=="GREEN" (
echo [32m%message%[0m
) else if "%color%"=="YELLOW" (
echo [33m%message%[0m
) else (
echo %message%
)
goto :eof
REM 检查JAR文件是否存在
:check_jar
if not exist "%JAR_NAME%" (
call :print_message RED "错误: %JAR_NAME% 文件不存在!"
call :print_message YELLOW "请确保在正确的目录下运行此脚本,或先执行 mvn clean package -Pprod 编译项目"
pause
exit /b 1
)
goto :eof
REM 启动单个实例
:start_instance
set port=%1
set profile=%2
set pid_file=logs\ruoyi-admin-%port%.pid
set log_file=logs\ruoyi-admin-%port%.log
REM 检查是否已经运行
if exist "%pid_file%" (
for /f "tokens=*" %%i in (%pid_file%) do set pid=%%i
tasklist /FI "PID eq !pid!" 2>nul | find /I "!pid!" >nul
if !errorlevel! equ 0 (
call :print_message YELLOW "实例 %port% 已经在运行中 (PID: !pid!)"
goto :eof
) else (
REM PID文件存在但进程不存在,清理PID文件
del "%pid_file%" 2>nul
)
)
call :print_message GREEN "正在启动实例 %port% (配置文件: %profile%)..."
REM 启动应用
start /b java %JAVA_OPTS% -Dspring.profiles.active=%profile% -Dserver.port=%port% -jar %JAR_NAME% > "%log_file%" 2>&1
REM 等待启动
timeout /t 3 /nobreak >nul
REM 查找新启动的进程
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq java.exe" /FO CSV ^| findstr /I "java.exe"') do (
set new_pid=%%i
set new_pid=!new_pid:"=!
echo !new_pid! > "%pid_file%"
call :print_message GREEN "实例 %port% 启动成功 (PID: !new_pid!)"
call :print_message GREEN "访问地址: http://localhost:%port%"
call :print_message GREEN "日志文件: %log_file%"
goto :eof
)
call :print_message RED "实例 %port% 启动失败,请检查日志文件: %log_file%"
if exist "%pid_file%" del "%pid_file%"
goto :eof
REM 停止单个实例
:stop_instance
set port=%1
set pid_file=logs\ruoyi-admin-%port%.pid
if exist "%pid_file%" (
for /f "tokens=*" %%i in (%pid_file%) do set pid=%%i
tasklist /FI "PID eq !pid!" 2>nul | find /I "!pid!" >nul
if !errorlevel! equ 0 (
call :print_message YELLOW "正在停止实例 %port% (PID: !pid!)..."
taskkill /PID !pid! /F >nul 2>&1
del "%pid_file%" 2>nul
call :print_message GREEN "实例 %port% 已停止"
) else (
call :print_message YELLOW "实例 %port% 未运行"
del "%pid_file%" 2>nul
)
) else (
call :print_message YELLOW "实例 %port% 未运行"
)
goto :eof
REM 重启单个实例
:restart_instance
set port=%1
call :print_message YELLOW "重启实例 %port%..."
call :stop_instance %port%
timeout /t 2 /nobreak >nul
if "%port%"=="9900" call :start_instance %port% prod-9900
if "%port%"=="9901" call :start_instance %port% prod-9901
if "%port%"=="9902" call :start_instance %port% prod-9902
if "%port%"=="9903" call :start_instance %port% prod-9903
goto :eof
REM 查看实例状态
:status_instance
set port=%1
set pid_file=logs\ruoyi-admin-%port%.pid
if exist "%pid_file%" (
for /f "tokens=*" %%i in (%pid_file%) do set pid=%%i
tasklist /FI "PID eq !pid!" 2>nul | find /I "!pid!" >nul
if !errorlevel! equ 0 (
call :print_message GREEN "实例 %port%: 运行中 (PID: !pid!)"
) else (
call :print_message RED "实例 %port%: 异常 (PID文件存在但进程不存在)"
del "%pid_file%" 2>nul
)
) else (
call :print_message YELLOW "实例 %port%: 未运行"
)
goto :eof
REM 启动所有实例
:start_all
call :print_message GREEN "启动所有实例..."
call :start_instance 9900 prod-9900
timeout /t 2 /nobreak >nul
call :start_instance 9901 prod-9901
timeout /t 2 /nobreak >nul
call :start_instance 9902 prod-9902
timeout /t 2 /nobreak >nul
call :start_instance 9903 prod-9903
goto :eof
REM 停止所有实例
:stop_all
call :print_message YELLOW "停止所有实例..."
call :stop_instance 9900
call :stop_instance 9901
call :stop_instance 9902
call :stop_instance 9903
goto :eof
REM 重启所有实例
:restart_all
call :print_message YELLOW "重启所有实例..."
call :stop_all
timeout /t 3 /nobreak >nul
call :start_all
goto :eof
REM 查看所有实例状态
:status_all
call :print_message GREEN "所有实例状态:"
call :status_instance 9900
call :status_instance 9901
call :status_instance 9902
call :status_instance 9903
goto :eof
REM 显示帮助信息
:show_help
echo 多实例启动脚本使用方法:
echo %0 [命令] [实例名称]
echo.
echo 命令:
echo start 启动实例
echo stop 停止实例
echo restart 重启实例
echo status 查看实例状态
echo.
echo 实例名称:
echo 9900 启动9900端口实例
echo 9901 启动9901端口实例
echo 9902 启动9902端口实例
echo 9903 启动9903端口实例
echo all 操作所有实例
echo.
echo 示例:
echo %0 start 9900 # 启动9900端口实例
echo %0 start all # 启动所有实例
echo %0 stop 9900 # 停止9900端口实例
echo %0 status all # 查看所有实例状态
echo %0 restart all # 重启所有实例
goto :eof
REM 主函数
:main
set action=%1
set instance=%2
REM 检查参数
if "%action%"=="" (
call :show_help
pause
exit /b 1
)
REM 检查JAR文件
call :check_jar
if !errorlevel! neq 0 goto :eof
if "%action%"=="start" (
if "%instance%"=="all" (
call :start_all
) else if "%instance%"=="9900" (
call :start_instance 9900 prod-9900
) else if "%instance%"=="9901" (
call :start_instance 9901 prod-9901
) else if "%instance%"=="9902" (
call :start_instance 9902 prod-9902
) else if "%instance%"=="9903" (
call :start_instance 9903 prod-9903
) else (
call :print_message RED "无效的实例名称: %instance%"
call :show_help
pause
exit /b 1
)
) else if "%action%"=="stop" (
if "%instance%"=="all" (
call :stop_all
) else if "%instance%"=="9900" (
call :stop_instance 9900
) else if "%instance%"=="9901" (
call :stop_instance 9901
) else if "%instance%"=="9902" (
call :stop_instance 9902
) else if "%instance%"=="9903" (
call :stop_instance 9903
) else (
call :print_message RED "无效的实例名称: %instance%"
call :show_help
pause
exit /b 1
)
) else if "%action%"=="restart" (
if "%instance%"=="all" (
call :restart_all
) else if "%instance%"=="9900" (
call :restart_instance 9900
) else if "%instance%"=="9901" (
call :restart_instance 9901
) else if "%instance%"=="9902" (
call :restart_instance 9902
) else if "%instance%"=="9903" (
call :restart_instance 9903
) else (
call :print_message RED "无效的实例名称: %instance%"
call :show_help
pause
exit /b 1
)
) else if "%action%"=="status" (
if "%instance%"=="all" (
call :status_all
) else if "%instance%"=="9900" (
call :status_instance 9900
) else if "%instance%"=="9901" (
call :status_instance 9901
) else if "%instance%"=="9902" (
call :status_instance 9902
) else if "%instance%"=="9903" (
call :status_instance 9903
) else (
call :print_message RED "无效的实例名称: %instance%"
call :show_help
pause
exit /b 1
)
) else (
call :print_message RED "无效的命令: %action%"
call :show_help
pause
exit /b 1
)
pause
goto :eof
@@ -0,0 +1,284 @@
#!/bin/bash
# 多实例启动脚本
# 使用方法: ./start-multi-instance.sh [start|stop|restart|status] [instance_name]
# 实例名称: 9900, 9901, 9902, 9903, all
# 配置变量
JAR_NAME="ruoyi-admin.jar"
JAVA_OPTS="-Xms8192m -Xmx8192m -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=1024m"
JAVA_OPTS="$JAVA_OPTS -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
JAVA_OPTS="$JAVA_OPTS -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
JAVA_OPTS="$JAVA_OPTS -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=logs/"
# 实例配置
declare -A INSTANCES
INSTANCES[9900]="prod-9900"
INSTANCES[9901]="prod-9901"
INSTANCES[9902]="prod-9902"
INSTANCES[9903]="prod-9903"
# 日志目录
LOG_DIR="logs"
mkdir -p $LOG_DIR
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# 检查JAR文件是否存在
check_jar() {
if [ ! -f "$JAR_NAME" ]; then
print_message $RED "错误: $JAR_NAME 文件不存在!"
print_message $YELLOW "请确保在正确的目录下运行此脚本,或先执行 mvn clean package -Pprod 编译项目"
exit 1
fi
}
# 启动单个实例
start_instance() {
local port=$1
local profile=$2
local pid_file="logs/ruoyi-admin-${port}.pid"
local log_file="logs/ruoyi-admin-${port}.log"
# 检查是否已经运行
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if ps -p $pid > /dev/null 2>&1; then
print_message $YELLOW "实例 $port 已经在运行中 (PID: $pid)"
return
else
# PID文件存在但进程不存在,清理PID文件
rm -f "$pid_file"
fi
fi
print_message $GREEN "正在启动实例 $port (配置文件: $profile)..."
# 启动应用
nohup java $JAVA_OPTS \
-Dspring.profiles.active=prod,$profile \
-Dserver.port=$port \
-jar $JAR_NAME \
> "$log_file" 2>&1 &
local new_pid=$!
echo $new_pid > "$pid_file"
# 等待启动
sleep 3
# 检查启动状态
if ps -p $new_pid > /dev/null 2>&1; then
print_message $GREEN "实例 $port 启动成功 (PID: $new_pid)"
print_message $GREEN "访问地址: http://localhost:$port"
print_message $GREEN "日志文件: $log_file"
else
print_message $RED "实例 $port 启动失败,请检查日志文件: $log_file"
rm -f "$pid_file"
fi
}
# 停止单个实例
stop_instance() {
local port=$1
local pid_file="logs/ruoyi-admin-${port}.pid"
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if ps -p $pid > /dev/null 2>&1; then
print_message $YELLOW "正在停止实例 $port (PID: $pid)..."
kill $pid
# 等待进程结束
local count=0
while ps -p $pid > /dev/null 2>&1 && [ $count -lt 30 ]; do
sleep 1
count=$((count + 1))
done
# 强制杀死进程
if ps -p $pid > /dev/null 2>&1; then
print_message $YELLOW "强制停止实例 $port..."
kill -9 $pid
fi
rm -f "$pid_file"
print_message $GREEN "实例 $port 已停止"
else
print_message $YELLOW "实例 $port 未运行"
rm -f "$pid_file"
fi
else
print_message $YELLOW "实例 $port 未运行"
fi
}
# 重启单个实例
restart_instance() {
local port=$1
print_message $YELLOW "重启实例 $port..."
stop_instance $port
sleep 2
start_instance $port ${INSTANCES[$port]}
}
# 查看实例状态
status_instance() {
local port=$1
local pid_file="logs/ruoyi-admin-${port}.pid"
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if ps -p $pid > /dev/null 2>&1; then
local memory=$(ps -o rss= -p $pid | awk '{print $1/1024 " MB"}')
local uptime=$(ps -o etime= -p $pid)
print_message $GREEN "实例 $port: 运行中 (PID: $pid, 内存: $memory, 运行时间: $uptime)"
else
print_message $RED "实例 $port: 异常 (PID文件存在但进程不存在)"
rm -f "$pid_file"
fi
else
print_message $YELLOW "实例 $port: 未运行"
fi
}
# 启动所有实例
start_all() {
print_message $GREEN "启动所有实例..."
for port in "${!INSTANCES[@]}"; do
start_instance $port ${INSTANCES[$port]}
sleep 2
done
}
# 停止所有实例
stop_all() {
print_message $YELLOW "停止所有实例..."
for port in "${!INSTANCES[@]}"; do
stop_instance $port
done
}
# 重启所有实例
restart_all() {
print_message $YELLOW "重启所有实例..."
stop_all
sleep 3
start_all
}
# 查看所有实例状态
status_all() {
print_message $GREEN "所有实例状态:"
for port in "${!INSTANCES[@]}"; do
status_instance $port
done
}
# 显示帮助信息
show_help() {
echo "多实例启动脚本使用方法:"
echo " $0 [命令] [实例名称]"
echo ""
echo "命令:"
echo " start 启动实例"
echo " stop 停止实例"
echo " restart 重启实例"
echo " status 查看实例状态"
echo ""
echo "实例名称:"
echo " 9900 启动9900端口实例"
echo " 9901 启动9901端口实例"
echo " 9902 启动9902端口实例"
echo " 9903 启动9903端口实例"
echo " all 操作所有实例"
echo ""
echo "示例:"
echo " $0 start 9900 # 启动9900端口实例"
echo " $0 start all # 启动所有实例"
echo " $0 stop 9900 # 停止9900端口实例"
echo " $0 status all # 查看所有实例状态"
echo " $0 restart all # 重启所有实例"
}
# 主函数
main() {
local action=$1
local instance=$2
# 检查参数
if [ -z "$action" ]; then
show_help
exit 1
fi
# 检查JAR文件
check_jar
case $action in
start)
if [ "$instance" = "all" ]; then
start_all
elif [ -n "${INSTANCES[$instance]}" ]; then
start_instance $instance ${INSTANCES[$instance]}
else
print_message $RED "无效的实例名称: $instance"
show_help
exit 1
fi
;;
stop)
if [ "$instance" = "all" ]; then
stop_all
elif [ -n "${INSTANCES[$instance]}" ]; then
stop_instance $instance
else
print_message $RED "无效的实例名称: $instance"
show_help
exit 1
fi
;;
restart)
if [ "$instance" = "all" ]; then
restart_all
elif [ -n "${INSTANCES[$instance]}" ]; then
restart_instance $instance
else
print_message $RED "无效的实例名称: $instance"
show_help
exit 1
fi
;;
status)
if [ "$instance" = "all" ]; then
status_all
elif [ -n "${INSTANCES[$instance]}" ]; then
status_instance $instance
else
print_message $RED "无效的实例名称: $instance"
show_help
exit 1
fi
;;
*)
print_message $RED "无效的命令: $action"
show_help
exit 1
;;
esac
}
# 执行主函数
main "$@"
@@ -0,0 +1,66 @@
#!/bin/bash
# 配置修复验证脚本
# 使用方法: ./test-config-fix.sh
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
print_message $GREEN "=== 多实例配置修复验证脚本 ==="
# 检查JAR文件
JAR_NAME="ruoyi-admin.jar"
if [ ! -f "$JAR_NAME" ]; then
print_message $RED "错误: $JAR_NAME 文件不存在!"
print_message $YELLOW "请先执行 mvn clean package -Pprod 编译项目"
exit 1
fi
print_message $GREEN "✓ JAR文件存在"
# 检查配置文件
CONFIG_FILES=(
"backend/ruoyi-admin/src/main/resources/application-prod-9900.yml"
"backend/ruoyi-admin/src/main/resources/application-prod-9901.yml"
"backend/ruoyi-admin/src/main/resources/application-prod-9902.yml"
"backend/ruoyi-admin/src/main/resources/application-prod-9903.yml"
)
for config_file in "${CONFIG_FILES[@]}"; do
if [ -f "$config_file" ]; then
# 检查是否包含spring.profiles.include
if grep -q "spring.profiles.include" "$config_file"; then
print_message $RED "✗ $config_file 仍包含 spring.profiles.include"
else
print_message $GREEN "✓ $config_file 配置正确"
fi
else
print_message $RED "✗ $config_file 文件不存在"
fi
done
# 检查启动脚本
START_SCRIPT="docs/部署文档/杭州第一技师部署文档/start-multi-instance.sh"
if [ -f "$START_SCRIPT" ]; then
if grep -q "spring.profiles.active=prod,\$profile" "$START_SCRIPT"; then
print_message $GREEN "✓ 启动脚本配置正确"
else
print_message $RED "✗ 启动脚本配置错误"
fi
else
print_message $RED "✗ 启动脚本不存在"
fi
print_message $GREEN ""
print_message $GREEN "=== 验证完成 ==="
print_message $YELLOW "如果所有检查都通过,可以尝试启动实例:"
print_message $YELLOW " ./start-multi-instance.sh start 9900"
@@ -0,0 +1,96 @@
# 多实例启动配置修复说明
## 问题描述
在使用 `start-multi-instance.sh` 启动实例时,出现以下错误:
```
org.springframework.boot.context.config.InvalidConfigDataPropertyException: Property 'spring.profiles.include' imported from location 'class path resource [application-prod-9900.yml]' is invalid in a profile specific resource
```
## 问题原因
在Spring Boot 2.4+版本中,`spring.profiles.include` 属性不能在profile-specific的配置文件中使用(如 `application-prod-9900.yml`)。这个属性只能在主配置文件(`application.yml`)中使用。
## 修复方案
### 1. 修改配置文件结构
移除了所有实例配置文件中的 `spring.profiles.include: prod` 配置:
**修复前:**
```yaml
# application-prod-9900.yml
spring:
profiles:
include: prod # ❌ 这在profile-specific文件中不被允许
```
**修复后:**
```yaml
# application-prod-9900.yml
spring:
application:
name: RuoYi-Vue-Plus-9900 # ✅ 直接配置应用名称
```
### 2. 修改启动脚本
修改启动脚本,在启动时同时激活 `prod` 和具体的实例profile:
**修复前:**
```bash
-Dspring.profiles.active=$profile # 只激活 prod-9900
```
**修复后:**
```bash
-Dspring.profiles.active=prod,$profile # 同时激活 prod 和 prod-9900
```
## 修复的文件
1. `backend/ruoyi-admin/src/main/resources/application-prod-9900.yml`
2. `backend/ruoyi-admin/src/main/resources/application-prod-9901.yml`
3. `backend/ruoyi-admin/src/main/resources/application-prod-9902.yml`
4. `backend/ruoyi-admin/src/main/resources/application-prod-9903.yml`
5. `docs/部署文档/杭州第一技师部署文档/start-multi-instance.sh`
## 配置加载顺序
修复后的配置加载顺序:
1. `application.yml` (主配置文件)
2. `application-prod.yml` (生产环境基础配置)
3. `application-prod-9900.yml` (9900端口实例特定配置)
## 验证方法
1. 重新编译项目:
```bash
mvn clean package -Pprod
```
2. 启动单个实例测试:
```bash
./start-multi-instance.sh start 9900
```
3. 检查启动日志,确认没有配置错误
4. 访问应用验证功能正常:
```bash
curl http://localhost:9900
```
## 注意事项
1. 确保在修改配置文件后重新编译项目
2. 如果之前有实例在运行,需要先停止再重新启动
3. 检查日志文件确认配置加载正确
4. 验证监控中心和SnailJob配置是否正常工作
## 相关文档
- [Spring Boot Profile配置最佳实践](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.external-config.files.profile-specific)
- [多实例部署指南](../README.md)
@@ -0,0 +1,293 @@
# 🚀 RuoYi-Admin 多实例部署说明
## 📋 概述
本文档说明如何在生产环境中部署多个 `ruoyi-admin.jar` 实例到不同端口,实现负载均衡和高可用。
## 🏗️ 部署架构
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 前端应用 │ │ Nginx │ │ 后端实例 │
│ │ │ 负载均衡器 │ │ │
│ http://80 │───▶│ http://80 │───▶│ 9900, 9901 │
│ │ │ │ │ 9902, 9903 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## 📁 文件结构
```
backend/
├── ruoyi-admin.jar # 编译后的JAR包
├── application-prod-9900.yml # 9900端口配置文件
├── application-prod-9901.yml # 9901端口配置文件
├── application-prod-9902.yml # 9902端口配置文件
├── application-prod-9903.yml # 9903端口配置文件
├── start-multi-instance.sh # Linux启动脚本
├── start-multi-instance.bat # Windows启动脚本
├── docker-compose-multi.yml # Docker部署配置
├── nginx.conf # Nginx负载均衡配置
└── logs/ # 日志目录
├── ruoyi-admin-9900.log
├── ruoyi-admin-9901.log
├── ruoyi-admin-9902.log
└── ruoyi-admin-9903.log
```
## 🔧 部署步骤
### 1. 编译项目
```bash
# 进入backend目录
cd backend
# 编译生产环境JAR包
mvn clean package -Pprod
# 检查生成的JAR文件
ls -la ruoyi-admin/target/ruoyi-admin.jar
```
### 2. 准备部署文件
```bash
# 复制JAR文件到部署目录
cp ruoyi-admin/target/ruoyi-admin.jar ./
# 创建日志目录
mkdir -p logs
# 设置脚本执行权限 (Linux)
chmod +x start-multi-instance.sh
```
### 3. 启动多实例
#### Linux环境
```bash
# 启动所有实例
./start-multi-instance.sh start all
# 启动单个实例
./start-multi-instance.sh start 9900
./start-multi-instance.sh start 9901
./start-multi-instance.sh start 9902
./start-multi-instance.sh start 9903
# 查看实例状态
./start-multi-instance.sh status all
# 停止实例
./start-multi-instance.sh stop all
# 重启实例
./start-multi-instance.sh restart all
```
#### Windows环境
```cmd
# 启动所有实例
start-multi-instance.bat start all
# 启动单个实例
start-multi-instance.bat start 9900
start-multi-instance.bat start 9901
start-multi-instance.bat start 9902
start-multi-instance.bat start 9903
# 查看实例状态
start-multi-instance.bat status all
# 停止实例
start-multi-instance.bat stop all
# 重启实例
start-multi-instance.bat restart all
```
### 4. Docker部署 (可选)
```bash
# 启动所有服务
docker-compose -f docker-compose-multi.yml up -d
# 查看服务状态
docker-compose -f docker-compose-multi.yml ps
# 查看日志
docker-compose -f docker-compose-multi.yml logs -f
# 停止服务
docker-compose -f docker-compose-multi.yml down
```
## 🌐 访问地址
### 直接访问各实例
- **实例1**: http://localhost:9900
- **实例2**: http://localhost:9901
- **实例3**: http://localhost:9902
- **实例4**: http://localhost:9903
### 通过负载均衡器访问
- **主入口**: http://localhost (Nginx负载均衡)
- **监控页面**: http://localhost:8080/status
## 📊 监控和管理
### 1. 实例状态监控
```bash
# 查看所有实例状态
./start-multi-instance.sh status all
# 查看单个实例状态
./start-multi-instance.sh status 9900
```
### 2. 日志监控
```bash
# 查看实时日志
tail -f logs/ruoyi-admin-9900.log
tail -f logs/ruoyi-admin-9901.log
tail -f logs/ruoyi-admin-9902.log
tail -f logs/ruoyi-admin-9903.log
# 查看错误日志
grep "ERROR" logs/ruoyi-admin-*.log
```
### 3. 进程监控
```bash
# 查看Java进程
ps aux | grep java
# 查看端口占用
netstat -tlnp | grep :99
```
## ⚠️ 注意事项
### 1. 端口冲突
- 确保9900-9903端口未被其他服务占用
- 检查防火墙设置,确保端口可访问
### 2. 内存配置
- 每个实例默认配置:最小512MB,最大1024MB
- 根据服务器配置调整 `JAVA_OPTS` 参数
### 3. 数据库连接
- 所有实例共享同一个数据库
- 确保数据库连接池配置合理,避免连接数过多
### 4. 文件上传
- 临时文件目录:`/ruoyi/server/temp`
- 确保目录存在且有写权限
## 🔄 故障排查
### 1. 实例启动失败
```bash
# 检查JAR文件是否存在
ls -la ruoyi-admin.jar
# 检查端口是否被占用
netstat -tlnp | grep :99
# 查看启动日志
tail -f logs/ruoyi-admin-9900.log
```
### 2. 实例无法访问
```bash
# 检查防火墙设置
firewall-cmd --list-ports
# 检查SELinux状态 (CentOS/RHEL)
getenforce
# 测试端口连通性
telnet localhost 9900
```
### 3. 负载均衡不工作
```bash
# 检查Nginx状态
docker-compose -f docker-compose-multi.yml ps nginx
# 查看Nginx日志
docker-compose -f docker-compose-multi.yml logs nginx
# 测试后端服务
curl http://localhost:9900/health
```
## 📈 性能优化建议
### 1. JVM调优
```bash
# 根据服务器内存调整JVM参数
JAVA_OPTS="-Xms2g -Xmx4g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m"
```
### 2. 数据库连接池优化
```yaml
# 在application-prod-*.yml中调整
spring:
datasource:
hikari:
maxPoolSize: 100 # 根据实例数调整
minIdle: 20
connectionTimeout: 30000
```
### 3. 缓存配置
```yaml
# Redis连接池优化
redisson:
threads: 32
nettyThreads: 64
singleServerConfig:
connectionPoolSize: 128
```
## 🚀 扩展部署
### 1. 添加更多实例
- 复制配置文件模板
- 修改端口号和应用名称
- 更新启动脚本和Docker配置
### 2. 集群部署
- 使用Redis集群
- 配置数据库主从复制
- 实现会话共享
### 3. 容器化部署
- 使用Kubernetes管理
- 配置自动扩缩容
- 实现滚动更新
## 📞 技术支持
如遇到部署问题,请检查:
1. 系统日志和应用日志
2. 端口占用情况
3. 防火墙和网络配置
4. 数据库连接状态
5. 内存和磁盘空间
---
**📝 文档版本**: v1.0
**🔄 最后更新**: 2025-01-03
**👨‍💻 维护人员**: 开发团队
@@ -0,0 +1,96 @@
# SnailJob命名空间配置修复方案
## 问题描述
四个ruoyi-admin实例启动后都提示SnailJob Token认证失败:
```
2025-08-31 01:44:09 [snail-job-grpc-client-executor-172.16.0.67-1216] ERROR c.a.s.c.c.handler.ClientRegister
- heartbeat check requestId:[1216] message:[Token authentication failed. [namespace:prod,prod-9900 groupName:ruoyi_group_9900 token:SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT]]
```
## 问题分析
### 根本原因
1. **命名空间配置不匹配**:客户端尝试连接的命名空间是 `prod,prod-9900`,但数据库中的命名空间只有 `prod`
2. **动态配置问题**:配置文件使用 `${spring.profiles.active}` 作为命名空间,导致9900-9903实例的命名空间变成了 `prod-9900`, `prod-9901` 等
3. **数据库数据缺失**:数据库中缺少对应的命名空间记录
### 配置对比
**数据库中的命名空间:**
```sql
1 Development dev
2 Production prod
```
**客户端尝试连接的命名空间:**
- prod,prod-9900
- prod,prod-9901
- prod,prod-9902
- prod,prod-9903
## 解决方案
### 方案一:修改配置文件(已实施)
将所有实例的命名空间统一为 `prod`,而不是使用 `${spring.profiles.active}`:
**修改的文件:**
- `application-prod-9900.yml`
- `application-prod-9901.yml`
- `application-prod-9902.yml`
- `application-prod-9903.yml`
**修改内容:**
```yaml
snail-job:
namespace: "prod" # 从 ${spring.profiles.active} 改为固定值
```
### 方案二:数据库方案(备选)
如果希望保持动态命名空间配置,可以在数据库中创建对应的命名空间:
```sql
-- 为每个实例创建对应的命名空间
INSERT INTO `sj_namespace` VALUES (3, 'Production-9900', 'prod-9900', '', 0, now(), now());
INSERT INTO `sj_namespace` VALUES (4, 'Production-9901', 'prod-9901', '', 0, now(), now());
INSERT INTO `sj_namespace` VALUES (5, 'Production-9902', 'prod-9902', '', 0, now(), now());
INSERT INTO `sj_namespace` VALUES (6, 'Production-9903', 'prod-9903', '', 0, now(), now());
-- 为每个命名空间创建对应的组配置
INSERT INTO `sj_group_config` VALUES (13, 'prod-9900', 'ruoyi_group_9900', '', 'SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT', 1, 1, 0, 1, 1, now(), now());
INSERT INTO `sj_group_config` VALUES (14, 'prod-9901', 'ruoyi_group_9901', '', 'SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT', 1, 1, 0, 1, 1, now(), now());
INSERT INTO `sj_group_config` VALUES (15, 'prod-9902', 'ruoyi_group_9902', '', 'SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT', 1, 1, 0, 1, 1, now(), now());
INSERT INTO `sj_group_config` VALUES (16, 'prod-9903', 'ruoyi_group_9903', '', 'SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT', 1, 1, 0, 1, 1, now(), now());
```
## 验证步骤
1. **重启应用实例**
```bash
# 重启所有实例
./hot-start.sh
```
2. **检查日志**
- 确认不再出现Token认证失败错误
- 确认SnailJob客户端成功注册
3. **验证连接**
- 访问SnailJob管理界面
- 确认所有实例都显示为在线状态
## 最佳实践建议
1. **命名空间管理**:建议使用统一的命名空间(如 `prod`),避免动态配置导致的复杂性
2. **组名管理**:使用有意义的组名,便于识别和管理
3. **配置验证**:在部署前验证配置文件与数据库配置的一致性
4. **监控告警**:配置SnailJob连接状态的监控告警
## 相关文档
- [SnailJob官方文档](https://github.com/aizuda/snail-job)
- [项目部署文档](../部署文档/)
- [数据库设计文档](../设计文档/database/)
+236
View File
@@ -0,0 +1,236 @@
import { request } from '@/common/request';
/**
* 心理健康咨询预约 API
* 移动端API接口 - 基于web端功能移植
*/
// ===== 预约时段管理 API =====
/**
* 获取可预约时段列表(用于日历视图)
* @param {string} startDate 开始日期 YYYY-MM-DD
* @param {string} endDate 结束日期 YYYY-MM-DD
*/
export const getAvailableTimeSlots = (startDate, endDate) => {
return request({
url: '/xljk/consultation/appointment/available-slots',
method: 'GET',
params: { startDate, endDate }
});
};
/**
* 获取可用的详细时段列表(30分钟一个时段)
* @param {string} date 日期 YYYY-MM-DD
* @param {string} timeSlot 时段 如 "09:00-12:00"
*/
export const getAvailableDetailSlots = (date, timeSlot) => {
return request({
url: '/xljk/consultation/appointment/available-detail-slots',
method: 'GET',
params: { date, timeSlot }
});
};
// ===== 预约记录管理 API =====
/**
* 学生提交预约
* @param {object} data 预约数据
*/
export const submitAppointment = (data) => {
return request({
url: '/xljk/consultation/appointment/submit',
method: 'POST',
data
});
};
/**
* 获取我的预约记录(学生用)
* @param {string|number} studentId 学生ID
* @param {boolean} forceRefresh 是否强制刷新
*/
export const getMyAppointments = (studentId, forceRefresh = false) => {
let url = `/xljk/consultation/appointment/my-appointments/${studentId}`;
// 强制添加时间戳参数,确保绕过浏览器缓存
url += `?_t=${new Date().getTime()}`;
// 如果需要额外强制刷新
if (forceRefresh) {
url += `&force=true`;
}
return request({
url,
method: 'GET',
header: {
// 禁止所有缓存
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
};
/**
* 查询预约记录详细信息
* @param {string|number} id 预约记录ID
*/
export const getAppointmentDetail = (id) => {
return request({
url: `/xljk/consultation/appointment/${id}`,
method: 'GET'
});
};
/**
* 取消预约(更新状态为已取消,不删除记录)
* @param {string|number} id 预约记录ID
*/
export const cancelAppointment = (id) => {
return request({
url: `/xljk/consultation/appointment`,
method: 'PUT',
data: {
id: id,
status: 2 // 已取消状态
}
});
};
/**
* 更新预约信息(通用方法)
* @param {object} data 更新数据,必须包含id字段
*/
export const updateAppointment = (data) => {
return request({
url: `/xljk/consultation/appointment`,
method: 'PUT',
data
});
};
// ===== 选项数据 API =====
/**
* 获取咨询师选项
*/
export const getCounselorOptions = () => {
return request({
url: '/xljk/consultation/schedule/options/counselors',
method: 'GET'
});
};
/**
* 获取学生信息(通过学号)
* @param {string} studentNo 学号
*/
export const getStudentByNo = (studentNo) => {
return request({
url: `/xljk/consultation/student/${studentNo}`,
method: 'GET'
});
};
/**
* 获取院系选项
*/
export const getCollegeOptions = () => {
return request({
url: '/xljk/consultation/appointment/options/colleges',
method: 'GET'
});
};
/**
* 获取专业选项
* @param {string} collegeName 院系名称(可选)
*/
export const getMajorOptions = (collegeName) => {
return request({
url: '/xljk/consultation/appointment/options/majors',
method: 'GET',
params: { collegeName }
});
};
/**
* 获取班级选项
* @param {string} collegeName 院系名称(可选)
* @param {string} majorName 专业名称(可选)
*/
export const getClassOptions = (collegeName, majorName) => {
return request({
url: '/xljk/consultation/appointment/options/classes',
method: 'GET',
params: { collegeName, majorName }
});
};
// ===== 移动端专用辅助方法 =====
/**
* 格式化日期为 YYYY-MM-DD
* @param {Date} date 日期对象
*/
export const formatDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* 获取指定日期的周日期范围
* @param {Date} date 指定日期
* @returns {object} { startDate, endDate, weekDates }
*/
export const getWeekDateRange = (date = new Date()) => {
const currentDate = new Date(date);
const currentDay = currentDate.getDay(); // 0=Sunday, 1=Monday, ...
// 计算本周一的日期
const monday = new Date(currentDate);
monday.setDate(currentDate.getDate() - currentDay + (currentDay === 0 ? -6 : 1));
// 生成一周的日期数组
const weekDates = [];
for (let i = 0; i < 7; i++) {
const weekDate = new Date(monday);
weekDate.setDate(monday.getDate() + i);
weekDates.push({
date: formatDate(weekDate),
dayName: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][i],
dayNumber: weekDate.getDate(),
isToday: formatDate(weekDate) === formatDate(new Date())
});
}
return {
startDate: formatDate(monday),
endDate: formatDate(new Date(monday.getTime() + 6 * 24 * 60 * 60 * 1000)),
weekDates
};
};
/**
* 检查预约限制
* @param {string|number} studentId 学生ID
*/
export const checkAppointmentLimitation = async (studentId) => {
try {
// 这里可能需要导入noshow相关的API,暂时使用简单的实现
return request({
url: `/xljk/consultation/appointment/check-limitation/${studentId}`,
method: 'GET'
});
} catch (error) {
console.error('检查预约限制失败', error);
// 检查失败不阻止预约
return { code: 200, data: { limited: false } };
}
};
+25 -1
View File
@@ -302,7 +302,31 @@
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
}
},
{
"path": "pages/xljk/reservation/index",
"style": {
"navigationBarTitleText": "心理咨询预约",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/my-appointments/index",
"style": {
"navigationBarTitleText": "我的预约",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/reservation/appointment-form",
"style": {
"navigationBarTitleText": "预约咨询",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
+23 -1
View File
@@ -262,6 +262,20 @@
/>
</template>
</ServiceItem>
<ServiceItem
v-if="hasPermi(['student:counsel:reservation'])"
title="心理咨询预约"
@click="gotoPsychologicalReservation"
>
<template #icon>
<image
src="/static/00113.png"
mode="aspectFit"
style="width: 24px; height: 24px"
/>
</template>
</ServiceItem>
</GridLayout>
</Card>
@@ -710,7 +724,8 @@ export default {
this.hasPermi(['student:dormhygiene:listapp']) ||
this.hasPermi(['student:workstudy:list']) ||
this.hasPermi(['student:violation:listapp']) ||
this.hasPermi(['student:assessment:applyapp'])
this.hasPermi(['student:assessment:applyapp']) ||
this.hasPermi(['student:counsel:reservation'])
);
},
@@ -966,6 +981,13 @@ export default {
});
},
// 跳转到心理咨询预约页面
gotoPsychologicalReservation() {
uni.navigateTo({
url: '/pages/xljk/reservation/index',
});
},
// 跳转到修改密码页面
gotoResetPassword() {
uni.navigateTo({
@@ -0,0 +1,737 @@
<template>
<view class="page-container">
<!-- 导航栏 -->
<u-navbar
title="我的预约"
:border="false"
bg-color="#ffffff"
title-color="#333333"
@leftClick="goBack"
>
<template #left>
<u-icon name="arrow-left" size="22" color="#333333"></u-icon>
</template>
</u-navbar>
<!-- 主要内容区域 -->
<scroll-view class="main-content" scroll-y enhanced>
<!-- 筛选器 -->
<view class="filter-section">
<view class="filter-tabs">
<view
v-for="(tab, index) in filterTabs"
:key="index"
class="filter-tab"
:class="{ active: activeFilterIndex === index }"
@click="switchFilter(index)"
>
<text class="tab-text">{{ tab.label }}</text>
<!-- 只有待咨询标签显示圆形标记 -->
<view v-if="tab.value === '0' && tab.count > 0" class="tab-badge-circle"></view>
</view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<u-loading-icon mode="circle"></u-loading-icon>
<text class="loading-text">加载中...</text>
</view>
<!-- 预约列表 -->
<view v-else-if="appointmentList.length > 0" class="appointment-list">
<view
v-for="appointment in appointmentList"
:key="appointment.id"
class="appointment-card"
@click="viewAppointmentDetail(appointment)"
>
<!-- 预约状态 -->
<view class="appointment-header">
<view class="appointment-date">
<text class="date-text">{{ appointment.appointmentDate }}</text>
<text class="time-text">{{ appointment.appointmentTime }}</text>
</view>
<view class="appointment-status">
<u-tag
:text="getStatusText(appointment.status)"
:type="getStatusType(appointment.status)"
size="mini"
></u-tag>
</view>
</view>
<!-- 咨询师信息 -->
<view class="counselor-section">
<view class="counselor-avatar">
<u-avatar
:src="appointment.counselorAvatar"
size="40"
:text="appointment.counselorName ? appointment.counselorName.charAt(0) : '咨'"
></u-avatar>
</view>
<view class="counselor-info">
<text class="counselor-name">{{ appointment.counselorName || '未知咨询师' }}</text>
<text class="appointment-reason">{{ appointment.reason || '心理咨询' }}</text>
</view>
<!-- 取消按钮 -->
<view v-if="appointment.status === '0'" class="inline-action">
<button
class="cancel-button-inline"
@click.stop="handleCancelClick(appointment)"
:disabled="isCancelButtonDisabled(appointment.id)"
>
{{ getCancelButtonText(appointment.id) }}
</button>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-container">
<u-empty
mode="data"
:text="getEmptyText()"
textColor="#909399"
iconColor="#c8c9cc"
></u-empty>
<u-button
type="primary"
size="normal"
@click="goToReservation"
style="margin-top: 40rpx;"
>
立即预约
</u-button>
</view>
</scroll-view>
</view>
</template>
<script>
import { getMyAppointments, cancelAppointment } from '@/api/xljk'
export default {
name: 'MyAppointments',
data() {
return {
// 筛选相关
filterTabs: [
{ label: '全部', value: 'all', count: 0 },
{ label: '待咨询', value: '0', count: 0 },
{ label: '已完成', value: '1', count: 0 },
{ label: '已取消', value: '2', count: 0 },
{ label: '未到访', value: '3', count: 0 }
],
activeFilterIndex: 0,
// 列表相关
appointmentList: [],
allAppointments: [], // 存储所有预约数据用于筛选
loading: false,
// 取消预约相关
selectedAppointment: null,
cancelLoading: false,
cancelingAppointmentId: null // 正在取消的预约ID
}
},
onLoad() {
// 初始化时重置取消相关状态
this.resetCancelState()
this.loadAppointments()
},
onShow() {
// 页面显示时刷新数据,因为可能从预约页面返回
// 同时重置取消相关状态,防止从其他页面返回时状态异常
this.resetCancelState()
this.loadAppointments()
},
methods: {
// 返回上一页
goBack() {
uni.navigateBack()
},
// 加载预约数据
async loadAppointments() {
this.loading = true
try {
// 正确获取用户信息 - 使用专门的获取方法
const { userStorage } = await import('@/common/storage')
const userInfo = userStorage.getUserInfo()
console.log('🔍 [我的预约] 获取到的完整用户信息:', userInfo)
// 从正确的路径获取用户ID
let studentId = null
if (userInfo && userInfo.user) {
studentId = userInfo.user.userId
}
console.log('🔍 [我的预约] 解析到的学生ID:', studentId)
if (!studentId) {
uni.showToast({
title: '请先登录',
icon: 'error'
})
// 跳转到登录页面
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index'
})
}, 1500)
return
}
// 调用API获取我的预约数据
const response = await getMyAppointments(studentId)
console.log('🔍 [我的预约] API响应数据:', response)
if (response.code === 200 && response.data) {
console.log('🔍 [我的预约] 原始数据列表:', response.data)
// 打印每条数据的status字段类型和值
response.data.forEach((item, index) => {
console.log(`🔍 [我的预约] 数据${index}: ID=${item.appointmentId}, status=${item.status} (${typeof item.status})`)
})
// 转换API数据格式
const appointments = response.data.map(item => {
return {
id: item.id, // 修复:使用 id 而不是 appointmentId
appointmentDate: item.appointmentDate,
appointmentTime: item.appointmentDetailTime || item.appointmentTime,
counselorName: item.counselorName,
counselorAvatar: '',
reason: item.reason,
status: String(item.status), // 转换为字符串,便于筛选比较
originalData: item
}
})
this.allAppointments = appointments
console.log('🔍 [我的预约] 转换后的数据:', appointments)
this.updateFilterCounts()
this.filterAppointments()
} else {
this.allAppointments = []
this.appointmentList = []
this.updateFilterCounts()
}
} catch (error) {
console.error('加载预约数据失败:', error)
uni.showToast({
title: '加载失败,请检查网络连接',
icon: 'error'
})
this.allAppointments = []
this.appointmentList = []
this.updateFilterCounts()
} finally {
this.loading = false
}
},
// 更新筛选标签数量
updateFilterCounts() {
console.log('🔍 [我的预约] 更新筛选标签数量,总数据:', this.allAppointments.length)
// 统计各状态数量
const status0Count = this.allAppointments.filter(item => item.status === '0').length
const status1Count = this.allAppointments.filter(item => item.status === '1').length
const status2Count = this.allAppointments.filter(item => item.status === '2').length
const status3Count = this.allAppointments.filter(item => item.status === '3').length
console.log('🔍 [我的预约] 状态统计: 待咨询(0)=', status0Count, ', 已完成(1)=', status1Count, ', 已取消(2)=', status2Count, ', 未到访(3)=', status3Count)
this.filterTabs[0].count = this.allAppointments.length // 全部
this.filterTabs[1].count = status0Count // 待咨询
this.filterTabs[2].count = status1Count // 已完成
this.filterTabs[3].count = status2Count // 已取消
this.filterTabs[4].count = status3Count // 未到访
},
// 切换筛选器
switchFilter(index) {
this.activeFilterIndex = index
this.filterAppointments()
},
// 筛选预约数据
filterAppointments() {
const currentFilter = this.filterTabs[this.activeFilterIndex]
console.log('🔍 [我的预约] 开始筛选,当前筛选条件:', currentFilter)
if (currentFilter.value === 'all') {
this.appointmentList = [...this.allAppointments]
} else {
this.appointmentList = this.allAppointments.filter(item => item.status === currentFilter.value)
}
console.log('🔍 [我的预约] 筛选结果数量:', this.appointmentList.length)
console.log('🔍 [我的预约] 筛选结果详情:', this.appointmentList)
},
// 获取状态文本
getStatusText(status) {
const statusMap = {
'0': '待咨询',
'1': '已完成',
'2': '已取消',
'3': '未到访',
// 兼容英文状态值
'pending': '待咨询',
'completed': '已完成',
'cancelled': '已取消',
'expired': '已过期'
}
return statusMap[status] || '未知状态'
},
// 获取状态类型(用于标签颜色)
getStatusType(status) {
const typeMap = {
'0': 'warning',
'1': 'success',
'2': 'primary', // 已取消状态使用蓝色
'3': 'info', // 未到访状态使用灰色
// 兼容英文状态值
'pending': 'warning',
'completed': 'success',
'cancelled': 'primary', // 已取消状态使用蓝色
'expired': 'info'
}
return typeMap[status] || 'info'
},
// 获取空状态文本
getEmptyText() {
const currentFilter = this.filterTabs[this.activeFilterIndex]
const textMap = {
'all': '暂无预约记录',
'0': '暂无待咨询预约',
'1': '暂无已完成预约',
'2': '暂无已取消预约',
'3': '暂无未到访预约'
}
return textMap[currentFilter.value] || '暂无数据'
},
// 查看预约详情
viewAppointmentDetail(appointment) {
// 这里可以跳转到预约详情页面,或者显示详情弹窗
console.log('查看预约详情:', appointment)
// uni.navigateTo({
// url: `/pages/xljk/appointment-detail/index?id=${appointment.id}`
// })
},
// 处理取消按钮点击
handleCancelClick(appointment) {
console.log('🔄 [取消预约] 按钮点击处理:', {
appointmentId: appointment.id,
appointmentStatus: appointment.status,
当前状态: {
cancelLoading: this.cancelLoading,
cancelingAppointmentId: this.cancelingAppointmentId,
isDisabled: this.isCancelButtonDisabled(appointment.id)
}
})
// 调用取消预约方法
this.cancelAppointment(appointment)
},
// 取消预约
cancelAppointment(appointment) {
console.log('点击取消预约按钮,预约信息:', appointment)
if (appointment.status !== '0') {
uni.showToast({
title: '只能取消待咨询的预约',
icon: 'none'
})
return
}
// 检查是否已经在取消中
if (this.cancelingAppointmentId === appointment.id) {
return
}
console.log('使用原生弹窗显示取消确认')
this.selectedAppointment = appointment
// 使用原生确认弹窗
uni.showModal({
title: '确认取消',
content: '确定要取消这次预约吗?取消后将无法恢复。',
confirmText: '确认取消',
cancelText: '再想想',
confirmColor: '#ff4757',
success: (res) => {
if (res.confirm) {
console.log('用户确认取消')
this.confirmCancel()
} else if (res.cancel) {
console.log('用户取消了取消操作')
this.closeCancelModal()
}
}
})
},
// 确认取消预约
async confirmCancel() {
if (!this.selectedAppointment || this.cancelLoading) return
const appointmentId = this.selectedAppointment.id
this.cancelLoading = true
this.cancelingAppointmentId = appointmentId // 标记正在取消的预约
console.log('🔄 [取消预约] 开始处理:', {
appointmentId,
当前状态: {
cancelLoading: this.cancelLoading,
cancelingAppointmentId: this.cancelingAppointmentId
}
})
// 强制超时重置机制,防止状态一直不重置
const forceResetTimeout = setTimeout(() => {
console.log('⚠️ [取消预约] 强制超时重置状态')
this.cancelLoading = false
this.cancelingAppointmentId = null
uni.showToast({
title: '操作超时,已重置',
icon: 'none'
})
}, 45000) // 45秒强制重置
try {
if (!appointmentId) {
throw new Error('预约ID为空')
}
console.log('📡 [取消预约] 发送API请求...')
// 添加超时控制,手机端网络可能较慢
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('请求超时,请重试'))
}, 30000) // 30秒超时
})
const apiPromise = cancelAppointment(appointmentId)
const response = await Promise.race([apiPromise, timeoutPromise])
console.log('📡 [取消预约] API响应:', response)
if (response && response.code === 200) {
console.log('✅ [取消预约] 取消成功')
uni.showToast({
title: '取消成功',
icon: 'success'
})
// 立即更新本地数据,避免重新请求
const appointmentIndex = this.allAppointments.findIndex(item => item.id === appointmentId)
if (appointmentIndex !== -1) {
// 更新状态为已取消
this.allAppointments[appointmentIndex].status = '2'
console.log('📊 [取消预约] 本地数据已更新:', this.allAppointments[appointmentIndex])
// 立即刷新筛选和计数
this.updateFilterCounts()
this.filterAppointments()
}
this.closeCancelModal()
// 注释掉重新加载,使用本地更新提升用户体验
// this.loadAppointments()
} else {
const errorMsg = response?.msg || '取消失败'
console.log('❌ [取消预约] 业务错误:', errorMsg)
uni.showToast({
title: errorMsg,
icon: 'error'
})
}
} catch (error) {
console.error('❌ [取消预约] 请求失败:', error)
let errorMessage = '取消失败,请检查网络连接'
if (error.message === '请求超时,请重试') {
errorMessage = '请求超时,请重试'
} else if (error.errMsg && error.errMsg.includes('timeout')) {
errorMessage = '网络超时,请检查网络连接'
} else if (error.errMsg && error.errMsg.includes('fail')) {
errorMessage = '网络连接失败,请检查网络'
}
uni.showToast({
title: errorMessage,
icon: 'error'
})
} finally {
console.log('🔄 [取消预约] 清理状态')
clearTimeout(forceResetTimeout) // 清除强制重置定时器
this.cancelLoading = false
this.cancelingAppointmentId = null // 重置正在取消的预约ID
}
},
// 重置取消预约状态
closeCancelModal() {
console.log('🔄 [取消预约] 重置所有状态:', {
之前状态: {
selectedAppointment: this.selectedAppointment?.id,
cancelLoading: this.cancelLoading,
cancelingAppointmentId: this.cancelingAppointmentId
}
})
this.selectedAppointment = null
this.cancelLoading = false // 重置加载状态
this.cancelingAppointmentId = null // 重置正在取消的预约ID
console.log('🔄 [取消预约] 状态重置完成:', {
当前状态: {
selectedAppointment: this.selectedAppointment,
cancelLoading: this.cancelLoading,
cancelingAppointmentId: this.cancelingAppointmentId
}
})
},
// 跳转到预约页面
goToReservation() {
uni.navigateBack()
},
// 检查特定预约是否正在取消
isAppointmentCanceling(appointmentId) {
return this.cancelingAppointmentId === appointmentId
},
// 获取取消按钮文本
getCancelButtonText(appointmentId) {
return this.cancelingAppointmentId === appointmentId ? '取消中...' : '取消预约'
},
// 检查取消按钮是否禁用
isCancelButtonDisabled(appointmentId) {
return this.cancelingAppointmentId === appointmentId
},
// 重置取消相关状态
resetCancelState() {
console.log('🔄 [取消预约] 重置取消相关状态')
this.selectedAppointment = null
this.cancelLoading = false
this.cancelingAppointmentId = null
}
}
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f5f5f5;
}
.main-content {
height: calc(100vh - 88rpx); // 减去导航栏高度
padding-top: 20rpx;
}
.filter-section {
background: #ffffff;
margin-bottom: 20rpx;
.filter-tabs {
display: flex;
padding: 20rpx;
.filter-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 20rpx 10rpx;
border-radius: 12rpx;
transition: all 0.3s ease;
&.active {
background: rgba(24, 144, 255, 0.1);
.tab-text {
color: #1890ff;
font-weight: 600;
}
}
.tab-text {
font-size: 28rpx;
color: #666;
margin-right: 8rpx;
}
.tab-badge-circle {
width: 16rpx;
height: 16rpx;
background: #ff4d4f;
border-radius: 50%;
margin-left: 8rpx;
}
}
}
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 20rpx;
.loading-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
}
.appointment-list {
padding: 0 20rpx;
.appointment-card {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:last-child {
margin-bottom: 0;
}
&:active {
transform: scale(0.98);
background: rgba(24, 144, 255, 0.02);
}
.appointment-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24rpx;
.appointment-date {
.date-text {
font-size: 32rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.time-text {
font-size: 26rpx;
color: #666;
display: block;
}
}
}
.counselor-section {
display: flex;
align-items: center;
margin-bottom: 24rpx;
.counselor-avatar {
margin-right: 20rpx;
}
.counselor-info {
flex: 1;
.counselor-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.appointment-reason {
font-size: 26rpx;
color: #666;
display: block;
}
}
.inline-action {
.cancel-button-inline {
background-color: #ff4757;
color: white;
border: none;
border-radius: 12rpx;
padding: 12rpx 20rpx;
font-size: 24rpx;
font-weight: 500;
cursor: pointer;
outline: none;
min-width: 120rpx;
&:disabled {
background-color: #cccccc;
color: #999999;
cursor: not-allowed;
}
&:active:not(:disabled) {
background-color: #ff3742;
transform: scale(0.98);
}
}
}
}
}
}
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 20rpx;
}
.cancel-modal-content {
padding: 20rpx 0;
.cancel-text {
font-size: 28rpx;
color: #666;
line-height: 1.5;
text-align: center;
}
}
</style>
@@ -0,0 +1,708 @@
<template>
<view class="page-container">
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-left" @click="goBack">
<u-icon name="arrow-left" size="20" color="#ffffff"></u-icon>
</view>
<text class="navbar-title">预约咨询</text>
<view class="navbar-right"></view>
</view>
<!-- 主要内容区域 -->
<scroll-view class="main-content" scroll-y enhanced :show-scrollbar="false">
<!-- 预约信息确认 -->
<view class="confirm-section">
<view class="section-title">
<u-icon name="calendar" size="16" color="#3c9cff"></u-icon>
<text class="title-text">预约信息确认</text>
</view>
<view class="confirm-card">
<view class="confirm-item">
<text class="confirm-label">预约日期:</text>
<text class="confirm-value">{{ formattedDate }}</text>
</view>
<view class="confirm-item">
<text class="confirm-label">预约时段:</text>
<text class="confirm-value">{{ appointmentData.timeRange }}</text>
</view>
<view class="confirm-item">
<text class="confirm-label">咨询师:</text>
<text class="confirm-value">{{ appointmentData.counselorName }}</text>
</view>
</view>
</view>
<!-- 个人信息 -->
<view class="form-section">
<view class="section-title">
<u-icon name="account" size="16" color="#3c9cff"></u-icon>
<text class="title-text">个人信息</text>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label">姓名:</text>
<text class="form-value">{{ userInfo.nickName || userInfo.userName || '未设置' }}</text>
</view>
<view class="form-item">
<text class="form-label">学号:</text>
<text class="form-value">{{ userInfo.userName || '未设置' }}</text>
</view>
<!-- 院系、专业、班级信息 -->
<view class="form-item">
<text class="form-label">院系:</text>
<u-picker
:columns="[collegeOptions]"
keyName="label"
v-model="collegeIndex"
:show="showCollegePicker"
@confirm="onCollegeConfirm"
@close="showCollegePicker = false"
></u-picker>
<view class="picker-input" @click="showCollegePicker = true">
<text class="picker-text" :class="{ placeholder: !form.collegeName }">
{{ form.collegeName || '请选择院系' }}
</text>
<u-icon name="arrow-right" size="14" color="#c8c9cc"></u-icon>
</view>
</view>
<view class="form-item">
<text class="form-label">专业:</text>
<u-picker
:columns="[majorOptions]"
keyName="label"
v-model="majorIndex"
:show="showMajorPicker"
@confirm="onMajorConfirm"
@close="showMajorPicker = false"
></u-picker>
<view class="picker-input" @click="handleMajorPickerClick">
<text class="picker-text" :class="{ placeholder: !form.majorName }">
{{ form.majorName || '请选择专业' }}
</text>
<u-icon name="arrow-right" size="14" color="#c8c9cc"></u-icon>
</view>
</view>
<view class="form-item">
<text class="form-label">班级:</text>
<u-picker
:columns="[classOptions]"
keyName="label"
v-model="classIndex"
:show="showClassPicker"
@confirm="onClassConfirm"
@close="showClassPicker = false"
></u-picker>
<view class="picker-input" @click="handleClassPickerClick">
<text class="picker-text" :class="{ placeholder: !form.className }">
{{ form.className || '请选择班级' }}
</text>
<u-icon name="arrow-right" size="14" color="#c8c9cc"></u-icon>
</view>
</view>
</view>
</view>
<!-- 咨询原因 -->
<view class="form-section">
<view class="section-title">
<u-icon name="chat" size="16" color="#3c9cff"></u-icon>
<text class="title-text">咨询原因</text>
<text class="required-mark">*</text>
</view>
<view class="form-card">
<u-input
v-model="form.reason"
type="textarea"
placeholder="请详细描述您希望咨询的问题或困扰,以便咨询师更好地了解您的需求"
:maxlength="500"
:show-word-limit="true"
:auto-height="true"
:custom-style="{
minHeight: '120px',
backgroundColor: 'transparent',
border: 'none'
}"
></u-input>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-section">
<view class="section-title">
<u-icon name="info-circle" size="16" color="#ff9900"></u-icon>
<text class="title-text">温馨提示</text>
</view>
<view class="tips-card">
<view class="tip-item">
<text class="tip-number">1.</text>
<text class="tip-text">请提前5-10分钟到达咨询室,迟到可能影响咨询效果</text>
</view>
<view class="tip-item">
<text class="tip-number">2.</text>
<text class="tip-text">咨询内容严格保密,请放心倾述您的困扰</text>
</view>
<view class="tip-item">
<text class="tip-number">3.</text>
<text class="tip-text">如需取消预约,请提前24小时操作</text>
</view>
<view class="tip-item">
<text class="tip-number">4.</text>
<text class="tip-text">连续3次未到访将被限制预约功能</text>
</view>
</view>
</view>
<!-- 底部间距 -->
<view class="bottom-spacer"></view>
</scroll-view>
<!-- 底部提交按钮 -->
<view class="submit-container">
<u-button
text="提交预约"
type="primary"
:loading="submitting"
:disabled="!canSubmit"
@click="submitAppointment"
:custom-style="{
width: '100%',
height: '50px',
fontSize: '18px',
fontWeight: 'bold'
}"
></u-button>
</view>
</view>
</template>
<script>
import {
submitAppointment,
getCollegeOptions,
getMajorOptions,
getClassOptions,
checkAppointmentLimitation
} from '@/api/xljk';
import { userStorage } from '@/common/storage';
export default {
name: 'AppointmentForm',
data() {
return {
// 用户信息
userInfo: {},
// 预约数据
appointmentData: {
date: '',
scheduleId: '',
counselorId: '',
timeRange: '',
counselorName: ''
},
// 表单数据
form: {
collegeName: '',
majorName: '',
className: '',
reason: ''
},
// 选项数据
collegeOptions: [],
majorOptions: [],
classOptions: [],
// 选择器状态
showCollegePicker: false,
showMajorPicker: false,
showClassPicker: false,
// 选择器索引
collegeIndex: [0],
majorIndex: [0],
classIndex: [0],
// 提交状态
submitting: false
};
},
computed: {
formattedDate() {
if (!this.appointmentData.date) return '';
const date = new Date(this.appointmentData.date);
const month = date.getMonth() + 1;
const day = date.getDate();
const weekDay = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];
return `${month}月${day}日 ${weekDay}`;
},
canSubmit() {
return this.form.reason.trim().length > 0 &&
this.form.collegeName &&
this.form.majorName &&
this.form.className;
}
},
onLoad(options) {
console.log('预约表单页面加载,参数:', options);
this.checkLoginStatus();
this.parseParams(options);
this.loadOptions();
},
methods: {
// 检查登录状态
checkLoginStatus() {
if (!userStorage.isLoggedIn()) {
uni.reLaunch({
url: '/pages/login/index',
});
return false;
}
const userStorage_data = userStorage.getUserInfo();
if (userStorage_data && userStorage_data.user) {
this.userInfo = userStorage_data.user;
}
console.log('用户信息:', this.userInfo);
return true;
},
// 解析页面参数
parseParams(options) {
this.appointmentData = {
date: options.date || '',
scheduleId: options.scheduleId || '',
counselorId: options.counselorId || '',
timeRange: options.timeRange || '',
counselorName: decodeURIComponent(options.counselorName || '')
};
console.log('预约数据:', this.appointmentData);
},
// 加载选项数据
async loadOptions() {
try {
// 加载院系选项
const collegeRes = await getCollegeOptions();
if (collegeRes.code === 200 && collegeRes.data) {
this.collegeOptions = collegeRes.data.map(item => ({
label: item.label || item.name,
value: item.value || item.name
}));
}
// 加载所有专业选项
const majorRes = await getMajorOptions();
if (majorRes.code === 200 && majorRes.data) {
this.majorOptions = majorRes.data.map(item => ({
label: item.label || item.name,
value: item.value || item.name
}));
}
// 加载所有班级选项
const classRes = await getClassOptions();
if (classRes.code === 200 && classRes.data) {
this.classOptions = classRes.data.map(item => ({
label: item.label || item.name,
value: item.value || item.name
}));
}
console.log('选项数据加载完成:', {
colleges: this.collegeOptions.length,
majors: this.majorOptions.length,
classes: this.classOptions.length
});
} catch (error) {
console.error('加载选项数据失败:', error);
}
},
// 院系选择确认
async onCollegeConfirm(e) {
console.log('院系选择:', e);
const index = e.value[0];
const selectedCollege = this.collegeOptions[index];
if (selectedCollege) {
this.form.collegeName = selectedCollege.value;
this.collegeIndex = [index];
// 清空专业和班级
this.form.majorName = '';
this.form.className = '';
this.majorIndex = [0];
this.classIndex = [0];
// 重新加载专业选项
try {
const majorRes = await getMajorOptions(selectedCollege.value);
if (majorRes.code === 200 && majorRes.data) {
this.majorOptions = majorRes.data.map(item => ({
label: item.label || item.name,
value: item.value || item.name
}));
}
} catch (error) {
console.error('加载专业选项失败:', error);
}
}
this.showCollegePicker = false;
},
// 专业选择确认
async onMajorConfirm(e) {
console.log('专业选择:', e);
const index = e.value[0];
const selectedMajor = this.majorOptions[index];
if (selectedMajor) {
this.form.majorName = selectedMajor.value;
this.majorIndex = [index];
// 清空班级
this.form.className = '';
this.classIndex = [0];
// 重新加载班级选项
try {
const classRes = await getClassOptions(this.form.collegeName, selectedMajor.value);
if (classRes.code === 200 && classRes.data) {
this.classOptions = classRes.data.map(item => ({
label: item.label || item.name,
value: item.value || item.name
}));
}
} catch (error) {
console.error('加载班级选项失败:', error);
}
}
this.showMajorPicker = false;
},
// 班级选择确认
onClassConfirm(e) {
console.log('班级选择:', e);
const index = e.value[0];
const selectedClass = this.classOptions[index];
if (selectedClass) {
this.form.className = selectedClass.value;
this.classIndex = [index];
}
this.showClassPicker = false;
},
// 处理专业选择器点击
handleMajorPickerClick() {
if (!this.form.collegeName) {
uni.showToast({
title: '请先选择院系',
icon: 'none'
});
return;
}
this.showMajorPicker = true;
},
// 处理班级选择器点击
handleClassPickerClick() {
if (!this.form.collegeName) {
uni.showToast({
title: '请先选择院系',
icon: 'none'
});
return;
}
if (!this.form.majorName) {
uni.showToast({
title: '请先选择专业',
icon: 'none'
});
return;
}
this.showClassPicker = true;
},
// 提交预约
async submitAppointment() {
if (!this.canSubmit) {
uni.showToast({
title: '请完善预约信息',
icon: 'none'
});
return;
}
this.submitting = true;
try {
// 检查预约限制
if (this.userInfo.userId) {
const limitationCheck = await checkAppointmentLimitation(this.userInfo.userId);
if (limitationCheck.code === 200 && limitationCheck.data && limitationCheck.data.limited) {
uni.showToast({
title: `您暂时无法预约,限制将于 ${limitationCheck.data.expireDateStr} 解除`,
icon: 'none',
duration: 3000
});
return;
}
}
// 构建提交数据
const submitData = {
studentId: this.userInfo.userId,
studentName: this.userInfo.nickName || this.userInfo.userName,
studentNo: this.userInfo.userName,
collegeName: this.form.collegeName,
majorName: this.form.majorName,
className: this.form.className,
scheduleId: this.appointmentData.scheduleId,
counselorId: this.appointmentData.counselorId,
counselorName: this.appointmentData.counselorName,
appointmentDate: this.appointmentData.date,
appointmentTime: this.appointmentData.timeRange,
appointmentDetailTime: this.appointmentData.timeRange,
reason: this.form.reason.trim()
};
console.log('提交预约数据:', submitData);
const response = await submitAppointment(submitData);
if (response.code === 200) {
uni.showToast({
title: '预约成功',
icon: 'success'
});
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
uni.navigateBack({
delta: 2 // 返回到主页面
});
}, 1500);
} else {
uni.showToast({
title: response.msg || '预约失败',
icon: 'none',
duration: 3000
});
}
} catch (error) {
console.error('提交预约失败:', error);
uni.showToast({
title: '网络错误,请重试',
icon: 'none'
});
} finally {
this.submitting = false;
}
},
// 返回上一页
goBack() {
uni.navigateBack();
}
}
};
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.custom-navbar {
background: #1890ff;
padding: calc(var(--status-bar-height) + 12px) 16px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar-left, .navbar-right {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.navbar-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.main-content {
flex: 1;
padding-bottom: 80px;
}
/* 通用样式 */
.confirm-section, .form-section, .tips-section {
margin: 16px;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.title-text {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-left: 8px;
}
.required-mark {
color: #f56c6c;
margin-left: 4px;
}
.confirm-card, .form-card, .tips-card {
background-color: #ffffff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 确认信息 */
.confirm-item {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.confirm-item:last-child {
margin-bottom: 0;
}
.confirm-label {
font-size: 14px;
color: #606266;
margin-right: 8px;
min-width: 80px;
}
.confirm-value {
font-size: 14px;
color: #303133;
font-weight: 500;
}
/* 表单样式 */
.form-item {
display: flex;
align-items: center;
margin-bottom: 16px;
min-height: 40px;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
font-size: 14px;
color: #606266;
margin-right: 12px;
min-width: 60px;
flex-shrink: 0;
}
.form-value {
font-size: 14px;
color: #303133;
flex: 1;
}
.picker-input {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding: 8px 12px;
background-color: #f8f9fa;
border-radius: 12px;
min-height: 24px;
border: 1px solid #eee;
}
.picker-text {
font-size: 14px;
color: #303133;
flex: 1;
}
.picker-text.placeholder {
color: #c8c9cc;
}
/* 温馨提示 */
.tip-item {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.tip-item:last-child {
margin-bottom: 0;
}
.tip-number {
font-size: 14px;
color: #ff9900;
margin-right: 8px;
font-weight: 500;
flex-shrink: 0;
}
.tip-text {
font-size: 14px;
color: #606266;
line-height: 1.5;
}
/* 底部提交按钮 */
.submit-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
padding: 16px;
border-top: 1px solid #eee;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
.bottom-spacer {
height: 20px;
}
</style>
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -145,10 +145,16 @@ export interface StglActivityVO {
activityRange?: number;
/**
* 活动状态
* 活动状态(动态计算后的状态)
*/
activityStatus?: string;
/**
* 原始活动状态(数据库中存储的实际状态)
* 用于权限控制和业务逻辑判断
*/
originalActivityStatus?: string;
/**
* 是否为已办活动
*/
+2 -2
View File
@@ -211,8 +211,8 @@ export interface MyRegistrationQuery {
pageNum?: number;
/** 每页显示记录数 */
pageSize?: number;
/** 名称 */
name?: string;
/** 报名类型 */
registrationType?: string;
/** 审核状态 */
approvalStatus?: string;
}
@@ -134,6 +134,7 @@
<el-table-column label="申请类型" align="center" prop="applicationType" min-width="100">
<template #default="scope">
<el-tag v-if="scope.row.applicationType === 'RECOMMEND'" type="primary">教师推荐</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'TEACHER_RECOMMEND'" type="primary">教师推荐</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'STUDENT'" type="success">学生申请</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'AUTO'" type="info">自动评定</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'DIRECT'" type="warning">直接指定</el-tag>
@@ -311,7 +312,7 @@
<div class="flex-1">
<div class="student-basic-info flex items-center space-x-4 mb-2">
<span class="font-bold">{{ student.studentName }}</span>
<span class="text-gray-600">{{ student.studentNo }}</span>
<span class="text-gray-600">{{ student.studentCode }}</span>
<span class="text-gray-500">{{ student.gender }}</span>
<span class="text-gray-500">{{ student.phone }}</span>
</div>
@@ -963,7 +964,7 @@ const filterStudents = () => {
filteredStudents.value = classStudents.value;
} else {
filteredStudents.value = classStudents.value.filter(
(student: any) => student.studentName?.includes(studentKeyword.value) || student.studentNo?.includes(studentKeyword.value)
(student: any) => student.studentName?.includes(studentKeyword.value) || student.studentCode?.includes(studentKeyword.value)
);
}
};
@@ -989,7 +990,8 @@ const toggleStudentSelection = (student: any, checked: boolean) => {
if (availableScholarshipTypes.value.length === 1) {
const scholarshipType = availableScholarshipTypes.value[0];
if (canSelectScholarshipType(student, scholarshipType)) {
student.selectedScholarshipType = scholarshipType;
// ✅ 创建对象副本,避免引用问题
student.selectedScholarshipType = { ...scholarshipType };
updateScholarshipInfo(student);
console.log('🎯 自动选择唯一可用的奖学金类型:', scholarshipType.name);
} else {
@@ -1042,12 +1044,25 @@ const clearSelectedStudents = () => {
/** 更新奖学金信息 */
const updateScholarshipInfo = (student: any) => {
console.log('🔄 更新奖学金信息:', { studentName: student.studentName, selectedType: student.selectedScholarshipType?.name });
console.log('🔄 更新奖学金信息:', {
studentName: student.studentName,
selectedType: student.selectedScholarshipType?.name,
originalType: student.selectedScholarshipType
});
if (student.selectedScholarshipType) {
// ✅ 立即创建对象副本,确保 v-model 绑定的是副本而不是原始引用
student.selectedScholarshipType = { ...student.selectedScholarshipType };
// 使用副本更新相关字段
student.selectedLevel = student.selectedScholarshipType.levelType;
student.selectedAmount = student.selectedScholarshipType.amount;
console.log('✅ 创建奖学金类型副本:', {
copy: student.selectedScholarshipType,
name: student.selectedScholarshipType.name
});
// 检查名额是否足够
if (!canSelectScholarshipType(student, student.selectedScholarshipType)) {
ElMessage.warning(`${student.selectedScholarshipType.name} 名额不足,请选择其他类型`);
@@ -1120,7 +1135,7 @@ const submitBatchApplication = async () => {
const studentApplications = selectedStudents.value.map((s: any) => ({
studentId: String(s.studentId || s.id),
studentName: s.studentName,
studentNo: s.studentNo,
studentNo: s.studentCode, // ✅ 修复:使用后端返回的 studentCode 字段作为学号
scholarshipTypeId: s.selectedScholarshipType.id,
deptId: classInfo.value.deptId,
applicationReason: `班主任批量申请 - ${s.selectedScholarshipType.name}`,
@@ -114,6 +114,7 @@
<el-form-item label="申请类型" prop="applicationType">
<el-select v-model="queryParams.applicationType" placeholder="请选择申请类型" clearable style="width: 100%">
<el-option label="教师推荐" value="RECOMMEND" />
<el-option label="教师推荐" value="TEACHER_RECOMMEND" />
<el-option label="学生申请" value="STUDENT" />
<el-option label="自动评定" value="AUTO" />
<el-option label="直接指定" value="DIRECT" />
@@ -194,6 +195,7 @@
<template #default="scope">
<el-tag v-if="scope.row.applicationType === 'RECOMMEND'" type="primary">教师推荐</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'STUDENT'" type="success">学生申请</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'TEACHER_RECOMMEND'" type="primary">教师推荐</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'AUTO'" type="info">自动评定</el-tag>
<el-tag v-else-if="scope.row.applicationType === 'DIRECT'" type="warning">直接指定</el-tag>
<el-tag v-else type="info">{{ scope.row.applicationType }}</el-tag>
@@ -684,14 +686,25 @@ function handleReview(row) {
console.log('🔍 [DEBUG] handleReview 开始执行');
console.log('🔍 [DEBUG] 传入的row数据:', JSON.stringify(row, null, 2));
reviewForm.value = {
id: row.id,
status: 'APPROVED',
reviewComment: ''
};
// 先获取完整的申请数据
getScholarshipApplication(row.id)
.then((response) => {
console.log('🔍 [DEBUG] 获取到完整申请数据:', response.data);
console.log('🔍 [DEBUG] 设置的reviewForm:', JSON.stringify(reviewForm.value, null, 2));
reviewOpen.value = true;
// 设置完整的审核表单数据
reviewForm.value = {
...response.data, // 包含所有申请信息
status: 'APPROVED', // 默认审核状态
reviewComment: '' // 审核意见
};
console.log('🔍 [DEBUG] 设置的完整reviewForm:', JSON.stringify(reviewForm.value, null, 2));
reviewOpen.value = true;
})
.catch((error) => {
console.error('❌ [ERROR] 获取申请详情失败:', error);
proxy.$modal.msgError('获取申请详情失败');
});
}
/** 查看详情 */
@@ -171,6 +171,9 @@
@cancel="applyDialogVisible = false"
/>
</el-dialog>
<!-- 申请详情对话框(复用组件) -->
<ApplicationDetail ref="applicationDetailRef" />
</div>
</template>
@@ -183,12 +186,14 @@ import type { WorkStudyPositionVO } from '@/api/jcbt/workStudyPosition';
import { getStudentApplicationStatusList } from '@/api/jcbt/workStudyApplication';
import PositionDetail from './detail.vue';
import ApplicationForm from './form.vue';
import ApplicationDetail from './components/ApplicationDetail.vue';
const router = useRouter();
// 组件引用
const queryFormRef = ref();
const tableRef = ref();
const applicationDetailRef = ref();
// 响应式数据
const loading = ref(false);
@@ -313,7 +318,14 @@ const handleApply = (position: WorkStudyPositionVO) => {
// 如果已有申请且状态为已提交或审核中,跳转到申请管理页面
if (application && ['submitted', 'college_reviewing', 'final_reviewing'].includes(application.status)) {
router.push('/jcbt/workstudy/my-applications');
const id = application?.applicationId || application?.id;
if (id && applicationDetailRef.value) {
applicationDetailRef.value.handleDetail(id, 'application');
} else {
// 回退到岗位详情对话框
selectedPositionId.value = position.id as number;
detailDialogVisible.value = true;
}
return;
}
@@ -3,186 +3,69 @@
<el-tabs type="border-card" v-model="activeTab">
<!-- 基本信息标签页 -->
<el-tab-pane label="基本信息" name="basic">
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">活动名称</label>
<div class="detail-value">{{ activity.activityName || '暂无' }}</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">活动类型</label>
<div class="detail-value">
<dict-tag :options="stgl_activity_type" :value="activity.activityType" />
</div>
</div>
</el-col>
</el-row>
<!-- 基本信息区域 -->
<el-divider content-position="left">基本信息</el-divider>
<el-descriptions :column="2" border class="basic-info-section custom-width-registration">
<el-descriptions-item label="活动名称">{{ activity.activityName || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="活动类型">
<dict-tag :options="stgl_activity_type" :value="activity.activityType" />
</el-descriptions-item>
<el-descriptions-item label="活动主题">{{ activity.activityTheme || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="活动地点">{{ activity.location || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ activity.startTime || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ activity.endTime || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="活动状态">
<dict-tag :options="stgl_activity_status" :value="activity.activityStatus" />
</el-descriptions-item>
<el-descriptions-item label="关联社团/队伍">{{ activity.relatedClubName || '暂无' }}</el-descriptions-item>
</el-descriptions>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">活动主题</label>
<div class="detail-value">{{ activity.activityTheme || '暂无' }}</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">活动地点</label>
<div class="detail-value">{{ activity.location || '暂无' }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">开始时间</label>
<div class="detail-value">{{ activity.startTime || '暂无' }}</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">结束时间</label>
<div class="detail-value">{{ activity.endTime || '暂无' }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">活动状态</label>
<div class="detail-value">
<dict-tag :options="stgl_activity_status" :value="activity.activityStatus" />
</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">关联社团/队伍</label>
<div class="detail-value">{{ activity.relatedClubName || '暂无' }}</div>
</div>
</el-col>
</el-row>
<div class="detail-item">
<label class="detail-label">活动海报</label>
<div class="detail-value">
<div v-if="activity.posterUrl" class="view-poster">
<image-preview :src="activity.posterUrl" :width="200" :height="150" />
</div>
<div v-else class="no-poster">
<span class="no-poster-text">暂无海报</span>
</div>
</div>
<!-- 活动海报区域 -->
<el-divider content-position="left">活动海报</el-divider>
<div v-if="activity.posterUrl" class="view-poster">
<image-preview :src="activity.posterUrl" :width="200" :height="150" />
</div>
<div v-else class="no-poster">
<span class="no-poster-text">暂无海报</span>
</div>
<div class="detail-item">
<label class="detail-label">活动简介</label>
<div class="detail-value">
<div class="view-description">
{{ activity.description || '暂无简介' }}
</div>
</div>
<!-- 活动简介区域 -->
<el-divider content-position="left">活动简介</el-divider>
<div class="view-description">
{{ activity.description || '暂无简介' }}
</div>
<!-- 主办方信息 -->
<div class="section-divider">
<h4 class="section-title">主办方信息</h4>
</div>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">主办方名称</label>
<div class="detail-value">{{ activity.organizerName || '暂无' }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">联系人</label>
<div class="detail-value">{{ activity.organizerContact || '暂无' }}</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">联系电话</label>
<div class="detail-value">{{ activity.organizerPhone || '暂无' }}</div>
</div>
</el-col>
</el-row>
<!-- 主办方信息区域 -->
<el-divider content-position="left">主办方信息</el-divider>
<el-descriptions :column="3" border class="organizer-info-section custom-width-organizer">
<el-descriptions-item label="主办方名称" :label-style="{ width: '13%' }" :content-style="{ width: '35%' }">{{ activity.organizerName || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="联系人" :label-style="{ width: '10%' }" :content-style="{ width: '15%' }">{{ activity.organizerContact || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="联系电话" :label-style="{ width: '10%' }" :content-style="{ width: '17%' }">{{ activity.organizerPhone || '暂无' }}</el-descriptions-item>
</el-descriptions>
<!-- 承办方信息 -->
<div class="section-divider">
<h4 class="section-title">承办方信息</h4>
</div>
<!-- 承办方信息区域 -->
<el-divider content-position="left">承办方信息</el-divider>
<div v-if="coOrganizerList.length > 0">
<div v-for="(organizer, index) in coOrganizerList" :key="index" class="co-organizer-item">
<el-row :gutter="20">
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">承办方名称</label>
<div class="detail-value">{{ organizer.name || '暂无' }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">承办方类型</label>
<div class="detail-value">{{ organizer.type || '暂无' }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">联系人</label>
<div class="detail-value">{{ organizer.contact || '暂无' }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">联系电话</label>
<div class="detail-value">{{ organizer.phone || '暂无' }}</div>
</div>
</el-col>
</el-row>
<div v-for="(organizer, index) in coOrganizerList" :key="index" class="co-organizer-item custom-width-co-organizer">
<el-descriptions :column="3" border>
<el-descriptions-item label="承办方名称" :label-style="{ width: '12%' }" :content-style="{ width: '36%' }">{{ organizer.name || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="联系人" :label-style="{ width: '10%' }" :content-style="{ width: '16%' }">{{ organizer.contact || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="联系电话" :label-style="{ width: '10%' }" :content-style="{ width: '16%' }">{{ organizer.phone || '暂无' }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<div v-else class="no-data">暂无承办方信息</div>
<!-- 指导老师信息 -->
<div class="section-divider">
<h4 class="section-title">指导老师信息</h4>
</div>
<!-- 指导老师信息区域 -->
<el-divider content-position="left">指导老师信息</el-divider>
<div v-if="instructorList.length > 0">
<div v-for="(instructor, index) in instructorList" :key="index" class="instructor-item">
<el-row :gutter="20">
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">姓名</label>
<div class="detail-value">{{ instructor.name || '暂无' }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">部门</label>
<div class="detail-value">{{ instructor.dept || '暂无' }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">电话</label>
<div class="detail-value">{{ instructor.phone || '暂无' }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-item">
<label class="detail-label">邮箱</label>
<div class="detail-value">{{ instructor.email || '暂无' }}</div>
</div>
</el-col>
</el-row>
<div v-for="(instructor, index) in instructorList" :key="index" class="instructor-item custom-width-instructor">
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="姓名" :label-style="{ width: '8%' }" :content-style="{ width: '15%' }">{{ instructor.name || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="部门" :label-style="{ width: '8%' }" :content-style="{ width: '17%' }">{{ instructor.dept || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="电话" :label-style="{ width: '8%' }" :content-style="{ width: '15%' }">{{ instructor.phone || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="邮箱" :label-style="{ width: '8%' }" :content-style="{ width: '17%' }">{{ instructor.email || '暂无' }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<div v-else class="no-data">暂无指导老师信息</div>
@@ -190,100 +73,46 @@
<!-- 报名设置标签页 -->
<el-tab-pane label="报名设置" name="registration">
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">报名方式</label>
<div class="detail-value">
<dict-tag :options="stgl_registration_type" :value="activity.registrationType" />
</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">是否需要审核</label>
<div class="detail-value">
<!-- 临时解决方案:直接显示中文文本 -->
<el-tag v-if="activity.needApproval === 1" type="warning" size="small">需要审核</el-tag>
<el-tag v-else-if="activity.needApproval === 0" type="success" size="small">无需审核</el-tag>
<span v-else class="text-gray-400">{{ activity.needApproval }}</span>
</div>
</div>
</el-col>
</el-row>
<!-- 报名方式和审核设置区域 -->
<el-divider content-position="left">报名方式和审核设置</el-divider>
<el-descriptions :column="2" border class="registration-info-section custom-width-registration">
<el-descriptions-item label="报名方式" :label-style="{ width: '10%' }" :content-style="{ width: '40%' }">
<dict-tag :options="stgl_registration_type" :value="activity.registrationType" />
</el-descriptions-item>
<el-descriptions-item label="是否需要审核" :label-style="{ width: '10%' }" :content-style="{ width: '40%' }">
<!-- 临时解决方案:直接显示中文文本 -->
<el-tag v-if="activity.needApproval === 1" type="warning" size="small">需要审核</el-tag>
<el-tag v-else-if="activity.needApproval === 0" type="success" size="small">无需审核</el-tag>
<span v-else class="text-gray-400">{{ activity.needApproval }}</span>
</el-descriptions-item>
<el-descriptions-item label="报名范围" :label-style="{ width: '10%' }" :content-style="{ width: '40%' }">
<dict-tag :options="stgl_activity_range" :value="activity.activityRange" />
</el-descriptions-item>
<el-descriptions-item label="报名状态" :label-style="{ width: '10%' }" :content-style="{ width: '40%' }">
<dict-tag :options="stgl_registration_status" :value="activity.registrationStatus" />
</el-descriptions-item>
</el-descriptions>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">报名范围</label>
<div class="detail-value">
<dict-tag :options="stgl_activity_range" :value="activity.activityRange" />
</div>
</div>
</el-col>
</el-row>
<!-- 报名时间区域 -->
<el-divider content-position="left">报名时间</el-divider>
<el-descriptions :column="2" border class="registration-time-section custom-width-registration">
<el-descriptions-item label="报名开始时间">{{ activity.registrationStart || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="报名结束时间">{{ activity.registrationEnd || '暂无' }}</el-descriptions-item>
</el-descriptions>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">报名开始时间</label>
<div class="detail-value">{{ activity.registrationStart || '暂无' }}</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">报名结束时间</label>
<div class="detail-value">{{ activity.registrationEnd || '暂无' }}</div>
</div>
</el-col>
</el-row>
<!-- 人数限制区域 -->
<el-divider content-position="left">人数限制</el-divider>
<el-descriptions :column="2" border class="registration-limit-section custom-width-registration">
<el-descriptions-item label="总人数上限" v-if="activity.maxParticipants">{{ activity.maxParticipants }}</el-descriptions-item>
<el-descriptions-item label="团队数量上限" v-if="activity.maxTeams">{{ activity.maxTeams }}</el-descriptions-item>
<el-descriptions-item label="每队最小人数" v-if="activity.teamSizeMin">{{ activity.teamSizeMin }}</el-descriptions-item>
<el-descriptions-item label="每队最大人数" v-if="activity.teamSizeMax">{{ activity.teamSizeMax }}</el-descriptions-item>
</el-descriptions>
<el-row :gutter="20" v-if="activity.maxParticipants">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">总人数上限</label>
<div class="detail-value">{{ activity.maxParticipants }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20" v-if="activity.maxTeams || activity.teamSizeMin || activity.teamSizeMax">
<el-col :span="8" v-if="activity.maxTeams">
<div class="detail-item">
<label class="detail-label">团队数量上限</label>
<div class="detail-value">{{ activity.maxTeams }}</div>
</div>
</el-col>
<el-col :span="8" v-if="activity.teamSizeMin">
<div class="detail-item">
<label class="detail-label">每队最小人数</label>
<div class="detail-value">{{ activity.teamSizeMin }}</div>
</div>
</el-col>
<el-col :span="8" v-if="activity.teamSizeMax">
<div class="detail-item">
<label class="detail-label">每队最大人数</label>
<div class="detail-value">{{ activity.teamSizeMax }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">报名状态</label>
<div class="detail-value">
<dict-tag :options="stgl_registration_status" :value="activity.registrationStatus" />
</div>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label class="detail-label">报名人数</label>
<div class="detail-value">{{ activity.registrationCount || 0 }}</div>
</div>
</el-col>
</el-row>
<!-- 如果没有任何限制信息,显示提示 -->
<div v-if="!activity.maxParticipants && !activity.maxTeams && !activity.teamSizeMin && !activity.teamSizeMax" class="no-limit-data">
暂无人数限制设置
</div>
</el-tab-pane>
<!-- 报名查看标签页 -->
@@ -506,36 +335,149 @@ onMounted(() => {
padding: 16px;
}
/* 详情项样式 */
.detail-item {
/* el-descriptions组件相关样式 */
.basic-info-section,
.organizer-info-section,
.registration-info-section,
.registration-time-section,
.registration-limit-section {
margin-bottom: 20px;
}
.detail-label {
display: inline-block;
width: 120px;
color: #606266;
font-weight: 500;
font-size: 14px;
margin-bottom: 8px;
text-align: right;
padding-right: 12px;
/* 自定义宽度的报名信息表格 */
.custom-width-registration :deep(.el-descriptions__table) {
table-layout: fixed !important;
width: 100% !important;
}
.custom-width-registration :deep(.el-descriptions__cell) {
vertical-align: top;
}
.detail-value {
display: inline-block;
vertical-align: top;
width: calc(100% - 120px);
color: #303133;
font-size: 14px;
line-height: 1.5;
/* 强制设置标签和内容的宽度 */
.custom-width-registration :deep(.el-descriptions__label) {
width: 20% !important;
min-width: 20% !important;
}
.custom-width-registration :deep(.el-descriptions__content) {
width: 30% !important;
min-width: 30% !important;
}
/* 自定义宽度的主办方信息表格 */
.custom-width-organizer :deep(.el-descriptions__table) {
table-layout: fixed !important;
width: 100% !important;
}
.custom-width-organizer :deep(.el-descriptions__cell) {
vertical-align: top;
}
/* 强制设置主办方信息标签和内容的宽度 */
.custom-width-organizer :deep(.el-descriptions__label) {
box-sizing: border-box !important;
word-wrap: break-word !important;
}
.custom-width-organizer :deep(.el-descriptions__content) {
box-sizing: border-box !important;
word-wrap: break-word !important;
}
/* 主办方信息区域 - 具体宽度控制 */
.custom-width-organizer :deep(.el-descriptions__table tr td:nth-child(1)) {
width: 13% !important;
}
.custom-width-organizer :deep(.el-descriptions__table tr td:nth-child(2)) {
width: 35% !important;
}
.custom-width-organizer :deep(.el-descriptions__table tr td:nth-child(3)) {
width: 10% !important;
}
.custom-width-organizer :deep(.el-descriptions__table tr td:nth-child(4)) {
width: 15% !important;
}
.custom-width-organizer :deep(.el-descriptions__table tr td:nth-child(5)) {
width: 10% !important;
}
.custom-width-organizer :deep(.el-descriptions__table tr td:nth-child(6)) {
width: 17% !important;
}
/* 承办方信息区域 - 具体宽度控制 */
.custom-width-co-organizer :deep(.el-descriptions__table tr td:nth-child(1)) {
width: 12% !important;
}
.custom-width-co-organizer :deep(.el-descriptions__table tr td:nth-child(2)) {
width: 36% !important;
}
.custom-width-co-organizer :deep(.el-descriptions__table tr td:nth-child(3)) {
width: 10% !important;
}
.custom-width-co-organizer :deep(.el-descriptions__table tr td:nth-child(4)) {
width: 16% !important;
}
.custom-width-co-organizer :deep(.el-descriptions__table tr td:nth-child(5)) {
width: 10% !important;
}
.custom-width-co-organizer :deep(.el-descriptions__table tr td:nth-child(6)) {
width: 16% !important;
}
/* 自定义宽度的指导老师信息表格 */
.custom-width-instructor :deep(.el-descriptions__table) {
table-layout: fixed !important;
width: 100% !important;
}
.custom-width-instructor :deep(.el-descriptions__cell) {
vertical-align: top;
}
/* 强制设置指导老师信息标签和内容的宽度 */
.custom-width-instructor :deep(.el-descriptions__label) {
box-sizing: border-box !important;
word-wrap: break-word !important;
}
.custom-width-instructor :deep(.el-descriptions__content) {
box-sizing: border-box !important;
word-wrap: break-word !important;
}
/* 指导老师信息区域 - 具体宽度控制 (8+15+8+17+8+15+8+17) */
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(1)) {
width: 8% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(2)) {
width: 15% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(3)) {
width: 8% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(4)) {
width: 17% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(5)) {
width: 8% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(6)) {
width: 15% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(7)) {
width: 8% !important;
}
.custom-width-instructor :deep(.el-descriptions__table tr td:nth-child(8)) {
width: 17% !important;
}
/* 海报显示区域样式 */
.view-poster {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: center;
padding: 20px 0;
}
@@ -548,6 +490,7 @@ onMounted(() => {
border: 1px dashed #dcdfe6;
border-radius: 4px;
background-color: #fafafa;
margin: 20px auto;
}
.no-poster-text {
@@ -556,6 +499,7 @@ onMounted(() => {
text-align: center;
}
/* 描述文本区域样式 */
.view-description {
padding: 12px;
border: 1px solid #e4e7ed;
@@ -567,26 +511,12 @@ onMounted(() => {
white-space: pre-wrap;
word-break: break-word;
background-color: #fafafa;
margin-bottom: 20px;
}
/* 章节分隔样式 */
.section-divider {
margin: 24px 0 16px 0;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
}
.section-title {
margin: 0 0 16px 0;
color: #303133;
font-size: 16px;
font-weight: 600;
border-bottom: 2px solid #409eff;
padding-bottom: 8px;
}
/* 承办方项目样式 */
.co-organizer-item {
/* 承办方和指导老师项目样式 */
.co-organizer-item,
.instructor-item {
margin-bottom: 16px;
padding: 16px;
background: #f8f9fa;
@@ -594,24 +524,14 @@ onMounted(() => {
border: 1px solid #e9ecef;
}
.co-organizer-item:last-child {
margin-bottom: 0;
}
/* 指导老师项目样式 */
.instructor-item {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #ebeef5;
}
.co-organizer-item:last-child,
.instructor-item:last-child {
margin-bottom: 0;
}
.no-data {
/* 无数据提示样式 */
.no-data,
.no-limit-data {
color: #909399;
font-size: 13px;
font-style: italic;
@@ -620,10 +540,9 @@ onMounted(() => {
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
margin-bottom: 20px;
}
/* 附件显示区域样式 */
.attachment-list {
display: flex;
@@ -647,4 +566,78 @@ onMounted(() => {
.attachment-icon {
font-size: 14px;
}
/* el-divider样式优化 */
:deep(.el-divider--horizontal) {
margin: 24px 0 16px 0;
}
:deep(.el-divider__text) {
background-color: #fff;
color: #303133;
font-size: 16px;
font-weight: 600;
padding: 0 20px;
}
/* el-descriptions样式优化 */
:deep(.el-descriptions) {
.el-descriptions__header {
margin-bottom: 20px;
}
.el-descriptions__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.el-descriptions__body {
.el-descriptions__table {
.el-descriptions__cell {
padding: 12px 16px;
}
.el-descriptions__label {
font-weight: 500;
color: #606266;
background-color: #fafafa;
}
.el-descriptions__content {
color: #303133;
}
}
}
}
/* 标签样式优化 */
:deep(.el-tag) {
border-radius: 4px;
font-size: 12px;
height: 24px;
line-height: 22px;
}
/* 响应式布局优化 */
@media (max-width: 768px) {
.activity-detail-view {
padding: 12px;
}
:deep(.el-descriptions) {
.el-descriptions__body {
.el-descriptions__table {
.el-descriptions__cell {
padding: 8px 12px;
}
}
}
}
.co-organizer-item,
.instructor-item {
padding: 12px;
}
}
</style>
@@ -90,7 +90,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { getActivityRatings } from '@/api/stgl/activity';
import { ActivityRatingVO } from '@/api/stgl/activity/types';
@@ -146,19 +146,23 @@ const formatDateTimeCompact = (dateStr?: string): string => {
// 加载评分数据
const loadRatings = async () => {
if (!props.activityId) return;
if (!props.activityId) {
ratings.value = [];
return;
}
loading.value = true;
try {
console.log('开始加载活动评分数据,活动ID:', props.activityId);
const res = await getActivityRatings(props.activityId);
ratings.value = res.data || [];
console.log('加载的评分数据:', ratings.value);
console.log('成功加载评分数据,活动ID:', props.activityId, '评分数量:', ratings.value.length);
} catch (error: any) {
// 如果是404错误,说明后端暂未实现该功能,静默处理
if (error.response?.status === 404) {
console.log('活动评分功能暂未实现');
console.log('活动评分功能暂未实现,活动ID:', props.activityId);
} else {
console.error('加载评分数据失败:', error);
console.error('加载评分数据失败,活动ID:', props.activityId, '错误:', error);
}
ratings.value = [];
} finally {
@@ -166,9 +170,15 @@ const loadRatings = async () => {
}
};
onMounted(() => {
loadRatings();
});
// 监听 activityId 变化,自动重新加载评分数据
watch(() => props.activityId, (newId, oldId) => {
console.log('活动ID发生变化:', oldId, '->', newId);
if (newId && newId !== oldId) {
loadRatings();
}
}, { immediate: true }); // immediate: true 确保组件挂载时也会执行一次
// 移除 onMounted,因为 watch 的 immediate: true 已经覆盖了这个功能
</script>
<style scoped>
@@ -321,7 +321,7 @@
</template>
<script setup lang="ts">
import { getCurrentInstance, onMounted, reactive, ref, toRefs } from 'vue';
import { getCurrentInstance, onMounted, reactive, ref, toRefs, watch } from 'vue';
import {
listActivityRegister,
getActivityRegister,
@@ -507,6 +507,20 @@ const data = reactive<PageData<ExtendedActivityRegisterForm, StglActivityRegistr
const { queryParams, form, rules } = toRefs(data);
// 监听activityId变化,同步更新查询参数
watch(() => props.activityId, (newActivityId) => {
if (newActivityId) {
console.log('RegisteredMembersTab: activityId发生变化,从', queryParams.value.activityId, '更新为', newActivityId);
queryParams.value.activityId = newActivityId;
// 重置分页到第一页
queryParams.value.pageNum = 1;
// 如果组件已经挂载,则立即重新查询
if (memberList.value.length > 0 || loading.value === false) {
getList();
}
}
}, { immediate: true });
// 上传相关
const uploadRef = ref<UploadInstance>();
const upload = reactive<{
@@ -529,8 +543,11 @@ const upload = reactive<{
const getList = async () => {
loading.value = true;
try {
// 构建查询参数,查询已审核通过的报名记录
const queryData = { ...queryParams.value };
// 构建查询参数,确保使用最新的activityId
const queryData = {
...queryParams.value,
activityId: props.activityId // 强制使用最新的props值
};
// 如果状态字段为空或未选择,则查询已通过(approvalStatus=1)的报名记录
if (!queryData.approvalStatus) {
@@ -538,6 +555,8 @@ const getList = async () => {
}
console.log('RegisteredMembersTab: getList 开始查询,参数:', queryData);
console.log('RegisteredMembersTab: props.activityId:', props.activityId);
console.log('RegisteredMembersTab: queryParams.activityId:', queryParams.value.activityId);
const res = await listActivityRegister(queryData);
console.log('RegisteredMembersTab: 查询结果:', res);
@@ -560,7 +579,10 @@ const cancel = () => {
/** 表单重置 */
const reset = () => {
form.value = { ...initFormData, activityId: props.activityId };
form.value = {
...initFormData,
activityId: props.activityId // 确保使用最新的activityId
};
memberFormRef.value?.resetFields();
};
@@ -635,7 +657,7 @@ const submitForm = () => {
const { selectedStudent, ...submitData } = form.value;
// 确保包含必要字段
submitData.activityId = props.activityId;
submitData.activityId = props.activityId; // 使用最新的activityId
submitData.approvalStatus = 1; // 新增的报名默认为已通过状态
try {
+2 -2
View File
@@ -61,8 +61,8 @@
<dict-tag :options="stgl_activity_type" :value="scope.row.activityType" />
</template>
</el-table-column>
<el-table-column label="主办方" align="center" prop="organizerName" width="120" show-overflow-tooltip />
<el-table-column label="关联社团/队伍" align="center" width="120" show-overflow-tooltip>
<el-table-column label="主办方" align="center" prop="organizerName" min-width="150" show-overflow-tooltip />
<el-table-column label="关联社团/队伍" align="center" min-width="150" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.relatedClubName">{{ scope.row.relatedClubName }}</span>
<span v-else class="text-gray-400">未关联</span>
+24 -14
View File
@@ -84,13 +84,13 @@
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['stgl:activity:add']">新增</el-button>
</el-col>
<el-col :span="1.5" v-if="!single && selectedActivity && ['0', '7', '6'].includes(selectedActivity.activityStatus) && hasActivityOperationPermission(selectedActivity)">
<el-col :span="1.5" v-if="!single && selectedActivity && ['0', '7', '6'].includes(getOriginalActivityStatus(selectedActivity)) && hasActivityOperationPermission(selectedActivity)">
<el-button icon="Edit" @click="handleUpdate()" v-hasPermi="['stgl:activity:edit']">修改</el-button>
</el-col>
<el-col :span="1.5" v-if="!single && selectedActivity && selectedActivity.activityStatus === '0' && hasActivityOperationPermission(selectedActivity)">
<el-col :span="1.5" v-if="!single && selectedActivity && getOriginalActivityStatus(selectedActivity) === '0' && hasActivityOperationPermission(selectedActivity)">
<el-button type="success" icon="Upload" @click="handleSubmit()" v-hasPermi="['stgl:activity:edit']">提交</el-button>
</el-col>
<el-col :span="1.5" v-if="!single && selectedActivity && selectedActivity.activityStatus === '2' && hasActivityOperationPermission(selectedActivity)">
<el-col :span="1.5" v-if="!single && selectedActivity && getOriginalActivityStatus(selectedActivity) === '2' && hasActivityOperationPermission(selectedActivity)">
<el-button type="warning" icon="Check" @click="handleApprove()" v-hasPermi="['stgl:activity:approve']">审批</el-button>
</el-col>
<el-col :span="1.5" v-if="!single && selectedActivity && ['3', '4'].includes(selectedActivity.activityStatus) && hasActivityOperationPermission(selectedActivity)">
@@ -102,10 +102,10 @@
<el-col :span="1.5">
<el-button
icon="Delete"
:disabled="multiple || !['0', '7'].includes(selectedActivity?.activityStatus || '')"
:disabled="multiple || !['0', '7'].includes(getOriginalActivityStatus(selectedActivity) || '')"
@click="handleDelete()"
v-hasPermi="['stgl:activity:remove']"
:title="selectedActivity && !['0', '7'].includes(selectedActivity.activityStatus) ? '只有草稿和已驳回状态的活动可以删除' : ''"
:title="selectedActivity && !['0', '7'].includes(getOriginalActivityStatus(selectedActivity)) ? '只有草稿和已驳回状态的活动可以删除' : ''"
>删除</el-button
>
</el-col>
@@ -209,7 +209,7 @@
v-hasPermi="['stgl:activity:query']"
></el-button>
</el-tooltip>
<el-tooltip content="修改" placement="top" v-if="['0', '7', '6'].includes(scope.row.activityStatus) && hasActivityOperationPermission(scope.row)">
<el-tooltip content="修改" placement="top" v-if="['0', '7', '6'].includes(getOriginalActivityStatus(scope.row)) && hasActivityOperationPermission(scope.row)">
<el-button
link
type="primary"
@@ -220,7 +220,7 @@
v-hasPermi="['stgl:activity:edit']"
></el-button>
</el-tooltip>
<el-tooltip content="提交" placement="top" v-if="scope.row.activityStatus === '0' && hasActivityOperationPermission(scope.row)">
<el-tooltip content="提交" placement="top" v-if="getOriginalActivityStatus(scope.row) === '0' && hasActivityOperationPermission(scope.row)">
<el-button
link
type="success"
@@ -231,7 +231,7 @@
v-hasPermi="['stgl:activity:edit']"
></el-button>
</el-tooltip>
<el-tooltip content="审批" placement="top" v-if="scope.row.activityStatus === '2' && hasActivityOperationPermission(scope.row)">
<el-tooltip content="审批" placement="top" v-if="getOriginalActivityStatus(scope.row) === '2' && hasActivityOperationPermission(scope.row)">
<el-button
link
type="warning"
@@ -286,7 +286,7 @@
v-hasPermi="['stgl:activity:query']"
></el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top" v-if="['0', '7'].includes(scope.row.activityStatus) && hasActivityOperationPermission(scope.row)">
<el-tooltip content="删除" placement="top" v-if="['0', '7'].includes(getOriginalActivityStatus(scope.row)) && hasActivityOperationPermission(scope.row)">
<el-button
link
type="danger"
@@ -1014,8 +1014,9 @@ const handleUpdate = async (row?: StglActivityVO) => {
const res = await getStglActivity(_id);
// 检查活动状态是否允许修改(只有草稿0、已驳回7、已取消6状态允许修改)
if (!['0', '7', '6'].includes(res.data.activityStatus)) {
const statusDict = stgl_activity_status.value.find((dict: any) => dict.value === res.data.activityStatus);
const originalStatus = getOriginalActivityStatus(res.data);
if (!['0', '7', '6'].includes(originalStatus)) {
const statusDict = stgl_activity_status.value.find((dict: any) => dict.value === originalStatus);
const statusName = statusDict ? statusDict.label : '当前状态';
proxy?.$modal.msgWarning(`活动状态为"${statusName}",不允许修改`);
return;
@@ -1276,8 +1277,9 @@ const handleDelete = async (row?: StglActivityVO) => {
// 检查删除权限 - 只有草稿(状态0)和已驳回(状态7)的活动可以删除
if (row) {
if (!['0', '7'].includes(row.activityStatus)) {
const statusDict = stgl_activity_status.value.find((dict: any) => dict.value === row.activityStatus);
const originalStatus = getOriginalActivityStatus(row);
if (!['0', '7'].includes(originalStatus)) {
const statusDict = stgl_activity_status.value.find((dict: any) => dict.value === originalStatus);
const statusName = statusDict ? statusDict.label : '当前状态';
proxy?.$modal.msgWarning(`活动状态为"${statusName}",只有草稿和已驳回状态的活动可以删除`);
return;
@@ -1287,7 +1289,7 @@ const handleDelete = async (row?: StglActivityVO) => {
const selectedItems = activityList.value.filter((item) => ids.value.includes(item.id));
// 检查选中的活动是否都是草稿或已驳回状态
const nonDeletableItems = selectedItems.filter((item) => !['0', '7'].includes(item.activityStatus));
const nonDeletableItems = selectedItems.filter((item) => !['0', '7'].includes(getOriginalActivityStatus(item)));
if (nonDeletableItems.length > 0) {
const names = nonDeletableItems.map((item) => item.activityName).join('、');
proxy?.$modal.msgWarning(`活动"${names}"不是草稿或已驳回状态,只有草稿和已驳回状态的活动可以删除`);
@@ -1505,6 +1507,7 @@ const handleRegistrationTypeChange = (value: string) => {
/** 判断是否显示总结按钮 */
const shouldShowSummaryButton = (row: StglActivityVO): boolean => {
// 只有进行中(4)和已结束(5)状态的活动才显示总结按钮
// 注意:这里使用显示状态,因为总结功能应该基于活动的实际进行状态
return row.activityStatus === '4' || row.activityStatus === '5';
};
@@ -1716,6 +1719,13 @@ const hasActivityOperationPermission = (activity: StglActivityVO): boolean => {
return false;
};
/** 获取原始活动状态(用于权限控制) */
const getOriginalActivityStatus = (activity: StglActivityVO | null): string => {
if (!activity) return '';
// 优先使用 originalActivityStatus 字段,如果不存在则使用 activityStatus
return activity.originalActivityStatus || activity.activityStatus || '';
};
onMounted(() => {
initUrlParams();
getList();
@@ -479,10 +479,11 @@ const getList = async () => {
console.log('JoinedMembersTab: getList 开始查询,参数:', queryData);
// 如果状态字段为空或未选择,则查询已加入(status=2)和已退出(status=3)的成员
// 已加入页面:查询已加入(status=2)和已退出(status=4)的成员
// 字典配置:0-待审核,1-审核中,2-已加入,3-已驳回,4-已退出
if (!queryData.status) {
// 使用params传递多状态查询
queryData.params = { ...queryData.params, statusList: ['2', '3'] } as any;
queryData.params = { ...queryData.params, statusList: ['2', '4'] } as any;
delete queryData.status; // 删除单个status字段
}
@@ -283,10 +283,11 @@ const getList = async () => {
console.log('PendingMembersTab: getList 开始查询,参数:', queryData);
// 如果状态字段为空或未选择,则查询待审核(status=0)和已驳回(status=4)的成员
// 待审核页面:查询待审核(status=0)和已驳回(status=3)的成员
// 字典配置:0-待审核,1-审核中,2-已加入,3-已驳回,4-已退出
if (!queryData.status) {
// 使用params传递多状态查询
queryData.params = { ...queryData.params, statusList: ['0', '4'] } as any;
queryData.params = { ...queryData.params, statusList: ['0', '3'] } as any;
delete queryData.status; // 删除单个status字段
}
+11 -11
View File
@@ -45,7 +45,7 @@
<el-table v-loading="loading" border :data="clubList" @row-dblclick="handleView">
<el-table-column label="序号" type="index" align="center" width="60" />
<el-table-column label="社团LOGO" align="center" width="80">
<el-table-column label="社团LOGO" align="center" width="150">
<template #default="scope">
<div v-if="scope.row.logoUrl" class="table-image">
<image-preview :src="scope.row.logoUrl" :width="50" :height="50" />
@@ -56,35 +56,35 @@
</template>
</el-table-column>
<el-table-column label="社团名称" align="center" prop="clubName" min-width="150" show-overflow-tooltip />
<el-table-column label="社团类型" align="center" prop="clubType" width="100">
<el-table-column label="社团类型" align="center" prop="clubType" width="150">
<template #default="scope">
<dict-tag :options="stgl_club_type" :value="scope.row.clubType" />
</template>
</el-table-column>
<el-table-column label="我的角色" align="center" prop="myRole" width="100">
<el-table-column label="我的角色" align="center" prop="myRole" width="140">
<template #default="scope">
<dict-tag :options="stgl_member_role" :value="scope.row.myRole" />
<span class="role-text">{{ scope.row.myRole || '暂无' }}</span>
</template>
</el-table-column>
<el-table-column label="社长姓名" align="center" prop="presidentName" width="100" show-overflow-tooltip />
<el-table-column label="社长姓名" align="center" prop="presidentName" width="140" show-overflow-tooltip />
<el-table-column label="指导老师" align="center" width="120" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.advisorName">{{ scope.row.advisorName }}</span>
<span v-else class="text-gray-400">暂无</span>
</template>
</el-table-column>
<el-table-column label="成员数量" align="center" width="100">
<el-table-column label="成员数量" align="center" width="140">
<template #default="scope">
<span class="member-count">{{ scope.row.memberCount || 0 }}</span>
<span v-if="scope.row.memberLimit" class="member-limit">/ {{ scope.row.memberLimit }}</span>
</template>
</el-table-column>
<el-table-column label="加入时间" align="center" width="180">
<el-table-column label="加入时间" align="center" width="200">
<template #default="scope">
<span>{{ parseTime(scope.row.joinTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="80">
<el-table-column label="状态" align="center" prop="status" width="120">
<template #default="scope">
<dict-tag :options="sys_normal_disable" :value="scope.row.status" />
</template>
@@ -124,7 +124,7 @@
<dict-tag :options="sys_normal_disable" :value="detailForm.status" />
</el-descriptions-item>
<el-descriptions-item label="我的角色">
<dict-tag :options="stgl_member_role" :value="detailForm.myRole" />
<span class="role-text">{{ detailForm.myRole || '暂无' }}</span>
</el-descriptions-item>
<el-descriptions-item label="加入时间">{{ parseTime(detailForm.joinTime, '{y}-{m}-{d} {h}:{i}') }}</el-descriptions-item>
<el-descriptions-item label="成员状态">
@@ -166,8 +166,8 @@ import type { MyClubVO, MyClubQuery } from '@/api/stgl/my/types';
import { UserFilled } from '@element-plus/icons-vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { stgl_club_type, sys_normal_disable, stgl_member_role, stgl_member_status } = toRefs<any>(
proxy?.useDict('stgl_club_type', 'sys_normal_disable', 'stgl_member_role', 'stgl_member_status')
const { stgl_club_type, sys_normal_disable, stgl_member_status } = toRefs<any>(
proxy?.useDict('stgl_club_type', 'sys_normal_disable', 'stgl_member_status')
);
const queryRef = ref();
+26 -34
View File
@@ -16,15 +16,17 @@
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="审核状态" prop="approvalStatus">
<el-select v-model="queryParams.approvalStatus" placeholder="请选择审核状态" clearable style="width: 100%">
<el-option label="全部" value="" />
<el-option label="待审核" value="待审核" />
<el-option label="已通过" value="已通过" />
<el-option label="已拒绝" value="已拒绝" />
</el-select>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入名称"
clearable
style="width: 100%"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
@@ -69,13 +71,7 @@
<span>{{ parseTime(scope.row.registrationTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
<el-table-column label="审核状态" align="center" prop="approvalStatus" width="100">
<template #default="scope">
<el-tag :type="getApprovalStatusTag(scope.row.approvalStatus)" size="small">
{{ scope.row.approvalStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="当前状态" align="center" prop="currentStatus" width="100">
<template #default="scope">
<el-tag :type="getCurrentStatusTag(scope.row.currentStatus)" size="small">
@@ -83,7 +79,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间信息" align="center" width="180">
<el-table-column label="创办时间" align="center" width="180">
<template #default="scope">
<span v-if="scope.row.timeInfo">
{{ parseTime(scope.row.timeInfo, '{y}-{m}-{d} {h}:{i}') }}
@@ -129,11 +125,7 @@
<el-descriptions-item label="我的角色">{{ detailForm.role || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="组织者">{{ detailForm.organizer || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="报名时间">{{ parseTime(detailForm.registrationTime, '{y}-{m}-{d} {h}:{i}') }}</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="getApprovalStatusTag(detailForm.approvalStatus)" size="small">
{{ detailForm.approvalStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="getCurrentStatusTag(detailForm.currentStatus)" size="small">
{{ detailForm.currentStatus }}
@@ -143,7 +135,7 @@
{{ parseTime(detailForm.approvalTime, '{y}-{m}-{d} {h}:{i}') }}
</el-descriptions-item>
<el-descriptions-item label="审核人" v-if="detailForm.approvalBy">{{ detailForm.approvalBy }}</el-descriptions-item>
<el-descriptions-item label="时间信息" v-if="detailForm.timeInfo">
<el-descriptions-item label="创办时间" v-if="detailForm.timeInfo">
{{ parseTime(detailForm.timeInfo, '{y}-{m}-{d} {h}:{i}') }}
</el-descriptions-item>
<el-descriptions-item label="地点" v-if="detailForm.location">{{ detailForm.location }}</el-descriptions-item>
@@ -178,8 +170,8 @@ const queryFormRef = ref<ElFormInstance>();
const queryParams = ref<MyRegistrationQuery>({
pageNum: 1,
pageSize: 10,
registrationType: '',
approvalStatus: ''
name: '',
registrationType: ''
});
/** 查询我的报名记录列表 */
@@ -236,22 +228,22 @@ const getRegistrationTypeTag = (type: string) => {
return tagMap[type as keyof typeof tagMap] || ('info' as const);
};
/** 获取审核状态标签样式 */
const getApprovalStatusTag = (status: string) => {
const tagMap = {
'已通过': 'success' as const,
'已拒绝': 'danger' as const,
'待审核': 'warning' as const
};
return tagMap[status as keyof typeof tagMap] || ('info' as const);
};
/** 获取当前状态标签样式 */
const getCurrentStatusTag = (status: string) => {
const tagMap = {
// 成员状态
'待审核': 'warning' as const,
'审核中': 'info' as const,
'已加入': 'success' as const,
'已驳回': 'danger' as const,
'已退出': 'danger' as const,
// 活动审核状态
'已通过': 'success' as const,
// 兼容旧状态
'正常': 'success' as const,
'已报名': 'primary' as const,
'待审核': 'warning' as const
'已报名': 'primary' as const
};
return tagMap[status as keyof typeof tagMap] || ('info' as const);
};
+3 -3
View File
@@ -107,7 +107,7 @@
<dict-tag :options="stgl_volunteer_role" :value="detailForm.myRole" />
</el-descriptions-item>
<el-descriptions-item label="成员状态">
<dict-tag :options="stgl_volunteer_status" :value="detailForm.memberStatus" />
<dict-tag :options="stgl_member_status" :value="detailForm.memberStatus" />
</el-descriptions-item>
<el-descriptions-item label="加入时间">{{ parseTime(detailForm.joinTime, '{y}-{m}-{d} {h}:{i}') }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detailForm.contactPhone || '暂无' }}</el-descriptions-item>
@@ -143,8 +143,8 @@ import type { MyVolunteerTeamVO, MyVolunteerQuery } from '@/api/stgl/my/types';
import { User } from '@element-plus/icons-vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_normal_disable, stgl_volunteer_role, stgl_volunteer_status } = toRefs<any>(
proxy?.useDict('sys_normal_disable', 'stgl_volunteer_role', 'stgl_volunteer_status')
const { sys_normal_disable, stgl_volunteer_role, stgl_member_status } = toRefs<any>(
proxy?.useDict('sys_normal_disable', 'stgl_volunteer_role', 'stgl_member_status')
);
const teamList = ref<MyVolunteerTeamVO[]>([]);
+1 -1
View File
@@ -50,7 +50,7 @@
</el-row>
</template>
<el-table v-loading="loading" border :data="studentList" @selection-change="handleSelectionChange" @row-dblclick="handleView">
<el-table v-loading="loading" border :data="studentList" row-key="id" @selection-change="handleSelectionChange" @row-dblclick="handleView">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" type="index" width="60" align="center" />
<el-table-column label="学生姓名" align="center" prop="studentName" min-width="40" />
@@ -48,6 +48,7 @@
</span>
</template>
</el-tab-pane>
<!--
<el-tab-pane label="我的申请" name="my">
<template #label>
<span class="tab-label">
@@ -56,6 +57,7 @@
</span>
</template>
</el-tab-pane>
-->
</el-tabs>
<!-- 工具栏 -->
@@ -520,6 +522,8 @@ function resetQuery() {
function handleTabChange(tabName: string) {
activeTab.value = tabName;
queryParams.value.pageNum = 1;
// 注释掉"我的申请"页签相关逻辑
/*
if (tabName === 'my') {
// 切换到"我的申请"页签时,直接打开申请列表对话框
handleMyApplications();
@@ -531,6 +535,9 @@ function handleTabChange(tabName: string) {
// 切换到"志愿者大厅"页签时,刷新队伍列表
getList();
}
*/
// 简化逻辑,只刷新队伍列表
getList();
}
/** 查看详情 */
@@ -91,7 +91,7 @@
<el-button type="primary" :disabled="multiple" @click="handleBatchApplyBed()"> 批量申请床位 </el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" :disabled="multiple" @click="handleBatchCancelBed()"> 批量取消床位 </el-button>
<el-button type="danger" :disabled="multiple" @click="handleBatchCancelBed()"> 批量取消床位申请 </el-button>
</el-col>
<!-- <el-col :span="1.5">
<el-button :disabled="multiple" icon="Delete" @click="handleDelete()"> 删除 </el-button>
@@ -2285,7 +2285,7 @@ const resetQuery = () => {
/** 删除按钮操作 */
const handleDelete = async (row?: StudentVO) => {
const studentIds = row?.id || ids.value;
const [err] = await to(ElMessageBox.confirm('是否确认删除学生编号为"' + row?.name + '"的数据项?', '提示'));
const [err] = await to(ElMessageBox.confirm('是否确认删除学生姓名为"' + row?.name + '"的数据项?', '提示'));
if (!err) {
try {
const res = await service.delete(`/studentMsg/studentMsg/${studentIds}`);
@@ -2342,23 +2342,93 @@ const handleBatchCancelBed = async () => {
return;
}
// 获取选中学生的详细信息
const selectedStudents = studentList.value.filter((student) => ids.value.includes(student.id));
// 分类学生:可以取消和不可以取消的
const canCancelStudents: any[] = [];
const cannotCancelStudents: any[] = [];
selectedStudents.forEach((student) => {
// 检查学生是否已分配宿舍(宿舍楼栋、房间号、床位号任一不为空)
const studentAny = student as any;
const hasAssignedBed = studentAny.dormitoryBuilding || studentAny.dormitoryRoom || studentAny.extField1; // 床位号字段
if (hasAssignedBed) {
cannotCancelStudents.push(student);
} else {
canCancelStudents.push(student);
}
});
// 构建确认消息
let confirmMessage = '';
if (canCancelStudents.length > 0) {
confirmMessage += `<div style="margin-bottom: 15px;">
<strong style="color: #67c23a;">✅ 可以取消床位申请的学生 (${canCancelStudents.length}人):</strong><br/>
${canCancelStudents.map((student) => `• ${student.name} (${student.studentNo})`).join('<br/>')}
</div>`;
}
if (cannotCancelStudents.length > 0) {
confirmMessage += `<div style="margin-bottom: 15px;">
<strong style="color: #f56c6c;">❌ 无法取消床位申请的学生 (${cannotCancelStudents.length}人):</strong><br/>
${cannotCancelStudents.map((student) => `• ${student.name} (${student.studentNo}) - 已分配宿舍`).join('<br/>')}
</div>`;
}
// 如果没有学生可以取消,直接提示
if (canCancelStudents.length === 0) {
ElMessage.warning('选中的学生都已分配宿舍,无法取消床位申请');
return;
}
// 显示确认对话框
const [err] = await to(
ElMessageBox.confirm('请确认是否取消,会将学生已有宿舍清除!!!', '批量取消床位', {
confirmButtonText: '确认取消',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: true
})
ElMessageBox.confirm(
`<div style="text-align: left; max-height: 400px; overflow-y: auto;">
<div style="margin-bottom: 15px; font-size: 14px;">
<strong>批量取消床位申请确认</strong><br/>
系统将只对未分配宿舍的学生执行取消床位申请操作。
</div>
${confirmMessage}
<div style="margin-top: 15px; padding: 10px; background-color: #f0f9ff; border-left: 4px solid #409eff; border-radius: 4px;">
<strong>操作说明:</strong><br/>
• 已分配宿舍的学生将被跳过<br/>
• 仅对未分配宿舍的学生执行取消操作<br/>
• 确认后将无法撤销此操作
</div>
</div>`,
'批量取消床位申请',
{
confirmButtonText: '确认取消',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: true,
customClass: 'batch-cancel-bed-dialog'
}
)
);
if (!err) {
try {
// 只对可以取消的学生执行操作
const allowedStudentIds = canCancelStudents.map((student) => student.id);
const res = await service.post('/studentMsg/studentMsg/batchCancelBed', {
studentIds: ids.value
studentIds: allowedStudentIds
});
if (res && res.code === 200) {
await getList();
ElMessage.success('批量取消床位成功');
// 根据操作结果给出不同的提示
if (cannotCancelStudents.length > 0) {
ElMessage.success(`批量取消床位成功!成功取消 ${canCancelStudents.length} 名学生,跳过 ${cannotCancelStudents.length} 名已分配宿舍的学生`);
} else {
ElMessage.success('批量取消床位成功');
}
} else {
ElMessage.error(res?.msg || '批量取消床位失败');
}
@@ -2652,7 +2722,7 @@ const handleAdd = async () => {
form.majorName = classData.major_name || classData.majorName;
// 确保学历层次为数字类型,避免类型不匹配导致的显示问题
const classType = classData.class_type || classData.classType;
form.academicLevel = classType ? Number(classType) : undefined;
form.academicLevel = classType ? classType : undefined;
form.grade = classData.grade;
form.enrollmentYear = classData.enrollment_year || classData.enrollmentYear;
form.classTeacherId = classData.class_teacher_id || classData.classTeacherId;
@@ -3154,4 +3224,26 @@ onMounted(() => {
:deep(.el-message) {
z-index: 4000 !important;
}
/* 批量取消床位申请确认对话框样式 */
:deep(.batch-cancel-bed-dialog) {
max-width: 600px !important;
}
:deep(.batch-cancel-bed-dialog .el-message-box__content) {
max-height: 500px;
overflow-y: auto;
}
:deep(.batch-cancel-bed-dialog .el-message-box__message) {
line-height: 1.6;
}
:deep(.batch-cancel-bed-dialog .el-message-box__message div) {
margin-bottom: 10px;
}
:deep(.batch-cancel-bed-dialog .el-message-box__message strong) {
font-weight: 600;
}
</style>