feat: 系统优化和功能完善
主要更新: - 调整并发配置为50人(数据库连接池30,Tomcat线程150,异步线程池5/20) - 实现无界阻塞队列(LinkedBlockingQueue)任务处理 - 实现分镜视频保存功能(保存到uploads目录) - 统一管理页面导航栏和右上角样式 - 添加日活用户统计功能 - 优化视频拼接和保存逻辑 - 添加部署文档和快速部署指南 - 更新.gitignore排除敏感配置文件
This commit is contained in:
47
demo/.gitignore
vendored
47
demo/.gitignore
vendored
@@ -31,3 +31,50 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### 敏感配置文件 ###
|
||||
# 开发环境配置(包含敏感信息)
|
||||
src/main/resources/application-dev.properties
|
||||
# 生产环境配置(包含敏感信息)
|
||||
src/main/resources/application-prod.properties
|
||||
# 环境变量文件
|
||||
.env
|
||||
*.env
|
||||
config/.env
|
||||
|
||||
### 上传文件和临时文件 ###
|
||||
uploads/
|
||||
temp/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
### 构建产物 ###
|
||||
target/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
### 数据库相关 ###
|
||||
*.sql.backup
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
### IDE和编辑器 ###
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
### 测试文件 ###
|
||||
test_*.py
|
||||
test_*.java
|
||||
test_*.html
|
||||
test_*.sh
|
||||
test_*.bat
|
||||
*.test.*
|
||||
|
||||
### 备份文件 ###
|
||||
*.backup
|
||||
*.bak
|
||||
*~
|
||||
|
||||
@@ -216,3 +216,6 @@ ngrok http 8080
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/API_CALL_LOGIC_CHECK_REPORT.md
Normal file
1
demo/API_CALL_LOGIC_CHECK_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/API_FIX_SOLUTION.md
Normal file
1
demo/API_FIX_SOLUTION.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/CODE_COMPLETENESS_CHECK_REPORT.md
Normal file
1
demo/CODE_COMPLETENESS_CHECK_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/CODE_LOGIC_FIXES.md
Normal file
1
demo/CODE_LOGIC_FIXES.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/CODE_LOGIC_FIXES_REPORT.md
Normal file
1
demo/CODE_LOGIC_FIXES_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -111,3 +111,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/COMPREHENSIVE_CODE_LOGIC_FIXES.md
Normal file
1
demo/COMPREHENSIVE_CODE_LOGIC_FIXES.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
306
demo/CONFIGURATION_GUIDE.md
Normal file
306
demo/CONFIGURATION_GUIDE.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# 系统配置指南
|
||||
|
||||
本文档列出了系统中所有需要手动配置的重要配置项。
|
||||
|
||||
## 📋 配置项分类
|
||||
|
||||
### 1. 🔐 数据库配置(必须)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 开发环境(直接配置)
|
||||
spring.datasource.url=jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
spring.datasource.username=root
|
||||
spring.datasource.password=你的数据库密码
|
||||
|
||||
# 生产环境(使用环境变量)
|
||||
spring.datasource.url=${DB_URL}
|
||||
spring.datasource.username=${DB_USERNAME}
|
||||
spring.datasource.password=${DB_PASSWORD}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 开发环境:直接修改配置文件中的数据库连接信息
|
||||
- 生产环境:通过环境变量配置,更安全
|
||||
|
||||
---
|
||||
|
||||
### 2. 🔑 JWT配置(必须)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 开发环境
|
||||
jwt.secret=你的JWT密钥(至少128位,建议256位)
|
||||
jwt.expiration=86400000 # 24小时,单位毫秒
|
||||
|
||||
# 生产环境(使用环境变量)
|
||||
jwt.secret=${JWT_SECRET}
|
||||
jwt.expiration=${JWT_EXPIRATION:604800000} # 默认7天
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `jwt.secret`: 用于签名JWT token的密钥,**必须修改为强随机字符串**
|
||||
- `jwt.expiration`: Token过期时间(毫秒)
|
||||
|
||||
---
|
||||
|
||||
### 3. 🤖 AI API配置(必须)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# Comfly API配置(文生视频、图生视频、分镜视频)
|
||||
ai.api.base-url=https://ai.comfly.chat
|
||||
ai.api.key=你的Comfly API密钥
|
||||
|
||||
# Comfly API配置(文生图)
|
||||
ai.image.api.base-url=https://ai.comfly.chat
|
||||
ai.image.api.key=你的Comfly API密钥
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 可通过 **API管理页面** 在线修改(推荐)
|
||||
- 或直接修改配置文件
|
||||
- 修改后需要重启应用
|
||||
|
||||
---
|
||||
|
||||
### 4. 💰 支付宝配置(可选,如需支付功能)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 开发环境(沙箱测试)
|
||||
alipay.app-id=你的支付宝应用ID
|
||||
alipay.private-key=你的应用私钥(RSA2格式)
|
||||
alipay.public-key=支付宝公钥(RSA2格式)
|
||||
alipay.gateway-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
alipay.notify-url=你的回调通知URL
|
||||
alipay.return-url=你的支付返回URL
|
||||
|
||||
# 生产环境(使用环境变量)
|
||||
alipay.app-id=${ALIPAY_APP_ID}
|
||||
alipay.private-key=${ALIPAY_PRIVATE_KEY}
|
||||
alipay.public-key=${ALIPAY_PUBLIC_KEY}
|
||||
alipay.gateway-url=https://openapi.alipay.com/gateway.do
|
||||
alipay.notify-url=${ALIPAY_NOTIFY_URL}
|
||||
alipay.return-url=${ALIPAY_RETURN_URL}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 开发环境使用支付宝沙箱进行测试
|
||||
- 生产环境需要申请正式支付宝应用
|
||||
- 私钥和公钥必须是RSA2格式
|
||||
|
||||
---
|
||||
|
||||
### 5. 📧 腾讯云SES邮件配置(可选,如需邮件功能)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 开发环境
|
||||
tencent.ses.secret-id=你的腾讯云SecretId
|
||||
tencent.ses.secret-key=你的腾讯云SecretKey
|
||||
tencent.ses.region=ap-hongkong
|
||||
tencent.ses.from-email=你的发件邮箱
|
||||
tencent.ses.from-name=AIGC平台
|
||||
tencent.ses.template-id=你的邮件模板ID
|
||||
|
||||
# 生产环境(使用环境变量)
|
||||
tencent.ses.secret-id=${TENCENT_SES_SECRET_ID}
|
||||
tencent.ses.secret-key=${TENCENT_SES_SECRET_KEY}
|
||||
tencent.ses.from-email=${TENCENT_SES_FROM_EMAIL}
|
||||
tencent.ses.template-id=${TENCENT_SES_TEMPLATE_ID}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 需要在腾讯云SES控制台创建邮件模板
|
||||
- 如果`template-id`为0或未配置,将使用开发模式(仅记录日志)
|
||||
|
||||
---
|
||||
|
||||
### 6. 🎬 FFmpeg配置(可选,如需视频拼接功能)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 开发环境
|
||||
app.ffmpeg.path=C:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
|
||||
app.temp.dir=./temp
|
||||
|
||||
# 生产环境(使用环境变量)
|
||||
app.ffmpeg.path=${FFMPEG_PATH:ffmpeg} # 如果在PATH中,直接使用"ffmpeg"
|
||||
app.temp.dir=${TEMP_DIR:./temp}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `app.ffmpeg.path`: FFmpeg可执行文件路径
|
||||
- Windows: 使用完整路径,如 `C:\ffmpeg\bin\ffmpeg.exe`
|
||||
- Linux/Mac: 如果在PATH中,使用 `ffmpeg`;否则使用完整路径,如 `/usr/bin/ffmpeg`
|
||||
- `app.temp.dir`: 临时文件目录(用于视频处理)
|
||||
- 相对路径:基于应用运行目录
|
||||
- 绝对路径:如 `/app/temp` 或 `C:\app\temp`
|
||||
|
||||
---
|
||||
|
||||
### 7. 🖥️ 服务器配置(可选,根据需求调整)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 服务器端口
|
||||
server.port=8080
|
||||
|
||||
# Tomcat线程池配置(支持并发)
|
||||
server.tomcat.threads.max=1000
|
||||
server.tomcat.threads.min-spare=100
|
||||
server.tomcat.max-connections=10000
|
||||
server.tomcat.accept-count=500
|
||||
|
||||
# 请求体大小限制(支持大图片Base64编码)
|
||||
server.tomcat.max-http-post-size=600MB
|
||||
spring.servlet.multipart.max-file-size=500MB
|
||||
spring.servlet.multipart.max-request-size=600MB
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 根据实际并发需求调整线程池配置
|
||||
- 如果不需要处理大文件,可以减小请求体大小限制
|
||||
|
||||
---
|
||||
|
||||
### 8. 📊 数据库连接池配置(可选,根据需求调整)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# HikariCP连接池配置
|
||||
spring.datasource.hikari.maximum-pool-size=200
|
||||
spring.datasource.hikari.minimum-idle=20
|
||||
spring.datasource.hikari.idle-timeout=300000
|
||||
spring.datasource.hikari.max-lifetime=1200000
|
||||
spring.datasource.hikari.connection-timeout=30000
|
||||
spring.datasource.hikari.leak-detection-threshold=60000
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 根据数据库服务器性能和并发需求调整
|
||||
- 默认配置支持50人并发
|
||||
|
||||
---
|
||||
|
||||
### 9. 🗄️ JPA配置(开发/生产环境不同)
|
||||
|
||||
**位置**: `application-dev.properties` / `application-prod.properties`
|
||||
|
||||
```properties
|
||||
# 开发环境
|
||||
spring.jpa.hibernate.ddl-auto=update # 自动更新表结构
|
||||
spring.jpa.show-sql=true # 显示SQL语句
|
||||
|
||||
# 生产环境
|
||||
spring.jpa.hibernate.ddl-auto=validate # 仅验证,不自动修改
|
||||
spring.jpa.show-sql=false # 不显示SQL语句
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- **生产环境必须使用 `validate`**,避免自动修改数据库结构
|
||||
- 开发环境可以使用 `update` 方便开发
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速配置清单
|
||||
|
||||
### 开发环境必须配置:
|
||||
- [ ] 数据库连接信息(URL、用户名、密码)
|
||||
- [ ] JWT密钥(修改为强随机字符串)
|
||||
- [ ] AI API密钥(Comfly API)
|
||||
|
||||
### 开发环境可选配置:
|
||||
- [ ] 支付宝沙箱配置(如需测试支付)
|
||||
- [ ] 腾讯云SES配置(如需测试邮件)
|
||||
- [ ] FFmpeg路径(如需视频拼接)
|
||||
|
||||
### 生产环境必须配置(通过环境变量):
|
||||
- [ ] `DB_URL` - 数据库连接URL
|
||||
- [ ] `DB_USERNAME` - 数据库用户名
|
||||
- [ ] `DB_PASSWORD` - 数据库密码
|
||||
- [ ] `JWT_SECRET` - JWT密钥
|
||||
- [ ] `ALIPAY_APP_ID` - 支付宝应用ID(如需支付)
|
||||
- [ ] `ALIPAY_PRIVATE_KEY` - 支付宝私钥(如需支付)
|
||||
- [ ] `ALIPAY_PUBLIC_KEY` - 支付宝公钥(如需支付)
|
||||
- [ ] `ALIPAY_NOTIFY_URL` - 支付回调URL(如需支付)
|
||||
- [ ] `ALIPAY_RETURN_URL` - 支付返回URL(如需支付)
|
||||
- [ ] `TENCENT_SES_SECRET_ID` - 腾讯云SecretId(如需邮件)
|
||||
- [ ] `TENCENT_SES_SECRET_KEY` - 腾讯云SecretKey(如需邮件)
|
||||
- [ ] `TENCENT_SES_FROM_EMAIL` - 发件邮箱(如需邮件)
|
||||
- [ ] `TENCENT_SES_TEMPLATE_ID` - 邮件模板ID(如需邮件)
|
||||
- [ ] `FFMPEG_PATH` - FFmpeg路径(如需视频拼接)
|
||||
- [ ] `TEMP_DIR` - 临时文件目录(如需视频拼接)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 安全注意事项
|
||||
|
||||
1. **不要将生产环境配置提交到代码仓库**
|
||||
- 使用环境变量或外部配置文件
|
||||
- 将敏感信息添加到 `.gitignore`
|
||||
|
||||
2. **JWT密钥必须足够强**
|
||||
- 至少128位,建议256位
|
||||
- 使用随机字符串生成器生成
|
||||
|
||||
3. **数据库密码**
|
||||
- 使用强密码
|
||||
- 生产环境必须通过环境变量配置
|
||||
|
||||
4. **API密钥**
|
||||
- 定期更换
|
||||
- 不要泄露给他人
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置优先级
|
||||
|
||||
1. **环境变量** > 配置文件
|
||||
2. **application-{profile}.properties** > **application.properties**
|
||||
3. **外部配置文件** > **classpath配置文件**
|
||||
|
||||
---
|
||||
|
||||
## 🔄 配置更新方式
|
||||
|
||||
### 方式1:直接修改配置文件(开发环境)
|
||||
- 修改 `application-dev.properties`
|
||||
- 重启应用
|
||||
|
||||
### 方式2:通过管理页面(推荐)
|
||||
- **API密钥**:通过"API管理"页面在线修改
|
||||
- **会员价格**:通过"系统设置"页面在线修改
|
||||
|
||||
### 方式3:环境变量(生产环境)
|
||||
- 设置环境变量
|
||||
- 重启应用
|
||||
|
||||
---
|
||||
|
||||
## 📞 配置问题排查
|
||||
|
||||
1. **配置不生效**
|
||||
- 检查配置文件路径是否正确
|
||||
- 检查环境变量是否正确设置
|
||||
- 检查是否需要重启应用
|
||||
|
||||
2. **数据库连接失败**
|
||||
- 检查数据库服务是否启动
|
||||
- 检查连接URL、用户名、密码是否正确
|
||||
- 检查防火墙设置
|
||||
|
||||
3. **API调用失败**
|
||||
- 检查API密钥是否正确
|
||||
- 检查网络连接
|
||||
- 查看日志文件获取详细错误信息
|
||||
|
||||
1
demo/CONFIG_FIX_REPORT.md
Normal file
1
demo/CONFIG_FIX_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/DEEP_CODE_ANALYSIS_REPORT.md
Normal file
1
demo/DEEP_CODE_ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
672
demo/DEPLOYMENT_CHECKLIST.md
Normal file
672
demo/DEPLOYMENT_CHECKLIST.md
Normal file
@@ -0,0 +1,672 @@
|
||||
# 🚀 项目上线部署准备清单
|
||||
|
||||
本文档提供了项目上线前的完整准备清单和部署步骤。
|
||||
|
||||
## 📋 一、服务器环境准备
|
||||
|
||||
### 1.1 服务器要求
|
||||
|
||||
**最低配置:**
|
||||
- CPU: 4核
|
||||
- 内存: 8GB
|
||||
- 磁盘: 100GB SSD
|
||||
- 带宽: 10Mbps
|
||||
|
||||
**推荐配置(支持50人并发):**
|
||||
- CPU: 4核
|
||||
- 内存: 8GB
|
||||
- 磁盘: 100GB+ SSD
|
||||
- 带宽: 20Mbps
|
||||
|
||||
### 1.2 操作系统
|
||||
|
||||
- **Linux**: Ubuntu 20.04+ / CentOS 7+ / Debian 11+
|
||||
- **Windows Server**: Windows Server 2019+
|
||||
|
||||
### 1.3 必需软件安装
|
||||
|
||||
```bash
|
||||
# Java 21 (必需)
|
||||
sudo apt update
|
||||
sudo apt install openjdk-21-jdk
|
||||
|
||||
# 验证安装
|
||||
java -version
|
||||
|
||||
# MySQL 8.0+ (必需)
|
||||
sudo apt install mysql-server
|
||||
sudo systemctl start mysql
|
||||
sudo systemctl enable mysql
|
||||
|
||||
# FFmpeg (视频拼接功能需要)
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo yum install epel-release
|
||||
sudo yum install ffmpeg
|
||||
|
||||
# 验证安装
|
||||
ffmpeg -version
|
||||
|
||||
# Nginx (反向代理,推荐)
|
||||
sudo apt install nginx
|
||||
|
||||
# Maven (构建项目)
|
||||
sudo apt install maven
|
||||
```
|
||||
|
||||
### 1.4 目录结构准备
|
||||
|
||||
```bash
|
||||
# 创建应用目录
|
||||
sudo mkdir -p /app/aigc-platform
|
||||
sudo mkdir -p /app/aigc-platform/logs
|
||||
sudo mkdir -p /app/aigc-platform/uploads
|
||||
sudo mkdir -p /app/aigc-platform/temp
|
||||
sudo mkdir -p /app/aigc-platform/config
|
||||
|
||||
# 设置权限
|
||||
sudo chown -R $USER:$USER /app/aigc-platform
|
||||
chmod -R 755 /app/aigc-platform
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 二、数据库准备
|
||||
|
||||
### 2.1 创建数据库
|
||||
|
||||
```sql
|
||||
-- 登录MySQL
|
||||
mysql -u root -p
|
||||
|
||||
-- 创建数据库
|
||||
CREATE DATABASE aigc_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 创建用户(推荐)
|
||||
CREATE USER 'aigc_user'@'localhost' IDENTIFIED BY '强密码';
|
||||
GRANT ALL PRIVILEGES ON aigc_platform.* TO 'aigc_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 退出
|
||||
EXIT;
|
||||
```
|
||||
|
||||
### 2.2 数据库配置优化
|
||||
|
||||
编辑 `/etc/mysql/mysql.conf.d/mysqld.cnf`:
|
||||
|
||||
```ini
|
||||
[mysqld]
|
||||
# 字符集
|
||||
character-set-server=utf8mb4
|
||||
collation-server=utf8mb4_unicode_ci
|
||||
|
||||
# 连接数
|
||||
max_connections=500
|
||||
max_user_connections=400
|
||||
|
||||
# 缓冲池(根据内存调整,建议为内存的50-70%)
|
||||
# 8GB内存服务器建议设置为2-4G
|
||||
innodb_buffer_pool_size=2G
|
||||
|
||||
# 日志
|
||||
slow_query_log=1
|
||||
slow_query_log_file=/var/log/mysql/slow-query.log
|
||||
long_query_time=2
|
||||
```
|
||||
|
||||
重启MySQL:
|
||||
```bash
|
||||
sudo systemctl restart mysql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 三、环境变量配置
|
||||
|
||||
### 3.1 创建环境变量文件
|
||||
|
||||
创建 `/app/aigc-platform/config/.env`:
|
||||
|
||||
```bash
|
||||
# 数据库配置
|
||||
export DB_URL="jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
|
||||
export DB_USERNAME="aigc_user"
|
||||
export DB_PASSWORD="你的数据库密码"
|
||||
|
||||
# JWT配置
|
||||
export JWT_SECRET="生成一个至少128位的随机字符串"
|
||||
export JWT_EXPIRATION="604800000"
|
||||
|
||||
# AI API配置(可通过管理页面修改,但建议先配置)
|
||||
export AI_API_BASE_URL="https://ai.comfly.chat"
|
||||
export AI_API_KEY="你的Comfly API密钥"
|
||||
export AI_IMAGE_API_BASE_URL="https://ai.comfly.chat"
|
||||
export AI_IMAGE_API_KEY="你的Comfly API密钥(文生图)"
|
||||
|
||||
# 支付宝配置(如需支付功能)
|
||||
export ALIPAY_APP_ID="你的支付宝应用ID"
|
||||
export ALIPAY_PRIVATE_KEY="你的应用私钥(RSA2格式,完整内容)"
|
||||
export ALIPAY_PUBLIC_KEY="支付宝公钥(RSA2格式,完整内容)"
|
||||
export ALIPAY_NOTIFY_URL="https://yourdomain.com/api/payments/alipay/notify"
|
||||
export ALIPAY_RETURN_URL="https://yourdomain.com/api/payments/alipay/return"
|
||||
|
||||
# 腾讯云SES配置(如需邮件功能)
|
||||
export TENCENT_SES_SECRET_ID="你的SecretId"
|
||||
export TENCENT_SES_SECRET_KEY="你的SecretKey"
|
||||
export TENCENT_SES_FROM_EMAIL="你的发件邮箱"
|
||||
export TENCENT_SES_TEMPLATE_ID="你的邮件模板ID"
|
||||
|
||||
# 文件路径配置
|
||||
export FFMPEG_PATH="/usr/bin/ffmpeg"
|
||||
export TEMP_DIR="/app/aigc-platform/temp"
|
||||
export UPLOAD_PATH="/app/aigc-platform/uploads"
|
||||
export LOG_FILE_PATH="/app/aigc-platform/logs/application.log"
|
||||
```
|
||||
|
||||
### 3.2 生成JWT密钥
|
||||
|
||||
```bash
|
||||
# 方法1:使用OpenSSL
|
||||
openssl rand -base64 64
|
||||
|
||||
# 方法2:使用Python
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
|
||||
# 方法3:在线生成
|
||||
# 访问 https://www.random.org/strings/
|
||||
```
|
||||
|
||||
### 3.3 加载环境变量
|
||||
|
||||
```bash
|
||||
# 编辑 ~/.bashrc 或 ~/.profile
|
||||
echo "source /app/aigc-platform/config/.env" >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 或创建 systemd service 时直接加载
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 四、项目构建与部署
|
||||
|
||||
### 4.1 构建项目
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /path/to/AIGC/demo
|
||||
|
||||
# 清理并打包(跳过测试)
|
||||
./mvnw clean package -DskipTests
|
||||
|
||||
# 或使用Maven
|
||||
mvn clean package -DskipTests
|
||||
|
||||
# 打包后的JAR文件位置
|
||||
# target/demo-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
### 4.2 上传文件到服务器
|
||||
|
||||
```bash
|
||||
# 使用SCP上传
|
||||
scp target/demo-0.0.1-SNAPSHOT.jar user@server:/app/aigc-platform/
|
||||
|
||||
# 或使用FTP/SFTP工具
|
||||
```
|
||||
|
||||
### 4.3 前端构建
|
||||
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd frontend
|
||||
|
||||
# 安装依赖(如果还没有)
|
||||
npm install
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 构建后的文件在 dist/ 目录
|
||||
# 需要配置Nginx指向 dist/ 目录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 五、应用配置
|
||||
|
||||
### 5.1 创建生产环境配置文件
|
||||
|
||||
在服务器上创建 `/app/aigc-platform/config/application-prod.properties`:
|
||||
|
||||
```properties
|
||||
# 激活生产环境配置
|
||||
spring.profiles.active=prod
|
||||
|
||||
# 数据库配置(使用环境变量)
|
||||
spring.datasource.url=${DB_URL}
|
||||
spring.datasource.username=${DB_USERNAME}
|
||||
spring.datasource.password=${DB_PASSWORD}
|
||||
|
||||
# JWT配置
|
||||
jwt.secret=${JWT_SECRET}
|
||||
jwt.expiration=${JWT_EXPIRATION:604800000}
|
||||
|
||||
# 支付宝配置
|
||||
alipay.app-id=${ALIPAY_APP_ID}
|
||||
alipay.private-key=${ALIPAY_PRIVATE_KEY}
|
||||
alipay.public-key=${ALIPAY_PUBLIC_KEY}
|
||||
alipay.gateway-url=https://openapi.alipay.com/gateway.do
|
||||
alipay.notify-url=${ALIPAY_NOTIFY_URL}
|
||||
alipay.return-url=${ALIPAY_RETURN_URL}
|
||||
|
||||
# 腾讯云SES配置
|
||||
tencent.ses.secret-id=${TENCENT_SES_SECRET_ID}
|
||||
tencent.ses.secret-key=${TENCENT_SES_SECRET_KEY}
|
||||
tencent.ses.from-email=${TENCENT_SES_FROM_EMAIL}
|
||||
tencent.ses.template-id=${TENCENT_SES_TEMPLATE_ID}
|
||||
|
||||
# 文件路径
|
||||
app.ffmpeg.path=${FFMPEG_PATH:ffmpeg}
|
||||
app.temp.dir=${TEMP_DIR:./temp}
|
||||
app.upload.path=${UPLOAD_PATH:./uploads}
|
||||
|
||||
# 日志
|
||||
logging.file.name=${LOG_FILE_PATH:./logs/application.log}
|
||||
```
|
||||
|
||||
### 5.2 创建Systemd服务(Linux)
|
||||
|
||||
创建 `/etc/systemd/system/aigc-platform.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AIGC Platform Application
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your-user
|
||||
WorkingDirectory=/app/aigc-platform
|
||||
EnvironmentFile=/app/aigc-platform/config/.env
|
||||
ExecStart=/usr/bin/java -jar \
|
||||
-Xms1g \
|
||||
-Xmx4g \
|
||||
-XX:+UseG1GC \
|
||||
-XX:MaxGCPauseMillis=200 \
|
||||
-Dspring.profiles.active=prod \
|
||||
-Dspring.config.location=file:/app/aigc-platform/config/application-prod.properties \
|
||||
/app/aigc-platform/demo-0.0.1-SNAPSHOT.jar
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=aigc-platform
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable aigc-platform
|
||||
sudo systemctl start aigc-platform
|
||||
sudo systemctl status aigc-platform
|
||||
```
|
||||
|
||||
查看日志:
|
||||
```bash
|
||||
sudo journalctl -u aigc-platform -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 六、Nginx反向代理配置
|
||||
|
||||
### 6.1 创建Nginx配置
|
||||
|
||||
创建 `/etc/nginx/sites-available/aigc-platform`:
|
||||
|
||||
```nginx
|
||||
upstream aigc_backend {
|
||||
server localhost:8080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
# 重定向到HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
# SSL证书配置
|
||||
ssl_certificate /path/to/your/cert.pem;
|
||||
ssl_certificate_key /path/to/your/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# 前端静态文件
|
||||
root /path/to/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# 前端路由
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端API代理
|
||||
location /api/ {
|
||||
proxy_pass http://aigc_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_cache_bypass $http_upgrade;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# 文件上传代理(支持大文件)
|
||||
location /uploads/ {
|
||||
proxy_pass http://aigc_backend;
|
||||
proxy_request_buffering off;
|
||||
client_max_body_size 500M;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# 日志
|
||||
access_log /var/log/nginx/aigc-platform-access.log;
|
||||
error_log /var/log/nginx/aigc-platform-error.log;
|
||||
}
|
||||
```
|
||||
|
||||
启用配置:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/aigc-platform /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 6.2 SSL证书(Let's Encrypt)
|
||||
|
||||
```bash
|
||||
# 安装Certbot
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
|
||||
# 获取证书
|
||||
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
|
||||
|
||||
# 自动续期
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 七、安全配置
|
||||
|
||||
### 7.1 防火墙配置
|
||||
|
||||
```bash
|
||||
# UFW (Ubuntu)
|
||||
sudo ufw allow 22/tcp
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
|
||||
# Firewalld (CentOS)
|
||||
sudo firewall-cmd --permanent --add-service=ssh
|
||||
sudo firewall-cmd --permanent --add-service=http
|
||||
sudo firewall-cmd --permanent --add-service=https
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 7.2 数据库安全
|
||||
|
||||
```sql
|
||||
-- 删除匿名用户
|
||||
DELETE FROM mysql.user WHERE User='';
|
||||
|
||||
-- 禁止root远程登录(如果不需要)
|
||||
DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');
|
||||
|
||||
-- 刷新权限
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 7.3 文件权限
|
||||
|
||||
```bash
|
||||
# 配置文件权限
|
||||
chmod 600 /app/aigc-platform/config/.env
|
||||
chmod 600 /app/aigc-platform/config/application-prod.properties
|
||||
|
||||
# 日志目录权限
|
||||
chmod 755 /app/aigc-platform/logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 八、监控与日志
|
||||
|
||||
### 8.1 日志管理
|
||||
|
||||
```bash
|
||||
# 配置日志轮转
|
||||
sudo nano /etc/logrotate.d/aigc-platform
|
||||
|
||||
# 内容:
|
||||
/app/aigc-platform/logs/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0644 your-user your-user
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 监控脚本
|
||||
|
||||
创建 `/app/aigc-platform/scripts/health-check.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
HEALTH_URL="http://localhost:8080/api/health"
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)
|
||||
|
||||
if [ $RESPONSE -ne 200 ]; then
|
||||
echo "Health check failed: $RESPONSE"
|
||||
systemctl restart aigc-platform
|
||||
fi
|
||||
```
|
||||
|
||||
添加到crontab:
|
||||
```bash
|
||||
# 每5分钟检查一次
|
||||
*/5 * * * * /app/aigc-platform/scripts/health-check.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 九、上线前检查清单
|
||||
|
||||
### 9.1 配置检查
|
||||
|
||||
- [ ] 数据库连接配置正确
|
||||
- [ ] JWT密钥已生成并配置
|
||||
- [ ] AI API密钥已配置
|
||||
- [ ] 支付宝配置(如需要)
|
||||
- [ ] 腾讯云SES配置(如需要)
|
||||
- [ ] FFmpeg路径正确
|
||||
- [ ] 文件上传目录权限正确
|
||||
- [ ] 日志目录权限正确
|
||||
|
||||
### 9.2 功能测试
|
||||
|
||||
- [ ] 用户注册/登录
|
||||
- [ ] 文生视频功能
|
||||
- [ ] 图生视频功能
|
||||
- [ ] 分镜视频功能
|
||||
- [ ] 支付功能(如需要)
|
||||
- [ ] 邮件发送(如需要)
|
||||
- [ ] 文件上传/下载
|
||||
- [ ] 视频拼接功能
|
||||
|
||||
### 9.3 性能测试
|
||||
|
||||
- [ ] 并发测试(建议使用JMeter)
|
||||
- [ ] 数据库连接池测试
|
||||
- [ ] 内存使用监控
|
||||
- [ ] CPU使用监控
|
||||
- [ ] 磁盘空间监控
|
||||
|
||||
### 9.4 安全检查
|
||||
|
||||
- [ ] 防火墙已配置
|
||||
- [ ] SSL证书已安装
|
||||
- [ ] 敏感信息使用环境变量
|
||||
- [ ] 数据库用户权限最小化
|
||||
- [ ] 文件权限已设置
|
||||
|
||||
### 9.5 备份策略
|
||||
|
||||
- [ ] 数据库自动备份脚本
|
||||
- [ ] 文件上传目录备份
|
||||
- [ ] 配置文件备份
|
||||
- [ ] 备份恢复测试
|
||||
|
||||
---
|
||||
|
||||
## 🔄 十、部署流程
|
||||
|
||||
### 10.1 首次部署
|
||||
|
||||
```bash
|
||||
# 1. 准备服务器环境
|
||||
# 2. 安装必需软件
|
||||
# 3. 配置数据库
|
||||
# 4. 配置环境变量
|
||||
# 5. 构建项目
|
||||
# 6. 上传文件
|
||||
# 7. 配置Nginx
|
||||
# 8. 启动服务
|
||||
# 9. 测试功能
|
||||
# 10. 配置监控
|
||||
```
|
||||
|
||||
### 10.2 更新部署
|
||||
|
||||
```bash
|
||||
# 1. 备份当前版本
|
||||
cp demo-0.0.1-SNAPSHOT.jar demo-0.0.1-SNAPSHOT.jar.backup
|
||||
|
||||
# 2. 停止服务
|
||||
sudo systemctl stop aigc-platform
|
||||
|
||||
# 3. 上传新版本
|
||||
scp target/demo-0.0.1-SNAPSHOT.jar user@server:/app/aigc-platform/
|
||||
|
||||
# 4. 启动服务
|
||||
sudo systemctl start aigc-platform
|
||||
|
||||
# 5. 检查状态
|
||||
sudo systemctl status aigc-platform
|
||||
sudo journalctl -u aigc-platform -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 十一、常见问题排查
|
||||
|
||||
### 11.1 应用无法启动
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
sudo journalctl -u aigc-platform -n 100
|
||||
|
||||
# 检查Java版本
|
||||
java -version
|
||||
|
||||
# 检查端口占用
|
||||
sudo netstat -tlnp | grep 8080
|
||||
|
||||
# 检查环境变量
|
||||
env | grep DB_
|
||||
```
|
||||
|
||||
### 11.2 数据库连接失败
|
||||
|
||||
```bash
|
||||
# 测试数据库连接
|
||||
mysql -h localhost -u aigc_user -p aigc_platform
|
||||
|
||||
# 检查MySQL服务
|
||||
sudo systemctl status mysql
|
||||
|
||||
# 检查防火墙
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### 11.3 文件上传失败
|
||||
|
||||
```bash
|
||||
# 检查目录权限
|
||||
ls -la /app/aigc-platform/uploads
|
||||
|
||||
# 检查磁盘空间
|
||||
df -h
|
||||
|
||||
# 检查Nginx配置
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 十二、技术支持
|
||||
|
||||
如遇到问题,请检查:
|
||||
1. 应用日志:`/app/aigc-platform/logs/application.log`
|
||||
2. 系统日志:`sudo journalctl -u aigc-platform`
|
||||
3. Nginx日志:`/var/log/nginx/aigc-platform-error.log`
|
||||
4. MySQL日志:`/var/log/mysql/error.log`
|
||||
|
||||
---
|
||||
|
||||
## 📝 十三、维护计划
|
||||
|
||||
### 日常维护
|
||||
|
||||
- **每日**: 检查日志、监控系统资源
|
||||
- **每周**: 检查备份、清理临时文件
|
||||
- **每月**: 更新依赖、安全补丁
|
||||
|
||||
### 定期任务
|
||||
|
||||
- **数据库备份**: 每天凌晨2点
|
||||
- **日志清理**: 每天凌晨4点
|
||||
- **任务清理**: 每天凌晨4点(已配置定时任务)
|
||||
|
||||
---
|
||||
|
||||
**祝部署顺利!** 🎉
|
||||
|
||||
@@ -277,3 +277,6 @@ if (result.success) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/FIFTH_ROUND_ULTIMATE_CHECK.md
Normal file
1
demo/FIFTH_ROUND_ULTIMATE_CHECK.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/FINAL_CODE_CHECK_REPORT.md
Normal file
1
demo/FINAL_CODE_CHECK_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/FINAL_CODE_LOGIC_AUDIT_REPORT.md
Normal file
1
demo/FINAL_CODE_LOGIC_AUDIT_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/FINAL_CODE_LOGIC_FIXES_SUMMARY.md
Normal file
1
demo/FINAL_CODE_LOGIC_FIXES_SUMMARY.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/FINAL_LOGIC_ERROR_FIXES.md
Normal file
1
demo/FINAL_LOGIC_ERROR_FIXES.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/FOURTH_ROUND_FINAL_CHECK.md
Normal file
1
demo/FOURTH_ROUND_FINAL_CHECK.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
209
demo/GIT_UPLOAD_GUIDE.md
Normal file
209
demo/GIT_UPLOAD_GUIDE.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 📤 Git 上传到云端指南
|
||||
|
||||
## 一、准备工作
|
||||
|
||||
### 1.1 检查敏感信息
|
||||
|
||||
已更新 `.gitignore` 文件,以下文件不会被提交:
|
||||
- `application-dev.properties` - 开发环境配置(包含数据库密码、API密钥等)
|
||||
- `application-prod.properties` - 生产环境配置
|
||||
- `.env` - 环境变量文件
|
||||
- `uploads/` - 上传文件目录
|
||||
- `temp/` - 临时文件目录
|
||||
- `logs/` - 日志文件
|
||||
- `target/` - 构建产物
|
||||
- `frontend/node_modules/` - 前端依赖
|
||||
|
||||
### 1.2 创建配置文件模板
|
||||
|
||||
如果需要保留配置结构,可以创建模板文件:
|
||||
- `application-dev.properties.example`
|
||||
- `application-prod.properties.example`
|
||||
- `.env.example`
|
||||
|
||||
---
|
||||
|
||||
## 二、上传步骤
|
||||
|
||||
### 2.1 添加所有更改
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd C:\Users\UI\Desktop\AIGC\demo
|
||||
|
||||
# 添加所有更改的文件
|
||||
git add .
|
||||
|
||||
# 或者只添加特定文件
|
||||
git add src/
|
||||
git add frontend/
|
||||
git add pom.xml
|
||||
git add .gitignore
|
||||
git add DEPLOYMENT_CHECKLIST.md
|
||||
git add QUICK_DEPLOY.md
|
||||
git add CONFIGURATION_GUIDE.md
|
||||
```
|
||||
|
||||
### 2.2 提交更改
|
||||
|
||||
```bash
|
||||
# 提交更改
|
||||
git commit -m "feat: 优化系统配置,支持50人并发
|
||||
|
||||
- 调整数据库连接池配置(30个连接)
|
||||
- 调整Tomcat线程池配置(150个线程)
|
||||
- 调整异步任务执行器配置(5核心线程,20最大线程)
|
||||
- 调整任务队列消费者线程数(5个线程)
|
||||
- 添加无界阻塞队列支持
|
||||
- 实现分镜视频保存功能
|
||||
- 统一管理页面导航栏和右上角样式
|
||||
- 添加日活用户统计功能
|
||||
- 添加部署文档和快速部署指南"
|
||||
```
|
||||
|
||||
### 2.3 推送到远程仓库
|
||||
|
||||
```bash
|
||||
# 推送到远程仓库
|
||||
git push origin main
|
||||
|
||||
# 或者如果主分支是 master
|
||||
git push origin master
|
||||
|
||||
# 如果是第一次推送,需要设置上游分支
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、常见问题
|
||||
|
||||
### 3.1 如果远程仓库已有新提交
|
||||
|
||||
```bash
|
||||
# 先拉取远程更改
|
||||
git pull origin main
|
||||
|
||||
# 如果有冲突,解决冲突后
|
||||
git add .
|
||||
git commit -m "解决合并冲突"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 3.2 如果文件太大无法推送
|
||||
|
||||
```bash
|
||||
# 检查大文件
|
||||
git ls-files | xargs du -h | sort -rh | head -20
|
||||
|
||||
# 如果确实需要大文件,考虑使用 Git LFS
|
||||
git lfs install
|
||||
git lfs track "*.jar"
|
||||
git lfs track "*.zip"
|
||||
```
|
||||
|
||||
### 3.3 如果推送被拒绝
|
||||
|
||||
```bash
|
||||
# 强制推送(谨慎使用)
|
||||
git push -f origin main
|
||||
|
||||
# 或者先拉取再推送
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、验证上传
|
||||
|
||||
### 4.1 检查远程仓库
|
||||
|
||||
访问远程仓库地址,确认文件已上传:
|
||||
- `ssh://git@49.234.3.145:222/blandarebiter/AIGC.git`
|
||||
|
||||
### 4.2 克隆验证
|
||||
|
||||
```bash
|
||||
# 在另一个目录克隆仓库验证
|
||||
cd /tmp
|
||||
git clone ssh://git@49.234.3.145:222/blandarebiter/AIGC.git test-clone
|
||||
cd test-clone
|
||||
ls -la
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、后续维护
|
||||
|
||||
### 5.1 定期提交
|
||||
|
||||
```bash
|
||||
# 每天工作结束后提交
|
||||
git add .
|
||||
git commit -m "更新: 描述本次更改"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 5.2 创建标签(版本发布)
|
||||
|
||||
```bash
|
||||
# 创建版本标签
|
||||
git tag -a v1.0.0 -m "版本 1.0.0 - 初始发布"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### 5.3 创建分支(功能开发)
|
||||
|
||||
```bash
|
||||
# 创建功能分支
|
||||
git checkout -b feature/new-feature
|
||||
# 开发完成后合并
|
||||
git checkout main
|
||||
git merge feature/new-feature
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
1. **不要提交敏感信息**
|
||||
- 数据库密码
|
||||
- API密钥
|
||||
- JWT密钥
|
||||
- 支付宝私钥
|
||||
- 腾讯云密钥
|
||||
|
||||
2. **提交前检查**
|
||||
```bash
|
||||
git status
|
||||
git diff
|
||||
```
|
||||
|
||||
3. **使用有意义的提交信息**
|
||||
- 清晰描述本次更改
|
||||
- 遵循提交信息规范
|
||||
|
||||
4. **定期备份**
|
||||
- 本地备份重要文件
|
||||
- 定期推送到远程仓库
|
||||
|
||||
---
|
||||
|
||||
## 📝 快速命令
|
||||
|
||||
```bash
|
||||
# 一键提交并推送
|
||||
git add . && git commit -m "更新代码" && git push origin main
|
||||
|
||||
# 查看提交历史
|
||||
git log --oneline -10
|
||||
|
||||
# 查看远程仓库
|
||||
git remote -v
|
||||
|
||||
# 查看当前分支
|
||||
git branch
|
||||
```
|
||||
|
||||
@@ -230,3 +230,6 @@ A: IJPay 是对原生 SDK 的封装,提供了更简洁的 API。底层实现
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -297,3 +297,6 @@ grep "img2vid_abc123def456" logs/application.log
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/LOGIC_ERROR_ANALYSIS.md
Normal file
1
demo/LOGIC_ERROR_ANALYSIS.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
demo/LOGIN_CONFIGURATION_GUIDE.md
Normal file
8
demo/LOGIN_CONFIGURATION_GUIDE.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -298,3 +298,6 @@ public TaskQueue addTextToVideoTask(String username, String taskId) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/POLLING_QUERY_IMPLEMENTATION.md
Normal file
1
demo/POLLING_QUERY_IMPLEMENTATION.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/POLLING_SCHEDULE_SUMMARY.md
Normal file
1
demo/POLLING_SCHEDULE_SUMMARY.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -39,6 +39,9 @@ public class PasswordChecker {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
126
demo/QUICK_DEPLOY.md
Normal file
126
demo/QUICK_DEPLOY.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 🚀 快速部署指南
|
||||
|
||||
本文档提供最精简的上线步骤,适合有经验的运维人员。
|
||||
|
||||
## 一、服务器准备(5分钟)
|
||||
|
||||
```bash
|
||||
# 1. 安装Java 21
|
||||
sudo apt update && sudo apt install -y openjdk-21-jdk
|
||||
|
||||
# 2. 安装MySQL 8.0
|
||||
sudo apt install -y mysql-server
|
||||
sudo mysql_secure_installation
|
||||
|
||||
# 3. 安装FFmpeg
|
||||
sudo apt install -y ffmpeg
|
||||
|
||||
# 4. 安装Nginx
|
||||
sudo apt install -y nginx
|
||||
```
|
||||
|
||||
## 二、数据库设置(2分钟)
|
||||
|
||||
```sql
|
||||
CREATE DATABASE aigc_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'aigc_user'@'localhost' IDENTIFIED BY '强密码';
|
||||
GRANT ALL PRIVILEGES ON aigc_platform.* TO 'aigc_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
## 三、环境变量(3分钟)
|
||||
|
||||
创建 `/app/aigc-platform/config/.env`:
|
||||
|
||||
```bash
|
||||
export DB_URL="jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=Asia/Shanghai"
|
||||
export DB_USERNAME="aigc_user"
|
||||
export DB_PASSWORD="数据库密码"
|
||||
export JWT_SECRET="$(openssl rand -base64 64)"
|
||||
export AI_API_KEY="你的API密钥"
|
||||
export AI_IMAGE_API_KEY="你的API密钥"
|
||||
export FFMPEG_PATH="/usr/bin/ffmpeg"
|
||||
export TEMP_DIR="/app/aigc-platform/temp"
|
||||
export UPLOAD_PATH="/app/aigc-platform/uploads"
|
||||
```
|
||||
|
||||
## 四、部署应用(5分钟)
|
||||
|
||||
```bash
|
||||
# 1. 创建目录
|
||||
sudo mkdir -p /app/aigc-platform/{logs,uploads,temp,config}
|
||||
sudo chown -R $USER:$USER /app/aigc-platform
|
||||
|
||||
# 2. 上传JAR文件
|
||||
scp target/demo-0.0.1-SNAPSHOT.jar user@server:/app/aigc-platform/
|
||||
|
||||
# 3. 创建systemd服务
|
||||
sudo nano /etc/systemd/system/aigc-platform.service
|
||||
```
|
||||
|
||||
systemd服务内容:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AIGC Platform
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your-user
|
||||
WorkingDirectory=/app/aigc-platform
|
||||
EnvironmentFile=/app/aigc-platform/config/.env
|
||||
ExecStart=/usr/bin/java -jar -Xms1g -Xmx4g \
|
||||
-Dspring.profiles.active=prod \
|
||||
/app/aigc-platform/demo-0.0.1-SNAPSHOT.jar
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
# 4. 启动服务
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable aigc-platform
|
||||
sudo systemctl start aigc-platform
|
||||
```
|
||||
|
||||
## 五、Nginx配置(3分钟)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /path/to/frontend/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 六、SSL证书(2分钟)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
## ✅ 检查清单
|
||||
|
||||
- [ ] 应用启动成功:`sudo systemctl status aigc-platform`
|
||||
- [ ] 数据库连接正常:查看日志
|
||||
- [ ] API可访问:`curl http://localhost:8080/api/health`
|
||||
- [ ] 前端可访问:浏览器打开域名
|
||||
- [ ] SSL证书有效:`https://yourdomain.com`
|
||||
|
||||
**总耗时:约20分钟**
|
||||
|
||||
详细配置请参考 `DEPLOYMENT_CHECKLIST.md`
|
||||
|
||||
1
demo/REAL_API_INTEGRATION_REPORT.md
Normal file
1
demo/REAL_API_INTEGRATION_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/RICH_STYLE_IMPLEMENTATION.md
Normal file
1
demo/RICH_STYLE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
demo/SES_SENDER_VERIFICATION_GUIDE.md
Normal file
8
demo/SES_SENDER_VERIFICATION_GUIDE.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/SINGLE_PAGE_EXPERIENCE.md
Normal file
1
demo/SINGLE_PAGE_EXPERIENCE.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/SIXTH_ROUND_COMPREHENSIVE_CHECK.md
Normal file
1
demo/SIXTH_ROUND_COMPREHENSIVE_CHECK.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/STORYBOARD_VIDEO_CODE_REVIEW.md
Normal file
1
demo/STORYBOARD_VIDEO_CODE_REVIEW.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/SYSTEM_SETTINGS_CLEANUP_GUIDE.md
Normal file
1
demo/SYSTEM_SETTINGS_CLEANUP_GUIDE.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/TASK_STATUS_CHECK_REPORT.md
Normal file
1
demo/TASK_STATUS_CHECK_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/TASK_TO_WORK_FLOW.md
Normal file
1
demo/TASK_TO_WORK_FLOW.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
demo/TENCENT_CONFIG_STATUS.md
Normal file
8
demo/TENCENT_CONFIG_STATUS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
8
demo/TENCENT_SES_MAIL_INTEGRATION.md
Normal file
8
demo/TENCENT_SES_MAIL_INTEGRATION.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
8
demo/TENCENT_SES_MAIL_MULTIPLE_RECIPIENTS.md
Normal file
8
demo/TENCENT_SES_MAIL_MULTIPLE_RECIPIENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -289,3 +289,6 @@ ResourceNotFound.TemplateNotFound
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -307,3 +307,6 @@ const startPolling = (taskId) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
8
demo/TEXT_TO_VIDEO_IMPLEMENTATION_SUMMARY.md
Normal file
8
demo/TEXT_TO_VIDEO_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/TEXT_TO_VIDEO_STATUS_REPORT.md
Normal file
1
demo/TEXT_TO_VIDEO_STATUS_REPORT.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
demo/THIRD_ROUND_LOGIC_CHECK.md
Normal file
1
demo/THIRD_ROUND_LOGIC_CHECK.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
98
demo/TRANSACTION_AUDIT.md
Normal file
98
demo/TRANSACTION_AUDIT.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 事务配置审计报告
|
||||
|
||||
## 检查时间
|
||||
2025-11-06
|
||||
|
||||
## 检查范围
|
||||
所有服务类中的 `@Transactional` 配置
|
||||
|
||||
## 检查结果
|
||||
|
||||
### ✅ 正确配置的服务
|
||||
|
||||
#### 1. StoryboardVideoService
|
||||
- **类级别**: 无 `@Transactional` ✅
|
||||
- **createTask**: `@Transactional(propagation = Propagation.REQUIRES_NEW)` ✅
|
||||
- 快速保存任务,事务立即提交
|
||||
- **processTaskAsync**: `@Async` + `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 在事务外执行,调用外部API不会占用连接
|
||||
- **其他方法**: 使用独立的事务方法,快速完成 ✅
|
||||
|
||||
#### 2. TaskQueueService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **processPendingTasks**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 覆盖类级别配置,在事务外执行
|
||||
- **processTask**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 在事务外调用外部API
|
||||
- **checkTaskStatuses**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 在事务外轮询外部API状态
|
||||
- **checkTaskStatusInternal**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 在事务外调用外部API
|
||||
- **其他方法**: 快速数据库操作,使用独立事务 ✅
|
||||
|
||||
#### 3. TaskStatusPollingService
|
||||
- **类级别**: 无 `@Transactional` ✅
|
||||
- **pollTaskStatus**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 在事务外轮询外部API
|
||||
- **其他方法**: 快速数据库操作,使用独立事务 ✅
|
||||
|
||||
### ⚠️ 需要关注的服务
|
||||
|
||||
#### 4. TextToVideoService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **createTask**: 继承类级别事务
|
||||
- ✅ 只做快速数据库操作(保存任务、添加到队列)
|
||||
- ✅ 没有调用外部API
|
||||
- ✅ 没有长时间运行的操作
|
||||
- **结论**: 虽然使用类级别事务,但方法快速完成,应该没问题
|
||||
|
||||
#### 5. ImageToVideoService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **createTask**: 继承类级别事务
|
||||
- ✅ 只做快速数据库操作(保存任务、添加到队列)
|
||||
- ✅ 没有调用外部API
|
||||
- ✅ 没有长时间运行的操作
|
||||
- **结论**: 虽然使用类级别事务,但方法快速完成,应该没问题
|
||||
|
||||
#### 6. UserWorkService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **所有方法**: 都是快速数据库操作
|
||||
- ✅ 没有调用外部API
|
||||
- ✅ 没有长时间运行的操作
|
||||
- **结论**: 应该没问题
|
||||
|
||||
### 📊 总结
|
||||
|
||||
#### 长时间运行的方法(已正确配置)
|
||||
1. ✅ `StoryboardVideoService.processTaskAsync` - `NOT_SUPPORTED` + `@Async`
|
||||
2. ✅ `TaskQueueService.processPendingTasks` - `NOT_SUPPORTED`
|
||||
3. ✅ `TaskQueueService.processTask` - `NOT_SUPPORTED`
|
||||
4. ✅ `TaskQueueService.checkTaskStatuses` - `NOT_SUPPORTED`
|
||||
5. ✅ `TaskQueueService.checkTaskStatusInternal` - `NOT_SUPPORTED`
|
||||
6. ✅ `TaskStatusPollingService.pollTaskStatus` - `NOT_SUPPORTED`
|
||||
|
||||
#### 快速事务方法(已正确配置)
|
||||
- ✅ 所有 `readOnly = true` 的方法
|
||||
- ✅ 所有独立的 `@Transactional` 方法(快速数据库操作)
|
||||
- ✅ `StoryboardVideoService.createTask` - `REQUIRES_NEW`(快速提交)
|
||||
|
||||
#### 潜在问题
|
||||
- ⚠️ `TextToVideoService` 和 `ImageToVideoService` 有类级别 `@Transactional`
|
||||
- 但它们的 `createTask` 方法都是快速操作,应该没问题
|
||||
- 如果未来需要在这些方法中调用外部API,需要添加 `NOT_SUPPORTED`
|
||||
|
||||
## 建议
|
||||
|
||||
### 当前状态
|
||||
✅ **所有事务配置都是正确的,应该能正常关闭**
|
||||
|
||||
### 最佳实践建议
|
||||
1. ✅ 长时间运行的方法都使用了 `NOT_SUPPORTED`
|
||||
2. ✅ 异步方法都正确配置了 `@Async`
|
||||
3. ✅ 快速事务方法都快速完成
|
||||
4. ⚠️ 考虑移除 `TextToVideoService` 和 `ImageToVideoService` 的类级别 `@Transactional`,改为方法级别配置,以提高灵活性
|
||||
|
||||
## 结论
|
||||
**✅ 所有事务都能正常关闭,没有发现连接泄漏风险**
|
||||
|
||||
|
||||
94
demo/TRANSACTION_USAGE_REPORT.md
Normal file
94
demo/TRANSACTION_USAGE_REPORT.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 事务使用情况完整报告
|
||||
|
||||
## 检查时间
|
||||
2025-11-06
|
||||
|
||||
## 检查范围
|
||||
所有服务类中的 `@Transactional` 使用情况
|
||||
|
||||
## 检查结果
|
||||
|
||||
### ✅ 已修复的服务
|
||||
|
||||
#### 1. StoryboardVideoService
|
||||
- **类级别**: 无 `@Transactional` ✅
|
||||
- **createTask**: `@Transactional(propagation = Propagation.REQUIRES_NEW)` ✅
|
||||
- **processTaskAsync**: `@Async` + `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- 内部调用使用 `REQUIRES_NEW` 的私有方法:
|
||||
- `loadTaskInfoInNewTransaction` - `REQUIRES_NEW` + `readOnly = true` ✅
|
||||
- `updateTaskStatusInNewTransaction` - `REQUIRES_NEW` ✅
|
||||
- `saveStoryboardImageResultInNewTransaction` - `REQUIRES_NEW` ✅
|
||||
- `updateTaskStatusToFailedInNewTransaction` - `REQUIRES_NEW` ✅
|
||||
- **其他方法**: 快速数据库操作,使用独立事务 ✅
|
||||
|
||||
### ⚠️ 需要注意的服务
|
||||
|
||||
#### 2. TextToVideoService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **createTask**: 继承类级别事务
|
||||
- ✅ 只做快速数据库操作,无外部API调用
|
||||
- **processTaskWithRealAPI**: `@Async` + 继承类级别事务 ⚠️
|
||||
- ⚠️ 直接使用 `taskRepository.save(task)`,会继承类级别事务
|
||||
- ⚠️ 在异步方法中调用外部API,可能导致连接泄漏
|
||||
- **状态**: 此方法**未被调用**(任务通过 TaskQueueService 处理),不会导致实际泄漏
|
||||
- **建议**: 如果未来需要使用,应添加 `NOT_SUPPORTED` 并使用 `REQUIRES_NEW` 的私有方法
|
||||
|
||||
#### 3. ImageToVideoService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **createTask**: 继承类级别事务
|
||||
- ✅ 只做快速数据库操作,无外部API调用
|
||||
- **processTaskWithRealAPI**: `@Async` + 继承类级别事务 ⚠️
|
||||
- ⚠️ 直接使用 `taskRepository.save(task)`,会继承类级别事务
|
||||
- ⚠️ 在异步方法中调用外部API,可能导致连接泄漏
|
||||
- **状态**: 此方法**未被调用**(任务通过 TaskQueueService 处理),不会导致实际泄漏
|
||||
- **建议**: 如果未来需要使用,应添加 `NOT_SUPPORTED` 并使用 `REQUIRES_NEW` 的私有方法
|
||||
|
||||
### ✅ 正确配置的服务
|
||||
|
||||
#### 4. TaskQueueService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **processPendingTasks**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- **processTask**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- **checkTaskStatuses**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- **checkTaskStatusInternal**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- **其他方法**: 快速数据库操作,使用独立事务 ✅
|
||||
|
||||
#### 5. TaskStatusPollingService
|
||||
- **类级别**: 无 `@Transactional` ✅
|
||||
- **pollTaskStatus**: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` ✅
|
||||
- **其他方法**: 快速数据库操作,使用独立事务 ✅
|
||||
|
||||
#### 6. UserWorkService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **所有方法**: 快速数据库操作,无外部API调用 ✅
|
||||
|
||||
#### 7. UserService
|
||||
- **类级别**: 无 `@Transactional` ✅
|
||||
- **所有方法**: 快速数据库操作,使用独立事务 ✅
|
||||
|
||||
#### 8. PaymentService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **所有方法**: 快速数据库操作,无外部API调用 ✅
|
||||
|
||||
#### 9. OrderService
|
||||
- **类级别**: `@Transactional` ⚠️
|
||||
- **所有方法**: 快速数据库操作,无外部API调用 ✅
|
||||
|
||||
## 总结
|
||||
|
||||
### 当前状态
|
||||
✅ **所有实际使用的异步方法都已正确配置,不会导致连接泄漏**
|
||||
|
||||
### 潜在问题(但未实际使用)
|
||||
⚠️ `TextToVideoService.processTaskWithRealAPI` 和 `ImageToVideoService.processTaskWithRealAPI` 有潜在问题,但**未被调用**,不会导致实际泄漏
|
||||
|
||||
### 建议
|
||||
1. ✅ 当前配置正确,无需修改
|
||||
2. ⚠️ 如果未来需要使用 `processTaskWithRealAPI` 方法,应按照 `StoryboardVideoService` 的模式修复:
|
||||
- 在异步方法上添加 `@Transactional(propagation = Propagation.NOT_SUPPORTED)`
|
||||
- 创建使用 `REQUIRES_NEW` 的私有方法进行数据库操作
|
||||
|
||||
## 结论
|
||||
**✅ 所有实际使用的事务配置都是正确的,不会导致连接泄漏**
|
||||
|
||||
|
||||
1
demo/UNIREST_MIGRATION.md
Normal file
1
demo/UNIREST_MIGRATION.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -177,3 +177,6 @@ const updateWork = async (workId, updateData) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
demo/VIDEO_GENERATION_DIAGNOSTIC.md
Normal file
1
demo/VIDEO_GENERATION_DIAGNOSTIC.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
demo/add_bio_column.sql
Normal file
6
demo/add_bio_column.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 添加个人简介字段到用户表
|
||||
-- 执行此脚本以更新数据库schema
|
||||
|
||||
ALTER TABLE users ADD COLUMN bio TEXT COMMENT '个人简介';
|
||||
|
||||
|
||||
63
demo/cancel_image_to_video_task.sql
Normal file
63
demo/cancel_image_to_video_task.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- 取消图生视频任务: img2vid_1957126e9dbd46e2
|
||||
-- 1. 更新任务队列状态为 CANCELLED
|
||||
UPDATE task_queue
|
||||
SET status = 'CANCELLED',
|
||||
error_message = '用户取消了任务',
|
||||
updated_at = NOW()
|
||||
WHERE task_id = 'img2vid_1957126e9dbd46e2';
|
||||
|
||||
-- 2. 更新图生视频任务状态为 CANCELLED
|
||||
UPDATE image_to_video_tasks
|
||||
SET status = 'CANCELLED',
|
||||
error_message = '用户取消了任务',
|
||||
updated_at = NOW()
|
||||
WHERE task_id = 'img2vid_1957126e9dbd46e2';
|
||||
|
||||
-- 3. 更新用户积分(减少冻结积分)
|
||||
-- 注意:只减少 frozen_points,不增加 points(总积分不变)
|
||||
UPDATE users
|
||||
SET frozen_points = frozen_points - (
|
||||
SELECT COALESCE(freeze_points, 0)
|
||||
FROM points_freeze_records
|
||||
WHERE task_id = 'img2vid_1957126e9dbd46e2'
|
||||
AND status = 'FROZEN'
|
||||
LIMIT 1
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE username = (
|
||||
SELECT username
|
||||
FROM task_queue
|
||||
WHERE task_id = 'img2vid_1957126e9dbd46e2'
|
||||
LIMIT 1
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM points_freeze_records
|
||||
WHERE task_id = 'img2vid_1957126e9dbd46e2'
|
||||
AND status = 'FROZEN'
|
||||
);
|
||||
|
||||
-- 4. 返还冻结的积分(将冻结记录状态改为 RETURNED)
|
||||
UPDATE points_freeze_records
|
||||
SET status = 'RETURNED',
|
||||
updated_at = NOW()
|
||||
WHERE task_id = 'img2vid_1957126e9dbd46e2'
|
||||
AND status = 'FROZEN';
|
||||
|
||||
-- 5. 查询任务信息(用于确认)
|
||||
SELECT
|
||||
tq.task_id,
|
||||
tq.status as queue_status,
|
||||
tq.username,
|
||||
tq.error_message,
|
||||
itvt.status as task_status,
|
||||
pfr.freeze_points,
|
||||
pfr.status as freeze_status
|
||||
FROM task_queue tq
|
||||
LEFT JOIN image_to_video_tasks itvt ON tq.task_id = itvt.task_id
|
||||
LEFT JOIN points_freeze_records pfr ON tq.task_id = pfr.task_id
|
||||
WHERE tq.task_id = 'img2vid_1957126e9dbd46e2';
|
||||
|
||||
-- 查询结果
|
||||
SELECT '任务已取消' as result;
|
||||
|
||||
63
demo/cancel_task.sql
Normal file
63
demo/cancel_task.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- 取消任务: txt2vid_512378411b084a07
|
||||
-- 1. 更新任务队列状态为 CANCELLED
|
||||
UPDATE task_queue
|
||||
SET status = 'CANCELLED',
|
||||
error_message = '用户取消了任务',
|
||||
updated_at = NOW()
|
||||
WHERE task_id = 'txt2vid_512378411b084a07';
|
||||
|
||||
-- 2. 更新文生视频任务状态为 CANCELLED
|
||||
UPDATE text_to_video_tasks
|
||||
SET status = 'CANCELLED',
|
||||
error_message = '用户取消了任务',
|
||||
updated_at = NOW()
|
||||
WHERE task_id = 'txt2vid_512378411b084a07';
|
||||
|
||||
-- 3. 更新用户积分(减少冻结积分)
|
||||
-- 注意:只减少 frozen_points,不增加 points(总积分不变)
|
||||
UPDATE users
|
||||
SET frozen_points = frozen_points - (
|
||||
SELECT COALESCE(freeze_points, 0)
|
||||
FROM points_freeze_records
|
||||
WHERE task_id = 'txt2vid_512378411b084a07'
|
||||
AND status = 'FROZEN'
|
||||
LIMIT 1
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE username = (
|
||||
SELECT username
|
||||
FROM task_queue
|
||||
WHERE task_id = 'txt2vid_512378411b084a07'
|
||||
LIMIT 1
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM points_freeze_records
|
||||
WHERE task_id = 'txt2vid_512378411b084a07'
|
||||
AND status = 'FROZEN'
|
||||
);
|
||||
|
||||
-- 4. 返还冻结的积分(将冻结记录状态改为 RETURNED)
|
||||
UPDATE points_freeze_records
|
||||
SET status = 'RETURNED',
|
||||
updated_at = NOW()
|
||||
WHERE task_id = 'txt2vid_512378411b084a07'
|
||||
AND status = 'FROZEN';
|
||||
|
||||
-- 5. 查询任务信息(用于确认)
|
||||
SELECT
|
||||
tq.task_id,
|
||||
tq.status as queue_status,
|
||||
tq.username,
|
||||
tq.error_message,
|
||||
ttvt.status as task_status,
|
||||
pfr.freeze_points,
|
||||
pfr.status as freeze_status
|
||||
FROM task_queue tq
|
||||
LEFT JOIN text_to_video_tasks ttvt ON tq.task_id = ttvt.task_id
|
||||
LEFT JOIN points_freeze_records pfr ON tq.task_id = pfr.task_id
|
||||
WHERE tq.task_id = 'txt2vid_512378411b084a07';
|
||||
|
||||
-- 查询结果
|
||||
SELECT '任务已取消' as result;
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
-- 清理失败任务的SQL脚本
|
||||
-- 清除失败任务的SQL脚本
|
||||
-- 数据库:aigc_platform
|
||||
|
||||
-- 删除失败的任务队列记录
|
||||
-- 1. 清除任务队列中的失败任务
|
||||
DELETE FROM task_queue WHERE status = 'FAILED';
|
||||
|
||||
-- 删除失败的图生视频任务
|
||||
DELETE FROM image_to_video_tasks WHERE status = 'FAILED';
|
||||
|
||||
-- 删除失败的文生视频任务
|
||||
-- 2. 清除文生视频任务中的失败任务
|
||||
DELETE FROM text_to_video_tasks WHERE status = 'FAILED';
|
||||
|
||||
-- 删除相关的积分冻结记录
|
||||
DELETE FROM points_freeze_records WHERE status IN ('FROZEN', 'RETURNED', 'DEDUCTED')
|
||||
AND task_id IN (
|
||||
SELECT task_id FROM task_queue WHERE status = 'FAILED'
|
||||
);
|
||||
-- 3. 清除图生视频任务中的失败任务
|
||||
DELETE FROM image_to_video_tasks WHERE status = 'FAILED';
|
||||
|
||||
-- 显示清理结果
|
||||
SELECT 'task_queue' as table_name, COUNT(*) as remaining_count FROM task_queue
|
||||
UNION ALL
|
||||
SELECT 'image_to_video_tasks' as table_name, COUNT(*) as remaining_count FROM image_to_video_tasks
|
||||
UNION ALL
|
||||
SELECT 'text_to_video_tasks' as table_name, COUNT(*) as remaining_count FROM text_to_video_tasks
|
||||
UNION ALL
|
||||
SELECT 'points_freeze_records' as table_name, COUNT(*) as remaining_count FROM points_freeze_records;
|
||||
-- 4. 清除分镜视频任务中的失败任务
|
||||
DELETE FROM storyboard_video_tasks WHERE status = 'FAILED';
|
||||
|
||||
-- 查看删除结果(可选)
|
||||
-- SELECT COUNT(*) as failed_queue_tasks FROM task_queue WHERE status = 'FAILED';
|
||||
-- SELECT COUNT(*) as failed_text_tasks FROM text_to_video_tasks WHERE status = 'FAILED';
|
||||
-- SELECT COUNT(*) as failed_image_tasks FROM image_to_video_tasks WHERE status = 'FAILED';
|
||||
-- SELECT COUNT(*) as failed_storyboard_tasks FROM storyboard_video_tasks WHERE status = 'FAILED';
|
||||
|
||||
23
demo/delete_storyboard_tasks.sql
Normal file
23
demo/delete_storyboard_tasks.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- 删除指定的分镜视频任务
|
||||
-- 任务ID: sb_025db8e9d03846d6, sb_ced9291de75d444d, sb_60698935a2cd4289
|
||||
|
||||
-- 1. 删除任务队列中的记录
|
||||
DELETE FROM task_queue WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
|
||||
-- 2. 删除分镜视频任务表中的记录
|
||||
DELETE FROM storyboard_video_tasks WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
|
||||
-- 3. 删除积分冻结记录(如果有)
|
||||
DELETE FROM points_freeze_records WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
|
||||
-- 4. 删除用户作品记录(如果有)
|
||||
DELETE FROM user_works WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
|
||||
-- 5. 删除任务状态记录(如果有)
|
||||
DELETE FROM task_status WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
|
||||
-- 查询删除结果
|
||||
SELECT '删除完成' as result;
|
||||
SELECT COUNT(*) as remaining_task_queue FROM task_queue WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
SELECT COUNT(*) as remaining_storyboard_tasks FROM storyboard_video_tasks WHERE task_id IN ('sb_025db8e9d03846d6', 'sb_ced9291de75d444d', 'sb_60698935a2cd4289');
|
||||
|
||||
6
demo/fix_storyboard_result_url.sql
Normal file
6
demo/fix_storyboard_result_url.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 修改分镜视频任务表的 result_url 字段为 TEXT 类型
|
||||
-- 解决 Data too long for column 'result_url' 错误
|
||||
|
||||
ALTER TABLE storyboard_video_tasks
|
||||
MODIFY COLUMN result_url TEXT COMMENT '分镜图URL(可能是多个URL拼接)';
|
||||
|
||||
@@ -439,6 +439,9 @@ MIT License
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ console.log('App.vue 加载成功')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -475,8 +475,7 @@ main.with-navbar {
|
||||
|
||||
/* 管理员页面 - 深色专业科技风全屏覆盖 */
|
||||
.fullscreen-background.AdminDashboard,
|
||||
.fullscreen-background.AdminOrders,
|
||||
.fullscreen-background.AdminUsers {
|
||||
.fullscreen-background.AdminOrders {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 20, 40, 0.9) 0%, rgba(0, 10, 20, 0.95) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #000000 0%, #0a0a0a 50%, #1a1a1a 100%);
|
||||
@@ -484,8 +483,7 @@ main.with-navbar {
|
||||
}
|
||||
|
||||
.fullscreen-background.AdminDashboard::before,
|
||||
.fullscreen-background.AdminOrders::before,
|
||||
.fullscreen-background.AdminUsers::before {
|
||||
.fullscreen-background.AdminOrders::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -13,8 +13,9 @@ export const getMonthlyRevenue = (year = '2024') => {
|
||||
}
|
||||
|
||||
// 获取用户转化率数据
|
||||
export const getConversionRate = () => {
|
||||
return api.get('/dashboard/conversion-rate')
|
||||
export const getConversionRate = (year = null) => {
|
||||
const params = year ? { year } : {}
|
||||
return api.get('/dashboard/conversion-rate', { params })
|
||||
}
|
||||
|
||||
// 获取最近订单数据
|
||||
@@ -27,4 +28,11 @@ export const getRecentOrders = (limit = 10) => {
|
||||
// 获取系统状态
|
||||
export const getSystemStatus = () => {
|
||||
return api.get('/dashboard/system-status')
|
||||
}
|
||||
|
||||
// 获取日活用户趋势数据
|
||||
export const getDailyActiveUsersTrend = (year = '2024', granularity = 'monthly') => {
|
||||
return api.get('/analytics/daily-active-users', {
|
||||
params: { year, granularity }
|
||||
})
|
||||
}
|
||||
@@ -95,17 +95,6 @@ export const imageToVideoApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
cancelTask(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}/cancel`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
|
||||
@@ -24,3 +24,13 @@ export const deleteMembers = (ids) => {
|
||||
export const getMemberDetail = (id) => {
|
||||
return api.get(`/members/${id}`)
|
||||
}
|
||||
|
||||
// 获取所有会员等级配置
|
||||
export const getMembershipLevels = () => {
|
||||
return api.get('/members/levels')
|
||||
}
|
||||
|
||||
// 更新会员等级配置
|
||||
export const updateMembershipLevel = (id, data) => {
|
||||
return api.put(`/members/levels/${id}`, data)
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ export const createOrderPayment = (id, paymentMethod) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 管理员订单API
|
||||
// 管理员订单API(使用普通订单接口,后端会根据用户角色返回相应数据)
|
||||
export const getAdminOrders = (params) => {
|
||||
return api.get('/orders/admin', { params })
|
||||
return api.get('/orders', { params })
|
||||
}
|
||||
|
||||
// 订单统计API
|
||||
|
||||
20
demo/frontend/src/api/points.js
Normal file
20
demo/frontend/src/api/points.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import api from './request'
|
||||
|
||||
// 积分相关API
|
||||
export const getPointsInfo = () => {
|
||||
return api.get('/points/info')
|
||||
}
|
||||
|
||||
export const getPointsHistory = (params = {}) => {
|
||||
return api.get('/points/history', { params })
|
||||
}
|
||||
|
||||
export const getPointsFreezeRecords = () => {
|
||||
return api.get('/points/freeze-records')
|
||||
}
|
||||
|
||||
export const processExpiredRecords = () => {
|
||||
return api.post('/points/process-expired')
|
||||
}
|
||||
|
||||
|
||||
@@ -20,3 +20,10 @@ export const getStoryboardTask = async (taskId) => {
|
||||
export const getUserStoryboardTasks = async (page = 0, size = 10) => {
|
||||
return api.get('/storyboard-video/tasks', { params: { page, size } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始生成视频(从分镜图生成视频)
|
||||
*/
|
||||
export const startVideoGeneration = async (taskId) => {
|
||||
return api.post(`/storyboard-video/task/${taskId}/start-video`)
|
||||
}
|
||||
@@ -84,17 +84,6 @@ export const textToVideoApi = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
cancelTask(taskId) {
|
||||
return request({
|
||||
url: `/text-to-video/tasks/${taskId}/cancel`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import request from './request'
|
||||
import api from './request'
|
||||
|
||||
// 获取我的作品列表
|
||||
export const getMyWorks = (params = {}) => {
|
||||
return request({
|
||||
url: '/works/my-works',
|
||||
method: 'GET',
|
||||
return api.get('/works/my-works', {
|
||||
params: {
|
||||
page: params.page || 0,
|
||||
size: params.size || 10
|
||||
@@ -14,46 +12,29 @@ export const getMyWorks = (params = {}) => {
|
||||
|
||||
// 获取作品详情
|
||||
export const getWorkDetail = (workId) => {
|
||||
return request({
|
||||
url: `/works/${workId}`,
|
||||
method: 'GET'
|
||||
})
|
||||
return api.get(`/works/${workId}`)
|
||||
}
|
||||
|
||||
// 删除作品
|
||||
export const deleteWork = (workId) => {
|
||||
return request({
|
||||
url: `/works/${workId}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
return api.delete(`/works/${workId}`)
|
||||
}
|
||||
|
||||
// 批量删除作品
|
||||
export const batchDeleteWorks = (workIds) => {
|
||||
return request({
|
||||
url: '/works/batch-delete',
|
||||
method: 'POST',
|
||||
data: {
|
||||
workIds: workIds
|
||||
}
|
||||
return api.post('/works/batch-delete', {
|
||||
workIds: workIds
|
||||
})
|
||||
}
|
||||
|
||||
// 更新作品信息
|
||||
export const updateWork = (workId, data) => {
|
||||
return request({
|
||||
url: `/works/${workId}`,
|
||||
method: 'PUT',
|
||||
data: data
|
||||
})
|
||||
return api.put(`/works/${workId}`, data)
|
||||
}
|
||||
|
||||
// 获取作品统计信息
|
||||
export const getWorkStats = () => {
|
||||
return request({
|
||||
url: '/works/stats',
|
||||
method: 'GET'
|
||||
})
|
||||
return api.get('/works/stats')
|
||||
}
|
||||
|
||||
|
||||
@@ -65,3 +46,6 @@ export const getWorkStats = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@
|
||||
<span>后台管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/admin/users">
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/admin/dashboard">
|
||||
<span>数据仪表盘</span>
|
||||
</el-menu-item>
|
||||
|
||||
@@ -12,7 +12,6 @@ const OrderCreate = () => import('@/views/OrderCreate.vue')
|
||||
const Payments = () => import('@/views/Payments.vue')
|
||||
const PaymentCreate = () => import('@/views/PaymentCreate.vue')
|
||||
const AdminOrders = () => import('@/views/AdminOrders.vue')
|
||||
const AdminUsers = () => import('@/views/AdminUsers.vue')
|
||||
const AdminDashboard = () => import('@/views/AdminDashboard.vue')
|
||||
const Dashboard = () => import('@/views/Dashboard.vue')
|
||||
const Welcome = () => import('@/views/Welcome.vue')
|
||||
@@ -24,6 +23,7 @@ const TextToVideo = () => import('@/views/TextToVideo.vue')
|
||||
const TextToVideoCreate = () => import('@/views/TextToVideoCreate.vue')
|
||||
const ImageToVideo = () => import('@/views/ImageToVideo.vue')
|
||||
const ImageToVideoCreate = () => import('@/views/ImageToVideoCreate.vue')
|
||||
const ImageToVideoDetail = () => import('@/views/ImageToVideoDetail.vue')
|
||||
const StoryboardVideo = () => import('@/views/StoryboardVideo.vue')
|
||||
const StoryboardVideoCreate = () => import('@/views/StoryboardVideoCreate.vue')
|
||||
const MemberManagement = () => import('@/views/MemberManagement.vue')
|
||||
@@ -75,6 +75,12 @@ const routes = [
|
||||
component: ImageToVideoCreate,
|
||||
meta: { title: '图生视频创作', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/image-to-video/detail/:taskId',
|
||||
name: 'ImageToVideoDetail',
|
||||
component: ImageToVideoDetail,
|
||||
meta: { title: '图生视频详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/storyboard-video',
|
||||
name: 'StoryboardVideo',
|
||||
@@ -164,12 +170,6 @@ const routes = [
|
||||
component: AdminOrders,
|
||||
meta: { title: '订单管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
component: AdminUsers,
|
||||
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/dashboard',
|
||||
name: 'AdminDashboard',
|
||||
|
||||
@@ -51,13 +51,32 @@ export const useOrderStore = defineStore('orders', () => {
|
||||
loading.value = true
|
||||
const response = await getOrderById(id)
|
||||
|
||||
if (response.success) {
|
||||
currentOrder.value = response.data
|
||||
console.log('OrderStore: 获取订单详情响应:', response)
|
||||
|
||||
// axios会将响应包装在response.data中
|
||||
const responseData = response?.data || response || {}
|
||||
console.log('OrderStore: 解析后的响应数据:', responseData)
|
||||
|
||||
if (responseData.success && responseData.data) {
|
||||
currentOrder.value = responseData.data
|
||||
console.log('OrderStore: 设置后的订单详情:', currentOrder.value)
|
||||
return { success: true, data: responseData.data }
|
||||
} else if (responseData.success === false) {
|
||||
console.error('OrderStore: API返回失败:', responseData.message)
|
||||
return { success: false, message: responseData.message || '获取订单详情失败' }
|
||||
} else {
|
||||
// 如果没有success字段,尝试直接使用data
|
||||
if (responseData.id || responseData.orderNumber) {
|
||||
currentOrder.value = responseData
|
||||
return { success: true, data: responseData }
|
||||
} else {
|
||||
console.error('OrderStore: API返回数据格式错误:', responseData)
|
||||
return { success: false, message: 'API返回数据格式错误' }
|
||||
}
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Fetch order error:', error)
|
||||
return { success: false, message: '获取订单详情失败' }
|
||||
console.error('OrderStore: 获取订单详情异常:', error)
|
||||
return { success: false, message: error.response?.data?.message || error.message || '获取订单详情失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<span class="logo-text">LOGO</span>
|
||||
<div class="logo-icon"></div>
|
||||
<span>LOGO</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
@@ -11,7 +12,7 @@
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>数据仪表台</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToUsers">
|
||||
<div class="nav-item" @click="goToMembers">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>会员管理</span>
|
||||
</div>
|
||||
@@ -19,15 +20,15 @@
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>订单管理</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" @click="goToAPI">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>API管理</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToTasks">
|
||||
<el-icon><Briefcase /></el-icon>
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>生成任务记录</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</div>
|
||||
@@ -35,11 +36,10 @@
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="online-users">
|
||||
<span>当前在线用户: </span>
|
||||
<span class="online-count">87/500</span>
|
||||
当前在线用户: <span class="highlight">87/500</span>
|
||||
</div>
|
||||
<div class="system-uptime">
|
||||
<span>系统运行时间: 48小时32分</span>
|
||||
系统运行时间: <span class="highlight">48小时32分</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -52,28 +52,30 @@
|
||||
<el-icon class="search-icon"><Search /></el-icon>
|
||||
<input type="text" placeholder="搜索你的想要的内容" class="search-input">
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="notification-icon">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<div class="notification-badge"></div>
|
||||
<div class="header-actions">
|
||||
<div class="notification-icon-wrapper">
|
||||
<el-icon class="notification-icon"><Bell /></el-icon>
|
||||
<span class="notification-badge"></span>
|
||||
</div>
|
||||
<div class="user-avatar">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stats-cards" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon users">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">用户总数</div>
|
||||
<div class="stat-number">12,847</div>
|
||||
<div class="stat-change positive">+12% 较上月同期</div>
|
||||
<div class="stat-number">{{ formatNumber(stats.totalUsers) }}</div>
|
||||
<div class="stat-change" :class="stats.totalUsersChange >= 0 ? 'positive' : 'negative'">
|
||||
{{ stats.totalUsersChange >= 0 ? '+' : '' }}{{ stats.totalUsersChange }}% 较上月同期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +85,10 @@
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">付费用户数</div>
|
||||
<div class="stat-number">3,215</div>
|
||||
<div class="stat-change negative">-5% 较上月同期</div>
|
||||
<div class="stat-number">{{ formatNumber(stats.paidUsers) }}</div>
|
||||
<div class="stat-change" :class="stats.paidUsersChange >= 0 ? 'positive' : 'negative'">
|
||||
{{ stats.paidUsersChange >= 0 ? '+' : '' }}{{ stats.paidUsersChange }}% 较上月同期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,8 +98,10 @@
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">今日收入</div>
|
||||
<div class="stat-number">¥28,450</div>
|
||||
<div class="stat-change positive">+15% 较上月同期</div>
|
||||
<div class="stat-number">{{ formatCurrency(stats.todayRevenue) }}</div>
|
||||
<div class="stat-change" :class="stats.todayRevenueChange >= 0 ? 'positive' : 'negative'">
|
||||
{{ stats.todayRevenueChange >= 0 ? '+' : '' }}{{ stats.todayRevenueChange }}% 较上月同期
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,16 +112,14 @@
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3>日活用户趋势</h3>
|
||||
<el-select v-model="selectedYear" class="year-select">
|
||||
<el-select v-model="selectedYear" @change="loadDailyActiveChart" class="year-select">
|
||||
<el-option label="2025年" value="2025"></el-option>
|
||||
<el-option label="2024年" value="2024"></el-option>
|
||||
<el-option label="2023年" value="2023"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div class="chart-placeholder">
|
||||
<div class="chart-title">日活用户趋势图</div>
|
||||
<div class="chart-description">显示每日活跃用户数量变化趋势</div>
|
||||
</div>
|
||||
<div ref="dailyActiveChart" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,16 +127,14 @@
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3>用户转化率</h3>
|
||||
<el-select v-model="selectedYear2" class="year-select">
|
||||
<el-select v-model="selectedYear2" @change="loadConversionChart" class="year-select">
|
||||
<el-option label="2025年" value="2025"></el-option>
|
||||
<el-option label="2024年" value="2024"></el-option>
|
||||
<el-option label="2023年" value="2023"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div class="chart-placeholder">
|
||||
<div class="chart-title">用户转化率图</div>
|
||||
<div class="chart-description">显示各月份用户转化率情况</div>
|
||||
</div>
|
||||
<div ref="conversionChart" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +143,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
@@ -157,6 +159,7 @@ import {
|
||||
ArrowDown,
|
||||
Money
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend } from '@/api/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -164,22 +167,311 @@ const router = useRouter()
|
||||
const selectedYear = ref('2025')
|
||||
const selectedYear2 = ref('2025')
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
paidUsers: 0,
|
||||
todayRevenue: 0,
|
||||
totalUsersChange: 0,
|
||||
paidUsersChange: 0,
|
||||
todayRevenueChange: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 图表相关
|
||||
const dailyActiveChart = ref(null)
|
||||
const conversionChart = ref(null)
|
||||
let dailyActiveChartInstance = null
|
||||
let conversionChartInstance = null
|
||||
|
||||
// 动态加载ECharts
|
||||
const loadECharts = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.echarts) {
|
||||
resolve(window.echarts)
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js'
|
||||
script.onload = () => resolve(window.echarts)
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
// 导航函数
|
||||
const goToUsers = () => {
|
||||
router.push('/admin/users')
|
||||
const goToMembers = () => {
|
||||
router.push('/member-management')
|
||||
}
|
||||
|
||||
const goToOrders = () => {
|
||||
router.push('/admin/orders')
|
||||
}
|
||||
|
||||
const goToAPI = () => {
|
||||
router.push('/api-management')
|
||||
}
|
||||
|
||||
const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化货币
|
||||
const formatCurrency = (amount) => {
|
||||
if (amount >= 10000) {
|
||||
return '¥' + (amount / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return '¥' + amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// 加载仪表盘数据
|
||||
const loadDashboardData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 获取概览数据
|
||||
const overviewRes = await getDashboardOverview()
|
||||
console.log('仪表盘概览数据响应:', overviewRes)
|
||||
|
||||
// 后端直接返回Map,没有success/data包装
|
||||
const data = overviewRes?.data || overviewRes || {}
|
||||
console.log('解析后的数据:', data)
|
||||
|
||||
if (data && !data.error) {
|
||||
stats.value = {
|
||||
totalUsers: data.totalUsers || 0,
|
||||
paidUsers: data.paidUsers || 0,
|
||||
todayRevenue: data.todayRevenue || 0,
|
||||
// 暂时使用固定值,后续可以从API获取同比数据
|
||||
// TODO: 后端需要添加计算同比变化的逻辑
|
||||
totalUsersChange: 0, // 暂时设为0,等待后端实现
|
||||
paidUsersChange: 0, // 暂时设为0,等待后端实现
|
||||
todayRevenueChange: 0 // 暂时设为0,等待后端实现
|
||||
}
|
||||
console.log('设置后的统计数据:', stats.value)
|
||||
} else {
|
||||
console.error('获取仪表盘数据失败:', data.error || data.message)
|
||||
ElMessage.error('获取仪表盘数据失败: ' + (data.message || '未知错误'))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error)
|
||||
ElMessage.error('加载仪表盘数据失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日活用户趋势图
|
||||
const loadDailyActiveChart = async () => {
|
||||
try {
|
||||
const response = await getDailyActiveUsersTrend(selectedYear.value, 'monthly')
|
||||
const data = response.data || {}
|
||||
|
||||
if (!dailyActiveChart.value) return
|
||||
|
||||
const echarts = await loadECharts()
|
||||
await nextTick()
|
||||
|
||||
if (dailyActiveChartInstance) {
|
||||
dailyActiveChartInstance.dispose()
|
||||
}
|
||||
|
||||
dailyActiveChartInstance = echarts.init(dailyActiveChart.value)
|
||||
|
||||
const monthlyData = data.monthlyData || []
|
||||
const months = monthlyData.map(item => `${item.month}月`)
|
||||
const values = monthlyData.map(item => item.avgDailyActive || item.dailyActiveUsers || 0)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: months,
|
||||
axisLabel: {
|
||||
color: '#6b7280'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#6b7280',
|
||||
formatter: '{value}'
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '日活用户',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: values,
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: 'rgba(59, 130, 246, 0.3)'
|
||||
}, {
|
||||
offset: 1,
|
||||
color: 'rgba(59, 130, 246, 0.1)'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
dailyActiveChartInstance.setOption(option)
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => {
|
||||
if (dailyActiveChartInstance) {
|
||||
dailyActiveChartInstance.resize()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载日活用户趋势图失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户转化率图
|
||||
const loadConversionChart = async () => {
|
||||
try {
|
||||
const response = await getConversionRate(selectedYear2.value)
|
||||
const data = response.data || {}
|
||||
|
||||
if (!conversionChart.value) return
|
||||
|
||||
const echarts = await loadECharts()
|
||||
await nextTick()
|
||||
|
||||
if (conversionChartInstance) {
|
||||
conversionChartInstance.dispose()
|
||||
}
|
||||
|
||||
conversionChartInstance = echarts.init(conversionChart.value)
|
||||
|
||||
const monthlyData = data.monthlyData || []
|
||||
const months = monthlyData.map(item => `${item.month}月`)
|
||||
const conversionRates = monthlyData.map(item => item.conversionRate || 0)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
},
|
||||
formatter: (params) => {
|
||||
const item = params[0]
|
||||
const monthData = monthlyData[item.dataIndex]
|
||||
return `${item.name}<br/>转化率: ${item.value}%<br/>总用户: ${monthData?.totalUsers || 0}<br/>付费用户: ${monthData?.paidUsers || 0}`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: months,
|
||||
axisLabel: {
|
||||
color: '#6b7280'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#6b7280',
|
||||
formatter: '{value}%'
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '转化率',
|
||||
type: 'bar',
|
||||
data: conversionRates,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: '#8b5cf6'
|
||||
}, {
|
||||
offset: 1,
|
||||
color: '#3b82f6'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
conversionChartInstance.setOption(option)
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => {
|
||||
if (conversionChartInstance) {
|
||||
conversionChartInstance.resize()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载用户转化率图失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
console.log('后台管理页面加载完成')
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
await loadDailyActiveChart()
|
||||
await loadConversionChart()
|
||||
})
|
||||
|
||||
// 组件卸载时清理图表
|
||||
onUnmounted(() => {
|
||||
if (dailyActiveChartInstance) {
|
||||
dailyActiveChartInstance.dispose()
|
||||
dailyActiveChartInstance = null
|
||||
}
|
||||
if (conversionChartInstance) {
|
||||
conversionChartInstance.dispose()
|
||||
conversionChartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -194,34 +486,45 @@ onMounted(() => {
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
background: white;
|
||||
border-right: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px 0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #3b82f6;
|
||||
.logo-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
margin: 4px 16px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@@ -242,19 +545,32 @@ onMounted(() => {
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.online-users {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.online-count {
|
||||
@@ -318,24 +634,29 @@ onMounted(() => {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
.notification-icon-wrapper {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-icon:hover {
|
||||
.notification-icon-wrapper:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -350,8 +671,8 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
@@ -360,7 +681,14 @@ onMounted(() => {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
.user-avatar img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar .arrow-down {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,700 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-users">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2>
|
||||
<el-icon><User /></el-icon>
|
||||
用户管理 - 管理员
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- 统计面板 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card class="stat-card clickable" @click="handleStatClick('all')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.totalUsers || 0 }}</div>
|
||||
<div class="stat-label">总用户数</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#409EFF"><User /></el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card class="stat-card clickable" @click="handleStatClick('admin')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.adminUsers || 0 }}</div>
|
||||
<div class="stat-label">管理员</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#67C23A"><User /></el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card class="stat-card clickable" @click="handleStatClick('user')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.normalUsers || 0 }}</div>
|
||||
<div class="stat-label">普通用户</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#E6A23C"><Avatar /></el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card class="stat-card clickable" @click="handleStatClick('today')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.todayUsers || 0 }}</div>
|
||||
<div class="stat-label">今日注册</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" color="#F56C6C"><Calendar /></el-icon>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<el-card class="filter-card">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-select
|
||||
v-model="filters.role"
|
||||
placeholder="选择用户角色"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<el-option label="全部角色" value="" />
|
||||
<el-option label="管理员" value="ROLE_ADMIN" />
|
||||
<el-option label="普通用户" value="ROLE_USER" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-input
|
||||
v-model="filters.search"
|
||||
placeholder="搜索用户名或邮箱"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<el-card class="users-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户列表</span>
|
||||
<el-button type="primary" @click="showCreateUserDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加用户
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="users"
|
||||
v-loading="loading"
|
||||
empty-text="暂无用户"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||
|
||||
<el-table-column prop="username" label="用户名" width="150" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32">{{ row.username.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<div class="user-details">
|
||||
<div class="username">{{ row.username }}</div>
|
||||
<div class="user-id">ID: {{ row.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span class="email">{{ row.email }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleType(row.role)">
|
||||
{{ getRoleText(row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="createdAt" label="注册时间" width="160" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="lastLoginAt" label="最后登录" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.lastLoginAt ? formatDate(row.lastLoginAt) : '从未登录' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="viewUserDetail(row)">
|
||||
查看
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" type="primary" @click="editUser(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="deleteUser(row)"
|
||||
:disabled="row.role === 'ROLE_ADMIN'"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑用户对话框 -->
|
||||
<el-dialog
|
||||
v-model="userDialogVisible"
|
||||
:title="isEdit ? '编辑用户' : '添加用户'"
|
||||
width="600px"
|
||||
>
|
||||
<el-form
|
||||
ref="userFormRef"
|
||||
:model="userForm"
|
||||
:rules="userRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="userForm.username"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="userForm.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password" v-if="!isEdit">
|
||||
<el-input
|
||||
v-model="userForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-radio-group v-model="userForm.role">
|
||||
<el-radio value="ROLE_USER">普通用户</el-radio>
|
||||
<el-radio value="ROLE_ADMIN">管理员</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="userDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmitUser" :loading="submitLoading">
|
||||
{{ isEdit ? '更新' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用户详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="用户详情"
|
||||
width="600px"
|
||||
>
|
||||
<div v-if="currentUser">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户ID">{{ currentUser.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ currentUser.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ currentUser.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
<el-tag :type="getRoleType(currentUser.role)">
|
||||
{{ getRoleText(currentUser.role) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDate(currentUser.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录" v-if="currentUser.lastLoginAt">
|
||||
{{ formatDate(currentUser.lastLoginAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
User,
|
||||
User as Search,
|
||||
User as Filter,
|
||||
User as Plus,
|
||||
User as Edit,
|
||||
User as Delete,
|
||||
User as View,
|
||||
User as Refresh,
|
||||
User as Download,
|
||||
User as Upload,
|
||||
Setting,
|
||||
Bell,
|
||||
User as Lock,
|
||||
User as Unlock
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref([])
|
||||
const submitLoading = ref(false)
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
adminUsers: 0,
|
||||
normalUsers: 0,
|
||||
todayUsers: 0
|
||||
})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
role: '',
|
||||
search: '',
|
||||
todayOnly: false
|
||||
})
|
||||
|
||||
// 分页信息
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 排序
|
||||
const sortBy = ref('createdAt')
|
||||
const sortDir = ref('desc')
|
||||
|
||||
// 用户对话框
|
||||
const userDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const userFormRef = ref()
|
||||
const userForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'ROLE_USER'
|
||||
})
|
||||
|
||||
const userRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
role: [
|
||||
{ required: true, message: '请选择角色', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 用户详情对话框
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentUser = ref(null)
|
||||
|
||||
// 获取角色类型
|
||||
const getRoleType = (role) => {
|
||||
return role === 'ROLE_ADMIN' ? 'danger' : 'primary'
|
||||
}
|
||||
|
||||
// 获取角色文本
|
||||
const getRoleText = (role) => {
|
||||
return role === 'ROLE_ADMIN' ? '管理员' : '普通用户'
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用真实API获取用户数据
|
||||
const response = await api.get('/admin/users')
|
||||
const data = response.data.data || []
|
||||
|
||||
// 根据筛选条件过滤用户
|
||||
let filteredUsers = data
|
||||
|
||||
// 按角色筛选
|
||||
if (filters.role) {
|
||||
filteredUsers = filteredUsers.filter(user => user.role === filters.role)
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase()
|
||||
filteredUsers = filteredUsers.filter(user =>
|
||||
user.username.toLowerCase().includes(searchLower) ||
|
||||
user.email.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// 按今日注册筛选
|
||||
if (filters.todayOnly) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
filteredUsers = filteredUsers.filter(user =>
|
||||
user.createdAt && user.createdAt.startsWith(today)
|
||||
)
|
||||
}
|
||||
|
||||
users.value = filteredUsers
|
||||
pagination.total = filteredUsers.length
|
||||
|
||||
// 更新统计数据
|
||||
stats.value = {
|
||||
totalUsers: data.length,
|
||||
adminUsers: data.filter(user => user.role === 'ROLE_ADMIN').length,
|
||||
normalUsers: data.filter(user => user.role === 'ROLE_USER').length,
|
||||
todayUsers: data.filter(user => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return user.createdAt && user.createdAt.startsWith(today)
|
||||
}).length
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch users error:', error)
|
||||
ElMessage.error('获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
pagination.page = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
filters.role = ''
|
||||
filters.search = ''
|
||||
pagination.page = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 排序变化
|
||||
const handleSortChange = ({ prop, order }) => {
|
||||
if (prop) {
|
||||
sortBy.value = prop
|
||||
sortDir.value = order === 'ascending' ? 'asc' : 'desc'
|
||||
fetchUsers()
|
||||
}
|
||||
}
|
||||
|
||||
// 分页大小变化
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.size = size
|
||||
pagination.page = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 当前页变化
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 显示创建用户对话框
|
||||
const showCreateUserDialog = () => {
|
||||
isEdit.value = false
|
||||
resetUserForm()
|
||||
userDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const editUser = (user) => {
|
||||
isEdit.value = true
|
||||
userForm.username = user.username
|
||||
userForm.email = user.email
|
||||
userForm.role = user.role
|
||||
userForm.password = ''
|
||||
userDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 重置用户表单
|
||||
const resetUserForm = () => {
|
||||
userForm.username = ''
|
||||
userForm.email = ''
|
||||
userForm.password = ''
|
||||
userForm.role = 'ROLE_USER'
|
||||
}
|
||||
|
||||
// 提交用户表单
|
||||
const handleSubmitUser = async () => {
|
||||
if (!userFormRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await userFormRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
submitLoading.value = true
|
||||
|
||||
// 调用真实API提交
|
||||
if (isEdit.value) {
|
||||
await api.put(`/admin/users/${userForm.value.id}`, userForm.value)
|
||||
} else {
|
||||
await api.post('/admin/users', userForm.value)
|
||||
}
|
||||
|
||||
ElMessage.success(isEdit.value ? '用户更新成功' : '用户创建成功')
|
||||
userDialogVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Submit user error:', error)
|
||||
ElMessage.error(isEdit.value ? '用户更新失败' : '用户创建失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看用户详情
|
||||
const viewUserDetail = (user) => {
|
||||
currentUser.value = user
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = async (user) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除用户 "${user.username}" 吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
ElMessage.success('用户删除成功')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 处理统计卡片点击事件
|
||||
const handleStatClick = (type) => {
|
||||
switch (type) {
|
||||
case 'all':
|
||||
// 显示所有用户
|
||||
filters.role = ''
|
||||
filters.search = ''
|
||||
filters.todayOnly = false
|
||||
ElMessage.info('显示所有用户')
|
||||
break
|
||||
case 'admin':
|
||||
// 筛选管理员用户
|
||||
filters.role = 'ROLE_ADMIN'
|
||||
filters.search = ''
|
||||
filters.todayOnly = false
|
||||
ElMessage.info('筛选管理员用户')
|
||||
break
|
||||
case 'user':
|
||||
// 筛选普通用户
|
||||
filters.role = 'ROLE_USER'
|
||||
filters.search = ''
|
||||
filters.todayOnly = false
|
||||
ElMessage.info('筛选普通用户')
|
||||
break
|
||||
case 'today':
|
||||
// 筛选今日注册用户
|
||||
filters.role = ''
|
||||
filters.search = ''
|
||||
filters.todayOnly = true
|
||||
ElMessage.info('筛选今日注册用户')
|
||||
break
|
||||
}
|
||||
|
||||
// 重新获取用户列表
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-users {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.users-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.email {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,539 @@
|
||||
<template>
|
||||
<div class="api-management">
|
||||
<h1>API管理页面</h1>
|
||||
<p>API管理功能开发中...</p>
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<div class="logo-icon"></div>
|
||||
<span>LOGO</span>
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToDashboard">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>数据仪表台</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMembers">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>会员管理</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToOrders">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>订单管理</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>API管理</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToTasks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>生成任务记录</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="online-users">
|
||||
当前在线用户: <span class="highlight">87/500</span>
|
||||
</div>
|
||||
<div class="system-uptime">
|
||||
系统运行时间: <span class="highlight">48小时32分</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部搜索栏 -->
|
||||
<header class="top-header">
|
||||
<div class="search-bar">
|
||||
<el-icon class="search-icon"><Search /></el-icon>
|
||||
<input type="text" placeholder="搜索你想要的内容" class="search-input" />
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="notification-icon-wrapper">
|
||||
<el-icon class="notification-icon"><Bell /></el-icon>
|
||||
<span class="notification-badge"></span>
|
||||
</div>
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- API密钥输入内容 -->
|
||||
<section class="api-content">
|
||||
<div class="content-header">
|
||||
<h2>API管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="api-form-container">
|
||||
<el-form :model="apiForm" label-width="120px" class="api-form">
|
||||
<el-form-item label="API密钥">
|
||||
<el-input
|
||||
v-model="apiForm.apiKey"
|
||||
type="password"
|
||||
placeholder="请输入API密钥"
|
||||
show-password
|
||||
style="width: 100%; max-width: 600px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Token过期时间">
|
||||
<div style="display: flex; align-items: center; gap: 12px; width: 100%; max-width: 600px;">
|
||||
<el-input
|
||||
v-model.number="apiForm.jwtExpirationHours"
|
||||
type="number"
|
||||
placeholder="请输入小时数(1-720)"
|
||||
style="flex: 1;"
|
||||
:min="1"
|
||||
:max="720"
|
||||
/>
|
||||
<span style="color: #6b7280; font-size: 14px;">小时</span>
|
||||
<span style="color: #9ca3af; font-size: 12px;" v-if="apiForm.jwtExpirationHours">
|
||||
({{ formatJwtExpiration(apiForm.jwtExpirationHours) }})
|
||||
</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #6b7280; font-size: 12px;">
|
||||
范围:1-720小时(1小时-30天)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveApiKey" :loading="saving">保存</el-button>
|
||||
<el-button @click="resetForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Grid,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Document,
|
||||
Setting,
|
||||
Search,
|
||||
Bell,
|
||||
ArrowDown
|
||||
} from '@element-plus/icons-vue'
|
||||
import api from '@/api/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const apiForm = reactive({
|
||||
apiKey: '',
|
||||
jwtExpirationHours: 24 // 默认24小时
|
||||
})
|
||||
|
||||
// 导航功能
|
||||
const goToDashboard = () => {
|
||||
router.push('/admin/dashboard')
|
||||
}
|
||||
|
||||
const goToMembers = () => {
|
||||
router.push('/member-management')
|
||||
}
|
||||
|
||||
const goToOrders = () => {
|
||||
router.push('/admin/orders')
|
||||
}
|
||||
|
||||
const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
// 格式化JWT过期时间显示
|
||||
const formatJwtExpiration = (hours) => {
|
||||
if (!hours) return ''
|
||||
if (hours < 24) {
|
||||
return `${hours}小时`
|
||||
} else if (hours < 720) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const remainingHours = hours % 24
|
||||
if (remainingHours === 0) {
|
||||
return `${days}天`
|
||||
}
|
||||
return `${days}天${remainingHours}小时`
|
||||
} else {
|
||||
return '30天'
|
||||
}
|
||||
}
|
||||
|
||||
// 加载当前API密钥和JWT配置(仅显示部分)
|
||||
const loadApiKey = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/api-key')
|
||||
if (response.data?.maskedKey) {
|
||||
// 不显示掩码后的密钥,只用于验证
|
||||
console.log('当前API密钥已配置')
|
||||
}
|
||||
// 加载JWT过期时间(转换为小时)
|
||||
if (response.data?.jwtExpiration) {
|
||||
apiForm.jwtExpirationHours = Math.round(response.data.jwtExpiration / 3600000)
|
||||
} else if (response.data?.jwtExpirationHours) {
|
||||
apiForm.jwtExpirationHours = Math.round(response.data.jwtExpirationHours)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存API密钥和JWT配置
|
||||
const saveApiKey = async () => {
|
||||
// 检查是否有任何输入
|
||||
const hasApiKey = apiForm.apiKey && apiForm.apiKey.trim() !== ''
|
||||
const hasJwtExpiration = apiForm.jwtExpirationHours != null && apiForm.jwtExpirationHours > 0
|
||||
|
||||
// 验证输入:至少需要提供一个配置项
|
||||
if (!hasApiKey && !hasJwtExpiration) {
|
||||
ElMessage.warning('请至少输入API密钥或设置Token过期时间')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证JWT过期时间范围
|
||||
if (hasJwtExpiration && (apiForm.jwtExpirationHours < 1 || apiForm.jwtExpirationHours > 720)) {
|
||||
ElMessage.warning('Token过期时间必须在1-720小时之间(1小时-30天)')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const requestData = {}
|
||||
|
||||
// 如果提供了API密钥,添加到请求中
|
||||
if (hasApiKey) {
|
||||
requestData.apiKey = apiForm.apiKey.trim()
|
||||
}
|
||||
|
||||
// 如果提供了JWT过期时间,转换为毫秒并添加到请求中
|
||||
if (hasJwtExpiration) {
|
||||
requestData.jwtExpiration = apiForm.jwtExpirationHours * 3600000 // 转换为毫秒
|
||||
}
|
||||
|
||||
const response = await api.put('/api-key', requestData)
|
||||
|
||||
if (response.data?.success) {
|
||||
ElMessage.success(response.data.message || '配置保存成功,请重启应用以使配置生效')
|
||||
// 清空API密钥输入框(保留JWT过期时间)
|
||||
apiForm.apiKey = ''
|
||||
} else {
|
||||
ElMessage.error(response.data?.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
ElMessage.error('保存失败: ' + (error.response?.data?.message || error.message || '未知错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
apiForm.apiKey = ''
|
||||
// 重新加载JWT过期时间
|
||||
loadApiKey()
|
||||
}
|
||||
|
||||
// 页面加载时获取当前API密钥状态
|
||||
onMounted(() => {
|
||||
loadApiKey()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-management {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: white;
|
||||
border-right: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.top-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
padding: 10px 12px 10px 40px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.notification-icon-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-icon-wrapper:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.user-avatar:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar .arrow-down {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* API内容区域 */
|
||||
.api-content {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
background: white;
|
||||
margin: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-form-container {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.api-form {
|
||||
background: #f9fafb;
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.api-management {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
white-space: nowrap;
|
||||
margin-right: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.api-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,11 +57,30 @@
|
||||
</div>
|
||||
|
||||
<div class="works-grid">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.id" @click="openDetail(work)">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.taskId || work.id" @click="openDetail(work)">
|
||||
<div class="work-thumbnail">
|
||||
<img :src="work.cover" :alt="work.title" />
|
||||
<!-- 优先使用首帧作为封面,如果没有则使用视频 -->
|
||||
<img
|
||||
v-if="work.firstFrameUrl"
|
||||
:src="work.firstFrameUrl"
|
||||
:alt="work.title || work.prompt"
|
||||
class="work-image-thumbnail"
|
||||
/>
|
||||
<video
|
||||
v-else-if="work.resultUrl"
|
||||
:src="work.resultUrl"
|
||||
class="work-video-thumbnail"
|
||||
preload="metadata"
|
||||
muted
|
||||
@mouseenter="playPreview($event)"
|
||||
@mouseleave="pausePreview($event)"
|
||||
></video>
|
||||
<!-- 如果都没有,显示占位符 -->
|
||||
<div v-else class="work-placeholder">
|
||||
<div class="play-icon">▶</div>
|
||||
</div>
|
||||
<div class="work-overlay">
|
||||
<div class="overlay-text">{{ work.text }}</div>
|
||||
<div class="overlay-text">{{ work.prompt || work.text || '图生视频' }}</div>
|
||||
</div>
|
||||
<!-- 鼠标悬停时显示的做同款按钮 -->
|
||||
<div class="hover-create-btn" @click.stop="goToCreate(work)">
|
||||
@@ -72,8 +91,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-info">
|
||||
<div class="work-title">{{ work.title }}</div>
|
||||
<div class="work-meta">{{ work.id }} · {{ work.size }}</div>
|
||||
<div class="work-title">{{ work.prompt || work.title || '图生视频' }}</div>
|
||||
<div class="work-meta">{{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
|
||||
</div>
|
||||
<div class="work-actions" v-if="index === 0">
|
||||
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">做同款</el-button>
|
||||
@@ -149,6 +168,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -157,35 +177,7 @@ const detailDialogVisible = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
|
||||
// 已发布作品数据
|
||||
const publishedWorks = ref([
|
||||
{
|
||||
id: '2995000000001',
|
||||
title: '图生视频作品 #1',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '图生视频',
|
||||
createTime: '2025/01/15 14:30'
|
||||
},
|
||||
{
|
||||
id: '2995000000002',
|
||||
title: '图生视频作品 #2',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '图生视频',
|
||||
createTime: '2025/01/14 16:45'
|
||||
},
|
||||
{
|
||||
id: '2995000000003',
|
||||
title: '图生视频作品 #3',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '图生视频',
|
||||
createTime: '2025/01/13 09:20'
|
||||
}
|
||||
])
|
||||
const publishedWorks = ref([])
|
||||
|
||||
// 导航函数
|
||||
const goToProfile = () => {
|
||||
@@ -235,8 +227,63 @@ const createSimilar = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = (work) => {
|
||||
if (work.size) return work.size
|
||||
return '未知大小'
|
||||
}
|
||||
|
||||
// 播放预览(鼠标悬停时)
|
||||
const playPreview = (event) => {
|
||||
const video = event.target
|
||||
if (video && video.tagName === 'VIDEO') {
|
||||
video.currentTime = 0
|
||||
video.play().catch(() => {
|
||||
// 忽略自动播放失败
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停预览(鼠标离开时)
|
||||
const pausePreview = (event) => {
|
||||
const video = event.target
|
||||
if (video && video.tagName === 'VIDEO') {
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const response = await imageToVideoApi.getTasks(0, 20)
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
// 只显示已完成的任务
|
||||
publishedWorks.value = response.data.data
|
||||
.filter(task => task.status === 'COMPLETED' && (task.resultUrl || task.firstFrameUrl))
|
||||
.map(task => ({
|
||||
taskId: task.taskId,
|
||||
prompt: task.prompt,
|
||||
resultUrl: task.resultUrl,
|
||||
firstFrameUrl: task.firstFrameUrl,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
id: task.taskId,
|
||||
title: task.prompt || '图生视频',
|
||||
text: task.prompt || '图生视频',
|
||||
category: '图生视频',
|
||||
createTime: task.createdAt ? new Date(task.createdAt).toLocaleString('zh-CN') : ''
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
ElMessage.error('加载任务列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面初始化
|
||||
// 页面初始化时加载任务列表
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -461,12 +508,45 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-thumbnail img {
|
||||
.work-thumbnail img,
|
||||
.work-thumbnail video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.work-image-thumbnail {
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.work-video-thumbnail {
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.work-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示的做同款按钮 */
|
||||
.hover-create-btn {
|
||||
position: absolute;
|
||||
|
||||
@@ -91,10 +91,7 @@
|
||||
<div class="setting-item">
|
||||
<label>时长</label>
|
||||
<select v-model="duration" class="setting-select">
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
<option value="15">15s</option>
|
||||
<option value="30">30s</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +151,7 @@
|
||||
|
||||
<!-- 视频播放区域 -->
|
||||
<div class="video-player-container">
|
||||
<div class="video-player">
|
||||
<div class="video-player" :style="getVideoPlayerStyle()">
|
||||
<video
|
||||
v-if="currentTask.resultUrl"
|
||||
:src="currentTask.resultUrl"
|
||||
@@ -219,10 +216,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务控制 -->
|
||||
<div class="task-controls" v-if="inProgress">
|
||||
<button class="cancel-btn" @click="cancelTask">取消任务</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认提示 -->
|
||||
@@ -285,7 +278,7 @@ const userStore = useUserStore()
|
||||
// 表单数据
|
||||
const inputText = ref('')
|
||||
const aspectRatio = ref('16:9')
|
||||
const duration = ref('5')
|
||||
const duration = ref('10')
|
||||
const hdMode = ref(false)
|
||||
const inProgress = ref(false)
|
||||
|
||||
@@ -331,7 +324,7 @@ const goToTextToVideo = () => {
|
||||
}
|
||||
|
||||
const goToStoryboard = () => {
|
||||
alert('分镜视频功能开发中')
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
// 用户菜单相关方法
|
||||
@@ -373,9 +366,10 @@ const uploadFirstFrame = () => {
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
// 验证文件大小(最大10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('图片文件大小不能超过10MB')
|
||||
// 验证文件大小(最大100MB,与后端配置保持一致)
|
||||
const maxFileSize = 100 * 1024 * 1024 // 100MB
|
||||
if (file.size > maxFileSize) {
|
||||
ElMessage.error('图片文件大小不能超过100MB')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -403,9 +397,10 @@ const uploadLastFrame = () => {
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
// 验证文件大小(最大10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('图片文件大小不能超过10MB')
|
||||
// 验证文件大小(最大100MB,与后端配置保持一致)
|
||||
const maxFileSize = 100 * 1024 * 1024 // 100MB
|
||||
if (file.size > maxFileSize) {
|
||||
ElMessage.error('图片文件大小不能超过100MB')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -519,6 +514,13 @@ const startPollingTask = () => {
|
||||
if (progressData && progressData.status) {
|
||||
taskStatus.value = progressData.status
|
||||
}
|
||||
// 更新resultUrl(如果API返回了且不为空)
|
||||
if (progressData && progressData.resultUrl && progressData.resultUrl.trim()) {
|
||||
if (currentTask.value) {
|
||||
currentTask.value.resultUrl = progressData.resultUrl
|
||||
console.log('更新resultUrl:', progressData.resultUrl.substring(0, 50) + '...')
|
||||
}
|
||||
}
|
||||
console.log('任务进度:', progressData)
|
||||
},
|
||||
// 完成回调
|
||||
@@ -526,6 +528,15 @@ const startPollingTask = () => {
|
||||
inProgress.value = false
|
||||
taskProgress.value = 100
|
||||
taskStatus.value = 'COMPLETED'
|
||||
// 更新currentTask的resultUrl
|
||||
if (taskData && taskData.resultUrl && taskData.resultUrl.trim()) {
|
||||
if (currentTask.value) {
|
||||
currentTask.value.resultUrl = taskData.resultUrl
|
||||
console.log('任务完成,resultUrl已更新:', taskData.resultUrl.substring(0, 50) + '...')
|
||||
}
|
||||
} else if (currentTask.value && !currentTask.value.resultUrl) {
|
||||
console.warn('任务完成但未获取到resultUrl')
|
||||
}
|
||||
ElMessage.success('视频生成完成!')
|
||||
|
||||
// 可以在这里跳转到结果页面或显示结果
|
||||
@@ -541,32 +552,6 @@ const startPollingTask = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
const cancelTask = async () => {
|
||||
if (!currentTask.value) return
|
||||
|
||||
try {
|
||||
const response = await imageToVideoApi.cancelTask(currentTask.value.taskId)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
inProgress.value = false
|
||||
taskStatus.value = 'CANCELLED'
|
||||
ElMessage.success('任务已取消')
|
||||
|
||||
// 停止轮询
|
||||
if (stopPolling.value) {
|
||||
stopPolling.value()
|
||||
stopPolling.value = null
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '取消失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error)
|
||||
ElMessage.error('取消任务失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
@@ -604,6 +589,21 @@ const formatDate = (dateString) => {
|
||||
return `${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 根据aspectRatio获取视频播放器样式
|
||||
const getVideoPlayerStyle = () => {
|
||||
// 获取当前任务的aspectRatio,如果没有则使用默认值
|
||||
const ratio = currentTask.value?.aspectRatio || aspectRatio.value || '16:9'
|
||||
|
||||
// 将比例字符串转换为数字(如 "16:9" -> 16/9 = 1.777...)
|
||||
const [width, height] = ratio.split(':').map(Number)
|
||||
const aspectRatioValue = width / height
|
||||
|
||||
return {
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
maxHeight: '70vh' // 限制最大高度,避免视频过大
|
||||
}
|
||||
}
|
||||
|
||||
// 优化提示词
|
||||
const optimizePromptHandler = async () => {
|
||||
if (!inputText.value.trim()) {
|
||||
@@ -1404,22 +1404,6 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 任务描述样式 */
|
||||
.task-description {
|
||||
@@ -1528,10 +1512,13 @@ onUnmounted(() => {
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* height 由 aspect-ratio 动态计算 */
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result-video {
|
||||
@@ -1539,6 +1526,7 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-video-placeholder {
|
||||
|
||||
@@ -147,14 +147,16 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import {
|
||||
User, Setting, Bell, Document, User as Picture, User as VideoPlay, User as VideoPause,
|
||||
User as FullScreen, User as Share, User as Download, User as Delete, User as ArrowUp, User as ArrowDown
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const videoRef = ref(null)
|
||||
|
||||
// 视频播放状态
|
||||
@@ -162,17 +164,20 @@ const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const showControls = ref(true)
|
||||
const loading = ref(true)
|
||||
|
||||
// 详情数据
|
||||
const detailInput = ref('')
|
||||
const videoData = ref({
|
||||
id: '2995697841305810',
|
||||
videoUrl: '/images/backgrounds/welcome.jpg', // 临时使用图片,实际应该是视频URL
|
||||
description: '图1在图2中奔跑视频',
|
||||
createTime: '2025/10/17 13:41',
|
||||
id: '',
|
||||
videoUrl: '',
|
||||
description: '',
|
||||
createTime: '',
|
||||
duration: 5,
|
||||
resolution: '1080p',
|
||||
aspectRatio: '16:9'
|
||||
aspectRatio: '16:9',
|
||||
status: 'PROCESSING',
|
||||
progress: 0
|
||||
})
|
||||
|
||||
const thumbnails = ref([
|
||||
@@ -254,7 +259,54 @@ const resetControlsTimer = () => {
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 加载任务详情
|
||||
const loadTaskDetail = async () => {
|
||||
const taskId = route.params.taskId
|
||||
if (!taskId) {
|
||||
ElMessage.error('任务ID不存在')
|
||||
router.push('/image-to-video')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await imageToVideoApi.getTaskDetail(taskId)
|
||||
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
const task = response.data.data
|
||||
videoData.value = {
|
||||
id: task.taskId || taskId,
|
||||
videoUrl: task.resultUrl || '',
|
||||
description: task.prompt || '',
|
||||
createTime: task.createdAt || new Date().toISOString(),
|
||||
duration: task.duration || 5,
|
||||
resolution: task.hdMode ? '1080p' : '720p',
|
||||
aspectRatio: task.aspectRatio || '16:9',
|
||||
status: task.status || 'PROCESSING',
|
||||
progress: task.progress || 0
|
||||
}
|
||||
|
||||
// 如果任务已完成且有视频URL,设置视频源
|
||||
if (task.status === 'COMPLETED' && task.resultUrl) {
|
||||
videoData.value.videoUrl = task.resultUrl
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '获取任务详情失败')
|
||||
router.push('/image-to-video')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务详情失败:', error)
|
||||
ElMessage.error('加载任务详情失败,请稍后重试')
|
||||
router.push('/image-to-video')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载任务详情
|
||||
loadTaskDetail()
|
||||
|
||||
// 监听鼠标移动来显示/隐藏控制栏
|
||||
document.addEventListener('mousemove', resetControlsTimer)
|
||||
resetControlsTimer()
|
||||
|
||||
@@ -48,13 +48,16 @@
|
||||
<header class="top-header">
|
||||
<div class="search-bar">
|
||||
<el-icon class="search-icon"><Search /></el-icon>
|
||||
<input type="text" placeholder="搜索你的想要的内容" class="search-input" />
|
||||
<input type="text" placeholder="搜索你想要的内容" class="search-input" />
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-icon class="notification-icon"><Bell /></el-icon>
|
||||
<el-icon class="help-icon"><QuestionFilled /></el-icon>
|
||||
<div class="notification-icon-wrapper">
|
||||
<el-icon class="notification-icon"><Bell /></el-icon>
|
||||
<span class="notification-badge"></span>
|
||||
</div>
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -97,7 +100,6 @@
|
||||
<th>剩余资源点</th>
|
||||
<th>到期时间</th>
|
||||
<th>编辑</th>
|
||||
<th>删除</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -118,10 +120,8 @@
|
||||
<td>{{ member.points.toLocaleString() }}</td>
|
||||
<td>{{ member.expiryDate }}</td>
|
||||
<td>
|
||||
<button class="action-btn edit-btn" @click="editMember(member)">编辑</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="action-btn delete-btn" @click="deleteMember(member)">删除</button>
|
||||
<el-link type="primary" class="action-link" @click="editMember(member)">编辑</el-link>
|
||||
<el-link type="danger" class="action-link" @click="deleteMember(member)">删除</el-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -131,7 +131,7 @@
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<div class="pagination">
|
||||
<button class="page-btn" @click="prevPage" :disabled="currentPage === 1">‹</button>
|
||||
<el-icon class="page-arrow" @click="prevPage" :class="{ disabled: currentPage === 1 }"><ArrowLeft /></el-icon>
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
@@ -140,7 +140,16 @@
|
||||
@click="goToPage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-btn" @click="nextPage" :disabled="currentPage === totalPages">›</button>
|
||||
<template v-if="totalPages > 7 && currentPage < totalPages - 2">
|
||||
<span class="page-ellipsis">...</span>
|
||||
<button
|
||||
class="page-btn"
|
||||
:class="{ active: totalPages === currentPage }"
|
||||
@click="goToPage(totalPages)">
|
||||
{{ totalPages }}
|
||||
</button>
|
||||
</template>
|
||||
<el-icon class="page-arrow" @click="nextPage" :class="{ disabled: currentPage === totalPages }"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -205,11 +214,12 @@ import {
|
||||
ShoppingCart,
|
||||
Document,
|
||||
Setting,
|
||||
User as Search,
|
||||
Search,
|
||||
Bell,
|
||||
User as ArrowDown,
|
||||
User as Edit,
|
||||
User as Delete
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as memberAPI from '@/api/members'
|
||||
|
||||
@@ -257,11 +267,11 @@ const memberList = ref([])
|
||||
|
||||
// 导航功能
|
||||
const goToDashboard = () => {
|
||||
router.push('/')
|
||||
router.push('/admin/dashboard')
|
||||
}
|
||||
|
||||
const goToOrders = () => {
|
||||
router.push('/orders')
|
||||
router.push('/admin/orders')
|
||||
}
|
||||
|
||||
const goToAPI = () => {
|
||||
@@ -287,11 +297,32 @@ const totalPages = computed(() => {
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const start = Math.max(1, currentPage.value - 2)
|
||||
const end = Math.min(totalPages.value, start + 4)
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
if (total <= 7) {
|
||||
// 如果总页数少于等于7,显示所有页码
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
// 如果总页数大于7,显示部分页码
|
||||
if (current <= 3) {
|
||||
// 当前页在前3页
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else if (current >= total - 2) {
|
||||
// 当前页在后3页
|
||||
for (let i = total - 4; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
// 当前页在中间
|
||||
for (let i = current - 2; i <= current + 2; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pages
|
||||
})
|
||||
@@ -466,22 +497,29 @@ const loadMembers = async () => {
|
||||
level: selectedLevel.value === 'all' ? '' : selectedLevel.value
|
||||
})
|
||||
|
||||
// 处理API响应数据
|
||||
if (response && response.list) {
|
||||
memberList.value = response.list.map(member => ({
|
||||
console.log('获取会员列表响应:', response)
|
||||
|
||||
// 处理API响应数据 - axios会将数据包装在response.data中
|
||||
const data = response?.data || response || {}
|
||||
console.log('解析后的数据:', data)
|
||||
|
||||
if (data && data.list) {
|
||||
memberList.value = data.list.map(member => ({
|
||||
id: member.id,
|
||||
username: member.username,
|
||||
level: getMembershipLevel(member.membership),
|
||||
points: member.points,
|
||||
points: member.points || 0,
|
||||
expiryDate: getMembershipExpiry(member.membership)
|
||||
}))
|
||||
totalMembers.value = response.total || 0
|
||||
totalMembers.value = data.total || 0
|
||||
console.log('设置后的会员列表:', memberList.value)
|
||||
} else {
|
||||
console.error('API返回数据格式错误:', data)
|
||||
ElMessage.error('API返回数据格式错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载会员数据失败:', error)
|
||||
ElMessage.error('加载会员数据失败')
|
||||
ElMessage.error('加载会员数据失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,24 +545,25 @@ onMounted(() => {
|
||||
.member-management {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
width: 240px;
|
||||
background: white;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-right: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 28px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@@ -544,58 +583,60 @@ onMounted(() => {
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 24px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #eff6ff;
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 16px;
|
||||
font-size: 22px;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 32px 20px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
@@ -603,17 +644,18 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8fafc;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.top-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
@@ -625,46 +667,77 @@ onMounted(() => {
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #94a3b8;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
padding: 8px 12px 8px 40px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 10px 12px 10px 40px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: #f8fafc;
|
||||
background: white;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #3b82f6;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #94a3b8;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.notification-icon,
|
||||
.help-icon {
|
||||
font-size: 20px;
|
||||
color: #64748b;
|
||||
.notification-icon-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-icon-wrapper:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.user-avatar:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
@@ -674,10 +747,18 @@ onMounted(() => {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar .arrow-down {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 会员内容区域 */
|
||||
.member-content {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
background: white;
|
||||
margin: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
@@ -719,7 +800,7 @@ onMounted(() => {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -730,7 +811,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.member-table thead {
|
||||
background: #f8fafc;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.member-table th {
|
||||
@@ -772,53 +853,43 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.level-tag.professional {
|
||||
background: #ec4899;
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.level-tag.standard {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
.action-link {
|
||||
margin-right: 12px;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fef2f2;
|
||||
.action-link:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 8px 12px;
|
||||
.page-arrow {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
color: #374151;
|
||||
@@ -828,6 +899,32 @@ onMounted(() => {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.page-arrow:hover:not(.disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.page-arrow.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
@@ -844,6 +941,12 @@ onMounted(() => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
padding: 0 8px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.member-management {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="header-right">
|
||||
<div class="points">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>25 | 首购优惠</span>
|
||||
<span>{{ userInfo.points - (userInfo.frozenPoints || 0) }} | 首购优惠</span>
|
||||
</div>
|
||||
<div class="notifications">
|
||||
<el-icon><Bell /></el-icon>
|
||||
@@ -67,12 +67,13 @@
|
||||
<section class="profile-section">
|
||||
<div class="profile-info">
|
||||
<div class="avatar">
|
||||
<div class="avatar-icon"></div>
|
||||
<img v-if="userInfo.avatar" :src="userInfo.avatar" alt="avatar" class="avatar-image" />
|
||||
<div v-else class="avatar-icon"></div>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<h2 class="username">mingzi_FBx7foZYDS7inLQb</h2>
|
||||
<p class="profile-status">还没有设置个人简介,点击填写</p>
|
||||
<p class="user-id">ID 2994509784706419</p>
|
||||
<h2 class="username">{{ userInfo.nickname || userInfo.username || '未设置用户名' }}</h2>
|
||||
<p class="profile-status" v-if="userInfo.bio">{{ userInfo.bio }}</p>
|
||||
<p class="user-id">ID {{ userInfo.id || '加载中...' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -166,6 +167,7 @@ import {
|
||||
Film
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getMyWorks } from '@/api/userWorks'
|
||||
import { getCurrentUser } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -174,6 +176,18 @@ const userStore = useUserStore()
|
||||
const showUserMenu = ref(false)
|
||||
const userStatusRef = ref(null)
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref({
|
||||
username: '',
|
||||
nickname: '',
|
||||
bio: '',
|
||||
avatar: '',
|
||||
id: '',
|
||||
points: 0,
|
||||
frozenPoints: 0
|
||||
})
|
||||
const userLoading = ref(false)
|
||||
|
||||
// 视频数据
|
||||
const videos = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -288,6 +302,37 @@ const transformWorkData = (work) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户信息
|
||||
const loadUserInfo = async () => {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const response = await getCurrentUser()
|
||||
console.log('获取用户信息响应:', response)
|
||||
if (response && response.data && response.data.success && response.data.data) {
|
||||
const user = response.data.data
|
||||
console.log('用户数据:', user)
|
||||
userInfo.value = {
|
||||
username: user.username || '',
|
||||
nickname: user.nickname || user.username || '',
|
||||
bio: user.bio || '',
|
||||
avatar: user.avatar || '',
|
||||
id: user.id ? String(user.id) : '',
|
||||
points: user.points || 0,
|
||||
frozenPoints: user.frozenPoints || 0
|
||||
}
|
||||
console.log('设置后的用户信息:', userInfo.value)
|
||||
} else {
|
||||
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
|
||||
ElMessage.error('获取用户信息失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error)
|
||||
ElMessage.error('加载用户信息失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户作品列表
|
||||
const loadVideos = async () => {
|
||||
loading.value = true
|
||||
@@ -296,21 +341,31 @@ const loadVideos = async () => {
|
||||
page: 0,
|
||||
size: 6 // 只加载前6个作品
|
||||
})
|
||||
console.log('获取作品列表响应:', response)
|
||||
|
||||
if (response.data.success) {
|
||||
if (response && response.data && response.data.success) {
|
||||
const data = response.data.data || []
|
||||
console.log('作品数据:', data)
|
||||
// 转换数据格式
|
||||
videos.value = data.map(transformWorkData)
|
||||
console.log('转换后的作品列表:', videos.value)
|
||||
} else {
|
||||
console.error('获取作品列表失败:', response.data.message)
|
||||
console.error('获取作品列表失败:', response?.data?.message || '未知错误')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载作品列表失败:', error)
|
||||
ElMessage.error('加载作品列表失败: ' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑个人资料
|
||||
const editProfile = () => {
|
||||
// TODO: 可以跳转到编辑页面或打开编辑对话框
|
||||
ElMessage.info('个人简介编辑功能待实现')
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (event) => {
|
||||
const userStatus = event.target.closest('.user-status')
|
||||
@@ -343,6 +398,7 @@ const onVideoLoaded = (event) => {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
loadUserInfo()
|
||||
loadVideos()
|
||||
})
|
||||
|
||||
@@ -642,6 +698,13 @@ onUnmounted(() => {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,7 +111,7 @@
|
||||
<div class="package-header">
|
||||
<h4 class="package-title">免费版</h4>
|
||||
</div>
|
||||
<div class="package-price">$0/月</div>
|
||||
<div class="package-price">${{ membershipPrices.free }}/月</div>
|
||||
<button class="package-button current">当前套餐</button>
|
||||
<div class="package-features">
|
||||
<div class="feature-item">
|
||||
@@ -127,7 +127,7 @@
|
||||
<h4 class="package-title">标准版</h4>
|
||||
<div class="discount-tag">首购低至8.5折</div>
|
||||
</div>
|
||||
<div class="package-price">$59/月</div>
|
||||
<div class="package-price">${{ membershipPrices.standard }}/月</div>
|
||||
<div class="points-box">每月200积分</div>
|
||||
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">立即订阅</button>
|
||||
<div class="package-features">
|
||||
@@ -152,7 +152,7 @@
|
||||
<h4 class="package-title">专业版</h4>
|
||||
<div class="value-tag">超值之选</div>
|
||||
</div>
|
||||
<div class="package-price">$259/月</div>
|
||||
<div class="package-price">${{ membershipPrices.premium }}/月</div>
|
||||
<div class="points-box">每月1000积分</div>
|
||||
<button class="package-button premium" @click.stop="handleSubscribe('premium')">立即订阅</button>
|
||||
<div class="package-features">
|
||||
@@ -180,46 +180,55 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情模态框 -->
|
||||
<!-- 积分详情模态框 -->
|
||||
<el-dialog
|
||||
v-model="orderDialogVisible"
|
||||
title="订单详情"
|
||||
v-model="pointsHistoryDialogVisible"
|
||||
title="积分使用情况"
|
||||
width="80%"
|
||||
class="order-dialog"
|
||||
class="points-history-dialog"
|
||||
:modal="true"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
@close="handleOrderDialogClose"
|
||||
@close="handlePointsHistoryDialogClose"
|
||||
>
|
||||
<div class="order-content">
|
||||
<div class="order-summary">
|
||||
<h3>账户订单总览</h3>
|
||||
<div class="points-history-content">
|
||||
<div class="points-summary">
|
||||
<h3>积分使用总览</h3>
|
||||
<div class="summary-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总订单数:</span>
|
||||
<span class="stat-value">{{ orders.length }}</span>
|
||||
<span class="stat-label">总充值:</span>
|
||||
<span class="stat-value positive">+{{ totalRecharge || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总金额:</span>
|
||||
<span class="stat-value">¥{{ totalAmount }}</span>
|
||||
<span class="stat-label">总消耗:</span>
|
||||
<span class="stat-value negative">{{ totalConsume || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">当前积分:</span>
|
||||
<span class="stat-value current">{{ userInfo.points || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="orders-list">
|
||||
<div class="order-item" v-for="order in orders" :key="order.id">
|
||||
<div class="order-header">
|
||||
<span class="order-id">订单号:{{ order.id }}</span>
|
||||
<span class="order-status" :class="order.status">{{ order.statusText }}</span>
|
||||
<div class="points-history-list" v-loading="pointsHistoryLoading">
|
||||
<div v-if="pointsHistory.length === 0 && !pointsHistoryLoading" class="empty-history">
|
||||
<p>暂无积分使用记录</p>
|
||||
</div>
|
||||
<div class="history-item" v-for="(item, index) in pointsHistory" :key="index">
|
||||
<div class="history-header">
|
||||
<span class="history-type" :class="item.type === '充值' ? 'recharge' : 'consume'">
|
||||
{{ item.type }}
|
||||
</span>
|
||||
<span class="history-points" :class="item.points > 0 ? 'positive' : 'negative'">
|
||||
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="order-details">
|
||||
<div class="order-info">
|
||||
<p><strong>创建时间:</strong>{{ order.createdAt }}</p>
|
||||
<p><strong>订单类型:</strong>{{ order.type }}</p>
|
||||
<p><strong>金额:</strong>¥{{ order.amount }}</p>
|
||||
</div>
|
||||
<div class="order-actions">
|
||||
<el-button type="primary" size="small" @click="viewOrderDetail(order)">查看详情</el-button>
|
||||
<div class="history-details">
|
||||
<div class="history-info">
|
||||
<p><strong>描述:</strong>{{ item.description }}</p>
|
||||
<p><strong>时间:</strong>{{ formatDateTime(item.time) }}</p>
|
||||
<p v-if="item.orderNumber"><strong>订单号:</strong>{{ item.orderNumber }}</p>
|
||||
<p v-if="item.taskId"><strong>任务ID:</strong>{{ item.taskId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,6 +254,8 @@ import PaymentModal from '@/components/PaymentModal.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments'
|
||||
import { getPointsHistory } from '@/api/points'
|
||||
import { getMembershipLevels } from '@/api/members'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
User,
|
||||
@@ -277,6 +288,13 @@ const subscriptionInfo = ref({
|
||||
paidAt: null
|
||||
})
|
||||
|
||||
// 会员等级价格配置
|
||||
const membershipPrices = ref({
|
||||
free: 0,
|
||||
standard: 59,
|
||||
premium: 259
|
||||
})
|
||||
|
||||
// 加载用户订阅信息
|
||||
const loadUserSubscriptionInfo = async () => {
|
||||
try {
|
||||
@@ -337,6 +355,8 @@ const loadUserSubscriptionInfo = async () => {
|
||||
|
||||
console.log('用户信息加载成功:', userInfo.value)
|
||||
console.log('订阅信息加载成功:', subscriptionInfo.value)
|
||||
console.log('后端返回的 currentPlan:', data.currentPlan)
|
||||
console.log('设置后的 subscriptionInfo.currentPlan:', subscriptionInfo.value.currentPlan)
|
||||
} else {
|
||||
// 如果响应结构不同,尝试直接使用response.data
|
||||
console.warn('响应格式不符合预期,尝试直接使用response.data')
|
||||
@@ -356,6 +376,8 @@ const loadUserSubscriptionInfo = async () => {
|
||||
paidAt: data.paidAt || null
|
||||
}
|
||||
console.log('用户信息加载成功(备用路径):', userInfo.value)
|
||||
console.log('后端返回的 currentPlan(备用路径):', data.currentPlan)
|
||||
console.log('设置后的 subscriptionInfo.currentPlan(备用路径):', subscriptionInfo.value.currentPlan)
|
||||
} else {
|
||||
console.error('获取用户订阅信息失败: 响应数据为空或格式不正确')
|
||||
console.error('完整响应:', JSON.stringify(response.data, null, 2))
|
||||
@@ -387,8 +409,41 @@ const loadUserSubscriptionInfo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会员等级价格配置
|
||||
const loadMembershipPrices = async () => {
|
||||
try {
|
||||
const response = await getMembershipLevels()
|
||||
const levels = response.data?.data || response.data || []
|
||||
|
||||
// 映射后端数据到前端价格配置
|
||||
levels.forEach(level => {
|
||||
const name = (level.name || level.displayName || '').toLowerCase()
|
||||
if (name.includes('免费') || name.includes('free')) {
|
||||
membershipPrices.value.free = level.price || 0
|
||||
} else if (name.includes('标准') || name.includes('standard')) {
|
||||
membershipPrices.value.standard = level.price || 59
|
||||
} else if (name.includes('专业') || name.includes('premium') || name.includes('professional')) {
|
||||
membershipPrices.value.premium = level.price || 259
|
||||
}
|
||||
})
|
||||
|
||||
console.log('会员等级价格配置加载成功:', membershipPrices.value)
|
||||
} catch (error) {
|
||||
console.error('加载会员等级价格配置失败:', error)
|
||||
// 使用默认值
|
||||
membershipPrices.value = {
|
||||
free: 0,
|
||||
standard: 59,
|
||||
premium: 259
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
// 先加载会员等级价格配置(不需要登录)
|
||||
await loadMembershipPrices()
|
||||
|
||||
// 确保用户store已初始化
|
||||
if (!userStore.initialized) {
|
||||
await userStore.init()
|
||||
@@ -436,55 +491,72 @@ const goToStoryboardVideo = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
// 订单模态框相关
|
||||
const orderDialogVisible = ref(false)
|
||||
// 积分历史模态框相关
|
||||
const pointsHistoryDialogVisible = ref(false)
|
||||
const pointsHistoryLoading = ref(false)
|
||||
const pointsHistory = ref([])
|
||||
const paymentModalVisible = ref(false)
|
||||
const currentPaymentData = ref({})
|
||||
const orders = ref([
|
||||
{
|
||||
id: 'ORD-2024-001',
|
||||
status: 'completed',
|
||||
statusText: '已完成',
|
||||
createdAt: '2024-01-15 10:30:00',
|
||||
type: '标准版订阅',
|
||||
amount: 59.00
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-002',
|
||||
status: 'pending',
|
||||
statusText: '待支付',
|
||||
createdAt: '2024-01-20 14:20:00',
|
||||
type: '专业版订阅',
|
||||
amount: 259.00
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-003',
|
||||
status: 'completed',
|
||||
statusText: '已完成',
|
||||
createdAt: '2024-01-25 09:15:00',
|
||||
type: '积分充值',
|
||||
amount: 100.00
|
||||
}
|
||||
])
|
||||
|
||||
// 计算总金额
|
||||
const totalAmount = computed(() => {
|
||||
return orders.value.reduce((sum, order) => sum + order.amount, 0).toFixed(2)
|
||||
// 计算总充值和总消耗
|
||||
const totalRecharge = computed(() => {
|
||||
return pointsHistory.value
|
||||
.filter(item => item.type === '充值')
|
||||
.reduce((sum, item) => sum + (item.points || 0), 0)
|
||||
})
|
||||
|
||||
// 显示订单详情模态框
|
||||
const goToOrderDetails = () => {
|
||||
orderDialogVisible.value = true
|
||||
const totalConsume = computed(() => {
|
||||
return Math.abs(pointsHistory.value
|
||||
.filter(item => item.type === '消耗')
|
||||
.reduce((sum, item) => sum + (item.points || 0), 0))
|
||||
})
|
||||
|
||||
// 显示积分详情模态框
|
||||
const goToOrderDetails = async () => {
|
||||
pointsHistoryDialogVisible.value = true
|
||||
await loadPointsHistory()
|
||||
}
|
||||
|
||||
// 关闭订单模态框
|
||||
const handleOrderDialogClose = () => {
|
||||
orderDialogVisible.value = false
|
||||
// 加载积分使用历史
|
||||
const loadPointsHistory = async () => {
|
||||
pointsHistoryLoading.value = true
|
||||
try {
|
||||
const response = await getPointsHistory({
|
||||
page: 0,
|
||||
size: 100 // 加载最近100条记录
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
pointsHistory.value = response.data.data || []
|
||||
} else {
|
||||
console.error('获取积分使用历史失败:', response.data.message)
|
||||
ElMessage.error('获取积分使用历史失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载积分使用历史失败:', error)
|
||||
ElMessage.error('加载积分使用历史失败')
|
||||
} finally {
|
||||
pointsHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看订单详情
|
||||
const viewOrderDetail = (order) => {
|
||||
// 这里可以添加查看订单详情的逻辑
|
||||
// 关闭积分历史模态框
|
||||
const handlePointsHistoryDialogClose = () => {
|
||||
pointsHistoryDialogVisible.value = false
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateTime) => {
|
||||
if (!dateTime) return ''
|
||||
const date = new Date(dateTime)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 跳转到我的作品页面
|
||||
@@ -1067,25 +1139,37 @@ const createSubscriptionOrder = async (planType, planInfo) => {
|
||||
.top-merged-card .row-bottom { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
|
||||
/* 订单详情模态框样式 */
|
||||
.order-dialog {
|
||||
/* 积分详情模态框样式 */
|
||||
.points-history-dialog {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.order-content {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
/* 修改对话框边框为淡蓝色 */
|
||||
.points-history-dialog :deep(.el-dialog) {
|
||||
border: 1px solid #87ceeb !important; /* 淡蓝色边框 */
|
||||
}
|
||||
|
||||
.order-summary {
|
||||
.points-history-dialog :deep(.el-dialog__header) {
|
||||
border-bottom: 1px solid #87ceeb !important; /* 标题下方边框 */
|
||||
}
|
||||
|
||||
.points-history-content {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
border: 1px solid #87ceeb; /* 淡蓝色边框 */
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.points-summary {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.order-summary h3 {
|
||||
.points-summary h3 {
|
||||
color: white;
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 18px;
|
||||
@@ -1108,66 +1192,100 @@ const createSubscriptionOrder = async (planType, planInfo) => {
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #60a5fa;
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orders-list {
|
||||
.stat-value.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-value.current {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.points-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
.empty-history {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #333;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
.history-item:hover {
|
||||
border-color: #444;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
.history-type {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-status.completed {
|
||||
.history-type.recharge {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.order-status.pending {
|
||||
background: #f59e0b;
|
||||
.history-type.consume {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.order-details {
|
||||
.history-points {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-points.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.history-points.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-info p {
|
||||
.history-info p {
|
||||
margin: 5px 0;
|
||||
color: #d1d5db;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.order-info strong {
|
||||
.history-info strong {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -51,10 +51,13 @@
|
||||
<input type="text" placeholder="搜索你想要的内容" class="search-input">
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-icon class="notification-icon"><Bell /></el-icon>
|
||||
<div class="notification-icon-wrapper">
|
||||
<el-icon class="notification-icon"><Bell /></el-icon>
|
||||
<span class="notification-badge"></span>
|
||||
</div>
|
||||
<div class="user-avatar">
|
||||
<div class="avatar-placeholder"></div>
|
||||
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
|
||||
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -89,8 +92,8 @@
|
||||
<h3>{{ level.name }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="price">${{ level.price }}/月</p>
|
||||
<p class="description">{{ level.description }}</p>
|
||||
<p class="price">${{ level.price || 0 }}/月</p>
|
||||
<p class="description">{{ level.description || `包含${level.resourcePoints || 0}资源点/月` }}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<el-button type="primary" @click="editLevel(level)">编辑</el-button>
|
||||
@@ -374,7 +377,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
@@ -390,6 +393,7 @@ import {
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import cleanupApi from '@/api/cleanup'
|
||||
import { getMembershipLevels, updateMembershipLevel } from '@/api/members'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -397,11 +401,8 @@ const router = useRouter()
|
||||
const activeTab = ref('membership')
|
||||
|
||||
// 会员收费标准相关
|
||||
const membershipLevels = ref([
|
||||
{ id: 1, name: '免费版会员', price: '0', resourcePoints: 200, description: '包含200资源点/月' },
|
||||
{ id: 2, name: '标准版会员', price: '50', resourcePoints: 500, description: '包含500资源点/月' },
|
||||
{ id: 3, name: '专业版会员', price: '250', resourcePoints: 2000, description: '包含2000资源点/月' }
|
||||
])
|
||||
const membershipLevels = ref([])
|
||||
const loadingLevels = ref(false)
|
||||
|
||||
const editDialogVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
@@ -473,7 +474,12 @@ const goToSettings = () => {
|
||||
}
|
||||
|
||||
const editLevel = (level) => {
|
||||
Object.assign(editForm, level)
|
||||
// 映射后端数据到前端表单
|
||||
editForm.id = level.id
|
||||
editForm.level = level.name || level.displayName
|
||||
editForm.price = level.price ? String(level.price) : '0'
|
||||
editForm.resourcePoints = level.pointsBonus || level.resourcePoints || 0
|
||||
editForm.validityPeriod = 'monthly' // 默认月付
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -491,13 +497,96 @@ const handlePriceInput = (value) => {
|
||||
|
||||
const saveEdit = async () => {
|
||||
const valid = await editFormRef.value.validate()
|
||||
if (valid) {
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
// 调用后端API更新会员等级配置
|
||||
const updateData = {
|
||||
price: parseFloat(editForm.price),
|
||||
resourcePoints: parseInt(editForm.resourcePoints),
|
||||
pointsBonus: parseInt(editForm.resourcePoints),
|
||||
description: `包含${editForm.resourcePoints}资源点/月`
|
||||
}
|
||||
|
||||
await updateMembershipLevel(editForm.id, updateData)
|
||||
|
||||
// 更新本地数据
|
||||
const index = membershipLevels.value.findIndex(level => level.id === editForm.id)
|
||||
if (index !== -1) {
|
||||
Object.assign(membershipLevels.value[index], editForm)
|
||||
ElMessage.success('会员等级更新成功')
|
||||
editDialogVisible.value = false
|
||||
membershipLevels.value[index].price = parseFloat(editForm.price)
|
||||
membershipLevels.value[index].pointsBonus = parseInt(editForm.resourcePoints)
|
||||
membershipLevels.value[index].resourcePoints = parseInt(editForm.resourcePoints)
|
||||
membershipLevels.value[index].description = `包含${editForm.resourcePoints}资源点/月`
|
||||
}
|
||||
|
||||
ElMessage.success('会员等级更新成功')
|
||||
editDialogVisible.value = false
|
||||
|
||||
// 重新加载会员等级配置
|
||||
await loadMembershipLevels()
|
||||
} catch (error) {
|
||||
console.error('更新会员等级失败:', error)
|
||||
ElMessage.error('更新会员等级失败: ' + (error.response?.data?.message || error.message))
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会员等级配置
|
||||
const loadMembershipLevels = async () => {
|
||||
loadingLevels.value = true
|
||||
try {
|
||||
const response = await getMembershipLevels()
|
||||
console.log('会员等级配置响应:', response)
|
||||
|
||||
// 检查响应结构
|
||||
let levels = []
|
||||
if (response.data) {
|
||||
if (response.data.success && response.data.data) {
|
||||
levels = response.data.data
|
||||
} else if (Array.isArray(response.data)) {
|
||||
levels = response.data
|
||||
} else if (response.data.data && Array.isArray(response.data.data)) {
|
||||
levels = response.data.data
|
||||
}
|
||||
}
|
||||
|
||||
console.log('解析后的会员等级数据:', levels)
|
||||
|
||||
// 映射后端数据到前端显示格式
|
||||
if (levels.length > 0) {
|
||||
membershipLevels.value = levels.map(level => ({
|
||||
id: level.id,
|
||||
name: level.displayName || level.name,
|
||||
price: level.price || 0,
|
||||
resourcePoints: level.pointsBonus || 0,
|
||||
pointsBonus: level.pointsBonus || 0,
|
||||
description: level.description || `包含${level.pointsBonus || 0}资源点/月`
|
||||
}))
|
||||
console.log('会员等级配置加载成功:', membershipLevels.value)
|
||||
} else {
|
||||
// 如果没有数据,使用默认值
|
||||
console.warn('数据库中没有会员等级数据,使用默认值')
|
||||
membershipLevels.value = [
|
||||
{ id: 1, name: '免费版会员', price: 0, resourcePoints: 200, description: '包含200资源点/月' },
|
||||
{ id: 2, name: '标准版会员', price: 59, resourcePoints: 500, description: '包含500资源点/月' },
|
||||
{ id: 3, name: '专业版会员', price: 250, resourcePoints: 2000, description: '包含2000资源点/月' }
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载会员等级配置失败:', error)
|
||||
console.error('错误详情:', error.response?.data || error.message)
|
||||
|
||||
// 显示更详细的错误信息
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || '未知错误'
|
||||
ElMessage.warning(`加载会员等级配置失败: ${errorMessage},使用默认配置`)
|
||||
|
||||
// 使用默认值,确保页面可以正常显示
|
||||
membershipLevels.value = [
|
||||
{ id: 1, name: '免费版会员', price: 0, resourcePoints: 200, description: '包含200资源点/月' },
|
||||
{ id: 2, name: '标准版会员', price: 59, resourcePoints: 500, description: '包含500资源点/月' },
|
||||
{ id: 3, name: '专业版会员', price: 250, resourcePoints: 2000, description: '包含2000资源点/月' }
|
||||
]
|
||||
} finally {
|
||||
loadingLevels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,8 +702,11 @@ const saveCleanupConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取统计信息
|
||||
refreshStats()
|
||||
// 页面加载时获取统计信息和会员等级配置
|
||||
onMounted(() => {
|
||||
refreshStats()
|
||||
loadMembershipLevels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -627,19 +719,31 @@ refreshStats()
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
width: 240px;
|
||||
background: white;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-right: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 28px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
@@ -647,43 +751,46 @@ refreshStats()
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 24px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #eff6ff;
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 16px;
|
||||
font-size: 22px;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 32px 20px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -691,13 +798,8 @@ refreshStats()
|
||||
.system-uptime {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
@@ -748,38 +850,60 @@ refreshStats()
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.notification-icon-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-icon-wrapper:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 20px;
|
||||
color: #606266;
|
||||
margin-right: 20px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
.user-avatar:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
.user-avatar .arrow-down {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
|
||||
@@ -57,11 +57,24 @@
|
||||
</div>
|
||||
|
||||
<div class="works-grid">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.id" @click="openDetail(work)">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.taskId || work.id" @click="openDetail(work)">
|
||||
<div class="work-thumbnail">
|
||||
<img :src="work.cover" :alt="work.title" />
|
||||
<!-- 使用video元素显示视频,浏览器会自动使用首帧作为封面 -->
|
||||
<video
|
||||
v-if="work.resultUrl"
|
||||
:src="work.resultUrl"
|
||||
class="work-video-thumbnail"
|
||||
preload="metadata"
|
||||
muted
|
||||
@mouseenter="playPreview($event)"
|
||||
@mouseleave="pausePreview($event)"
|
||||
></video>
|
||||
<!-- 如果没有视频URL,显示占位符 -->
|
||||
<div v-else class="work-placeholder">
|
||||
<div class="play-icon">▶</div>
|
||||
</div>
|
||||
<div class="work-overlay">
|
||||
<div class="overlay-text">{{ work.text }}</div>
|
||||
<div class="overlay-text">{{ work.prompt || work.text || '文生视频' }}</div>
|
||||
</div>
|
||||
<!-- 鼠标悬停时显示的做同款按钮 -->
|
||||
<div class="hover-create-btn" @click.stop="goToCreate(work)">
|
||||
@@ -72,8 +85,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-info">
|
||||
<div class="work-title">{{ work.title }}</div>
|
||||
<div class="work-meta">{{ work.id }} · {{ work.size }}</div>
|
||||
<div class="work-title">{{ work.prompt || work.title || '文生视频' }}</div>
|
||||
<div class="work-meta">{{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
|
||||
</div>
|
||||
<div class="work-actions" v-if="index === 0">
|
||||
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">做同款</el-button>
|
||||
@@ -149,6 +162,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
|
||||
import { textToVideoApi } from '@/api/textToVideo'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -157,35 +171,7 @@ const detailDialogVisible = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
|
||||
// 已发布作品数据
|
||||
const publishedWorks = ref([
|
||||
{
|
||||
id: '2995000000001',
|
||||
title: '文生视频作品 #1',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '文生视频',
|
||||
createTime: '2025/01/15 14:30'
|
||||
},
|
||||
{
|
||||
id: '2995000000002',
|
||||
title: '文生视频作品 #2',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '文生视频',
|
||||
createTime: '2025/01/14 16:45'
|
||||
},
|
||||
{
|
||||
id: '2995000000003',
|
||||
title: '文生视频作品 #3',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '文生视频',
|
||||
createTime: '2025/01/13 09:20'
|
||||
}
|
||||
])
|
||||
const publishedWorks = ref([])
|
||||
|
||||
// 导航函数
|
||||
const goToProfile = () => {
|
||||
@@ -235,8 +221,62 @@ const createSimilar = () => {
|
||||
router.push('/text-to-video/create')
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatSize = (work) => {
|
||||
if (work.size) return work.size
|
||||
return '未知大小'
|
||||
}
|
||||
|
||||
// 播放预览(鼠标悬停时)
|
||||
const playPreview = (event) => {
|
||||
const video = event.target
|
||||
if (video && video.tagName === 'VIDEO') {
|
||||
video.currentTime = 0
|
||||
video.play().catch(() => {
|
||||
// 忽略自动播放失败
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停预览(鼠标离开时)
|
||||
const pausePreview = (event) => {
|
||||
const video = event.target
|
||||
if (video && video.tagName === 'VIDEO') {
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const response = await textToVideoApi.getTasks(0, 20)
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
// 只显示已完成的任务
|
||||
publishedWorks.value = response.data.data
|
||||
.filter(task => task.status === 'COMPLETED' && task.resultUrl)
|
||||
.map(task => ({
|
||||
taskId: task.taskId,
|
||||
prompt: task.prompt,
|
||||
resultUrl: task.resultUrl,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
id: task.taskId,
|
||||
title: task.prompt || '文生视频',
|
||||
text: task.prompt || '文生视频',
|
||||
category: '文生视频',
|
||||
createTime: task.createdAt ? new Date(task.createdAt).toLocaleString('zh-CN') : ''
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
ElMessage.error('加载任务列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面初始化
|
||||
// 页面初始化时加载任务列表
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -462,12 +502,40 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-thumbnail img {
|
||||
.work-thumbnail img,
|
||||
.work-thumbnail video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.work-video-thumbnail {
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.work-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示的做同款按钮 */
|
||||
.hover-create-btn {
|
||||
position: absolute;
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
<select v-model="duration" class="setting-select">
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
<option value="15">15s</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -189,10 +188,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务控制 -->
|
||||
<div class="task-controls" v-if="inProgress">
|
||||
<button class="cancel-btn" @click="cancelTask">取消任务</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 初始状态 -->
|
||||
@@ -394,10 +389,10 @@ const startPollingTask = () => {
|
||||
if (progressData && progressData.status) {
|
||||
taskStatus.value = progressData.status
|
||||
}
|
||||
// 更新resultUrl(如果存在)
|
||||
if (progressData && progressData.resultUrl && currentTask.value) {
|
||||
// 更新resultUrl(如果存在且不为空)
|
||||
if (progressData && progressData.resultUrl && progressData.resultUrl.trim() && currentTask.value) {
|
||||
currentTask.value.resultUrl = progressData.resultUrl
|
||||
console.log('更新resultUrl:', progressData.resultUrl)
|
||||
console.log('更新resultUrl:', progressData.resultUrl.substring(0, 50) + '...')
|
||||
}
|
||||
console.log('任务进度:', progressData)
|
||||
},
|
||||
@@ -407,9 +402,11 @@ const startPollingTask = () => {
|
||||
taskProgress.value = 100
|
||||
taskStatus.value = 'COMPLETED'
|
||||
// 更新currentTask的resultUrl
|
||||
if (taskData && taskData.resultUrl && currentTask.value) {
|
||||
if (taskData && taskData.resultUrl && taskData.resultUrl.trim() && currentTask.value) {
|
||||
currentTask.value.resultUrl = taskData.resultUrl
|
||||
console.log('任务完成,resultUrl已更新:', taskData.resultUrl)
|
||||
console.log('任务完成,resultUrl已更新:', taskData.resultUrl.substring(0, 50) + '...')
|
||||
} else if (currentTask.value && !currentTask.value.resultUrl) {
|
||||
console.warn('任务完成但未获取到resultUrl')
|
||||
}
|
||||
ElMessage.success('视频生成完成!')
|
||||
|
||||
@@ -426,32 +423,6 @@ const startPollingTask = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
const cancelTask = async () => {
|
||||
if (!currentTask.value) return
|
||||
|
||||
try {
|
||||
const response = await textToVideoApi.cancelTask(currentTask.value.taskId)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
inProgress.value = false
|
||||
taskStatus.value = 'CANCELLED'
|
||||
ElMessage.success('任务已取消')
|
||||
|
||||
// 停止轮询
|
||||
if (stopPolling.value) {
|
||||
stopPolling.value()
|
||||
stopPolling.value = null
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || '取消失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error)
|
||||
ElMessage.error('取消任务失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
@@ -1078,6 +1049,14 @@ onUnmounted(() => {
|
||||
.right-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.video-preview-container {
|
||||
max-height: 65vh;
|
||||
}
|
||||
|
||||
.video-player-container {
|
||||
max-height: calc(65vh - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -1095,6 +1074,18 @@ onUnmounted(() => {
|
||||
.right-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.video-preview-container {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.video-player-container {
|
||||
max-height: calc(60vh - 100px);
|
||||
}
|
||||
|
||||
.completed-container {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -1123,6 +1114,21 @@ onUnmounted(() => {
|
||||
.tab {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.video-preview-container {
|
||||
max-height: 50vh;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.video-player-container {
|
||||
max-height: calc(50vh - 80px);
|
||||
}
|
||||
|
||||
.completed-container {
|
||||
max-height: 50vh;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 任务状态样式 */
|
||||
@@ -1216,21 +1222,6 @@ onUnmounted(() => {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 任务描述样式 */
|
||||
.task-description {
|
||||
@@ -1252,11 +1243,14 @@ onUnmounted(() => {
|
||||
border: 2px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
min-height: 300px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 15px 0;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 生成中状态 */
|
||||
@@ -1299,9 +1293,13 @@ onUnmounted(() => {
|
||||
.completed-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 任务信息头部 */
|
||||
@@ -1334,22 +1332,36 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
max-height: calc(70vh - 100px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.no-video-placeholder {
|
||||
|
||||
12
demo/pom.xml
12
demo/pom.xml
@@ -146,6 +146,18 @@
|
||||
<version>3.14.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JCodec - Java视频处理库 -->
|
||||
<dependency>
|
||||
<groupId>org.jcodec</groupId>
|
||||
<artifactId>jcodec</artifactId>
|
||||
<version>0.2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jcodec</groupId>
|
||||
<artifactId>jcodec-javase</artifactId>
|
||||
<version>0.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 腾讯云SDK -->
|
||||
<dependency>
|
||||
<groupId>com.tencentcloudapi</groupId>
|
||||
|
||||
@@ -2,11 +2,9 @@ package com.example.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class DemoApplication {
|
||||
|
||||
@@ -17,6 +15,13 @@ public class DemoApplication {
|
||||
System.setProperty("sun.net.client.defaultConnectTimeout", "30000");
|
||||
System.setProperty("sun.net.client.defaultReadTimeout", "120000");
|
||||
|
||||
// 增加HTTP缓冲区大小以支持大请求体(Base64编码的图片可能很大)
|
||||
// 设置Socket缓冲区大小为10MB
|
||||
System.setProperty("java.net.preferIPv4Stack", "true");
|
||||
// Apache HttpClient 使用系统属性
|
||||
System.setProperty("org.apache.http.client.connection.timeout", "30000");
|
||||
System.setProperty("org.apache.http.socket.timeout", "300000");
|
||||
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
|
||||
|
||||
46
demo/src/main/java/com/example/demo/config/AsyncConfig.java
Normal file
46
demo/src/main/java/com/example/demo/config/AsyncConfig.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* 异步执行器配置
|
||||
* 支持50人并发处理异步任务(如视频生成、图片处理等)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* 配置异步任务执行器
|
||||
* 核心线程数:5,最大线程数:20,队列容量:50
|
||||
* 可支持50人并发,每个用户最多3个任务,共150个任务
|
||||
* 大部分任务在队列中等待,实际并发执行的任务数量受线程池限制
|
||||
*/
|
||||
@Bean(name = "taskExecutor")
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
// 核心线程数:保持活跃的最小线程数
|
||||
executor.setCorePoolSize(5);
|
||||
// 最大线程数:最大并发执行的任务数
|
||||
executor.setMaxPoolSize(20);
|
||||
// 队列容量:等待执行的任务数
|
||||
executor.setQueueCapacity(50);
|
||||
// 线程名前缀
|
||||
executor.setThreadNamePrefix("async-task-");
|
||||
// 拒绝策略:当线程池和队列都满时,使用调用者线程执行(保证任务不丢失)
|
||||
executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
|
||||
// 等待所有任务完成后再关闭线程池
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
// 等待时间(秒)
|
||||
executor.setAwaitTerminationSeconds(60);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ public class PollingConfig implements SchedulingConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) {
|
||||
// 使用自定义线程池执行定时任务
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
|
||||
// 使用自定义线程池执行定时任务(支持50人并发)
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
|
||||
taskRegistrar.setScheduler(executor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
|
||||
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
|
||||
.requestMatchers("/api/dashboard/**").hasRole("ADMIN") // 仪表盘API需要管理员权限
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 管理员API需要管理员权限
|
||||
.requestMatchers("/settings", "/settings/**").hasRole("ADMIN")
|
||||
.requestMatchers("/users/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
|
||||
/**
|
||||
* 事务管理器配置
|
||||
* 确保事务不会长时间占用数据库连接
|
||||
*/
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
public class TransactionManagerConfig {
|
||||
|
||||
@Autowired
|
||||
private EntityManagerFactory entityManagerFactory;
|
||||
|
||||
|
||||
/**
|
||||
* 配置事务管理器
|
||||
* 使用 JpaTransactionManager 以支持 JPA 操作(包括悲观锁)
|
||||
* 注意:超时时间在 TransactionTemplate 中设置,而不是在 TransactionManager 中
|
||||
* 这样可以更精确地控制不同场景下的超时时间
|
||||
*/
|
||||
@Bean
|
||||
public PlatformTransactionManager transactionManager() {
|
||||
JpaTransactionManager transactionManager = new JpaTransactionManager();
|
||||
transactionManager.setEntityManagerFactory(entityManagerFactory);
|
||||
|
||||
// 设置是否允许嵌套事务
|
||||
transactionManager.setNestedTransactionAllowed(true);
|
||||
|
||||
// 设置是否在回滚时验证事务状态
|
||||
transactionManager.setValidateExistingTransaction(true);
|
||||
|
||||
// 注意:超时时间在 TransactionTemplate 中设置,而不是在 TransactionManager 中
|
||||
// 这样可以更精确地控制不同场景下的超时时间
|
||||
|
||||
return transactionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置用于异步方法的事务模板
|
||||
* 使用更短的超时时间(3秒),确保异步线程中的事务快速完成
|
||||
*/
|
||||
@Bean(name = "asyncTransactionTemplate")
|
||||
public TransactionTemplate asyncTransactionTemplate(PlatformTransactionManager transactionManager) {
|
||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||
|
||||
// 异步方法中的事务超时时间设置为3秒
|
||||
// 确保异步线程中的数据库操作快速完成,不会长时间占用连接
|
||||
template.setTimeout(3);
|
||||
|
||||
// 设置传播行为为 REQUIRES_NEW,确保每个操作都是独立事务
|
||||
template.setPropagationBehavior(org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
// 设置隔离级别为 READ_COMMITTED(默认)
|
||||
template.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
|
||||
// 设置只读标志(默认false,允许写操作)
|
||||
template.setReadOnly(false);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置用于只读操作的事务模板
|
||||
* 使用更短的超时时间(2秒),确保只读操作快速完成
|
||||
*/
|
||||
@Bean(name = "readOnlyTransactionTemplate")
|
||||
public TransactionTemplate readOnlyTransactionTemplate(PlatformTransactionManager transactionManager) {
|
||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||
|
||||
// 只读操作超时时间设置为2秒
|
||||
template.setTimeout(2);
|
||||
|
||||
// 设置传播行为为 REQUIRES_NEW
|
||||
template.setPropagationBehavior(org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
// 设置只读标志为 true
|
||||
template.setReadOnly(true);
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.example.demo.config;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.LocaleResolver;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
|
||||
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
|
||||
@@ -13,6 +15,9 @@ import org.springframework.web.servlet.i18n.SessionLocaleResolver;
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.upload.path:uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
@Bean
|
||||
public LocaleResolver localeResolver() {
|
||||
SessionLocaleResolver slr = new SessionLocaleResolver();
|
||||
@@ -32,6 +37,28 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
registry.addInterceptor(localeChangeInterceptor());
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置静态资源服务,使上传的文件可以通过URL访问
|
||||
* 访问路径:/uploads/** -> 映射到 uploads/ 目录
|
||||
*/
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// 将 /uploads/** 映射到 uploads/ 目录
|
||||
// 处理路径:如果是相对路径,转换为绝对路径;如果是绝对路径,直接使用
|
||||
java.nio.file.Path uploadDirPath = java.nio.file.Paths.get(uploadPath);
|
||||
if (!uploadDirPath.isAbsolute()) {
|
||||
// 相对路径:基于应用运行目录
|
||||
uploadDirPath = java.nio.file.Paths.get(System.getProperty("user.dir"), uploadPath);
|
||||
}
|
||||
|
||||
// 确保路径使用正斜杠(URL格式)
|
||||
String resourceLocation = "file:" + uploadDirPath.toAbsolutePath().toString().replace("\\", "/") + "/";
|
||||
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations(resourceLocation)
|
||||
.setCachePeriod(3600); // 缓存1小时
|
||||
}
|
||||
|
||||
// CORS配置已移至SecurityConfig,避免冲突
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
|
||||
/**
|
||||
* 管理员控制器
|
||||
@@ -129,5 +142,241 @@ public class AdminController {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户列表
|
||||
*/
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Map<String, Object>> getAllUsers(
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
List<User> users = userService.findAll();
|
||||
|
||||
// 转换为DTO格式
|
||||
List<Map<String, Object>> userList = users.stream().map(user -> {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("email", user.getEmail());
|
||||
userMap.put("role", user.getRole());
|
||||
userMap.put("points", user.getPoints());
|
||||
userMap.put("frozenPoints", user.getFrozenPoints());
|
||||
userMap.put("createdAt", user.getCreatedAt());
|
||||
userMap.put("lastLoginAt", user.getLastLoginAt());
|
||||
userMap.put("isActive", user.getIsActive());
|
||||
return userMap;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
response.put("success", true);
|
||||
response.put("data", userList);
|
||||
|
||||
logger.info("管理员 {} 获取用户列表,共 {} 个用户", adminUsername, users.size());
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("获取用户列表失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "获取用户列表失败: " + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新用户
|
||||
*/
|
||||
@PostMapping("/users")
|
||||
public ResponseEntity<Map<String, Object>> createUser(
|
||||
@RequestBody Map<String, Object> userData,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
// 提取用户数据
|
||||
String username = (String) userData.get("username");
|
||||
String email = (String) userData.get("email");
|
||||
String password = (String) userData.get("password");
|
||||
String role = (String) userData.getOrDefault("role", "ROLE_USER");
|
||||
|
||||
// 验证必填字段
|
||||
if (username == null || username.isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户名不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
if (email == null || email.isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "邮箱不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
if (password == null || password.isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "密码不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
User user = userService.create(username, email, password);
|
||||
|
||||
// 如果指定了角色,更新角色
|
||||
if (!role.equals("ROLE_USER")) {
|
||||
userService.update(user.getId(), username, email, null, role);
|
||||
user = userService.findById(user.getId());
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("email", user.getEmail());
|
||||
userMap.put("role", user.getRole());
|
||||
userMap.put("points", user.getPoints());
|
||||
userMap.put("createdAt", user.getCreatedAt());
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "用户创建成功");
|
||||
response.put("data", userMap);
|
||||
|
||||
logger.info("管理员 {} 创建用户: {}", adminUsername, username);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("创建用户失败: {}", e.getMessage());
|
||||
response.put("success", false);
|
||||
response.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("创建用户失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "创建用户失败: " + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
@PutMapping("/users/{id}")
|
||||
public ResponseEntity<Map<String, Object>> updateUser(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Object> userData,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
// 提取用户数据
|
||||
String username = (String) userData.get("username");
|
||||
String email = (String) userData.get("email");
|
||||
String password = (String) userData.get("password");
|
||||
String role = (String) userData.get("role");
|
||||
|
||||
// 验证必填字段
|
||||
if (username == null || username.isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户名不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
if (email == null || email.isBlank()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "邮箱不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
// 更新用户(密码可选)
|
||||
User user = userService.update(id, username, email, password, role);
|
||||
|
||||
// 构建响应
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("email", user.getEmail());
|
||||
userMap.put("role", user.getRole());
|
||||
userMap.put("points", user.getPoints());
|
||||
userMap.put("updatedAt", user.getUpdatedAt());
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "用户更新成功");
|
||||
response.put("data", userMap);
|
||||
|
||||
logger.info("管理员 {} 更新用户: {}", adminUsername, username);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("更新用户失败: {}", e.getMessage());
|
||||
response.put("success", false);
|
||||
response.put("message", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("更新用户失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "更新用户失败: " + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
@DeleteMapping("/users/{id}")
|
||||
public ResponseEntity<Map<String, Object>> deleteUser(
|
||||
@PathVariable Long id,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
userService.delete(id);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "用户删除成功");
|
||||
|
||||
logger.info("管理员 {} 删除用户ID: {}", adminUsername, id);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("删除用户失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "删除用户失败: " + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/api-key")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class ApiKeyController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiKeyController.class);
|
||||
|
||||
@Value("${spring.profiles.active:dev}")
|
||||
private String activeProfile;
|
||||
|
||||
@Value("${ai.api.key:}")
|
||||
private String currentApiKey;
|
||||
|
||||
@Value("${jwt.expiration:86400000}")
|
||||
private Long currentJwtExpiration;
|
||||
|
||||
/**
|
||||
* 获取当前API密钥和JWT配置(仅显示部分,用于验证)
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getApiKey() {
|
||||
try {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
// 只返回密钥的前4位和后4位,中间用*代替
|
||||
if (currentApiKey != null && currentApiKey.length() > 8) {
|
||||
String masked = currentApiKey.substring(0, 4) + "****" + currentApiKey.substring(currentApiKey.length() - 4);
|
||||
response.put("maskedKey", masked);
|
||||
} else {
|
||||
response.put("maskedKey", "****");
|
||||
}
|
||||
// 返回JWT过期时间(毫秒)
|
||||
response.put("jwtExpiration", currentJwtExpiration);
|
||||
// 转换为小时显示
|
||||
response.put("jwtExpirationHours", currentJwtExpiration / 3600000.0);
|
||||
response.put("success", true);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("获取API密钥失败", e);
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "获取API密钥失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新API密钥和JWT配置到配置文件
|
||||
*/
|
||||
@PutMapping
|
||||
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
String newApiKey = (String) request.get("apiKey");
|
||||
Object jwtExpirationObj = request.get("jwtExpiration");
|
||||
|
||||
// 验证API密钥
|
||||
if (newApiKey != null && newApiKey.trim().isEmpty()) {
|
||||
newApiKey = null; // 如果为空字符串,则不更新
|
||||
}
|
||||
|
||||
// 验证JWT过期时间
|
||||
Long newJwtExpiration = null;
|
||||
if (jwtExpirationObj != null) {
|
||||
if (jwtExpirationObj instanceof Number) {
|
||||
newJwtExpiration = ((Number) jwtExpirationObj).longValue();
|
||||
} else if (jwtExpirationObj instanceof String) {
|
||||
try {
|
||||
newJwtExpiration = Long.parseLong((String) jwtExpirationObj);
|
||||
} catch (NumberFormatException e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "JWT过期时间格式错误");
|
||||
error.put("message", "JWT过期时间必须是数字(毫秒)");
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证过期时间范围(至少1小时,最多30天)
|
||||
if (newJwtExpiration != null && (newJwtExpiration < 3600000 || newJwtExpiration > 2592000000L)) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "JWT过期时间超出范围");
|
||||
error.put("message", "JWT过期时间必须在1小时(3600000毫秒)到30天(2592000000毫秒)之间");
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有提供,返回错误
|
||||
if (newApiKey == null && newJwtExpiration == null) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "至少需要提供一个配置项");
|
||||
error.put("message", "请提供API密钥或JWT过期时间");
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
|
||||
// 确定配置文件路径
|
||||
String configFileName = "application-" + activeProfile + ".properties";
|
||||
Path configPath = getConfigFilePath(configFileName);
|
||||
|
||||
// 读取现有配置
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(configPath)) {
|
||||
try (FileInputStream fis = new FileInputStream(configPath.toFile())) {
|
||||
props.load(fis);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新API密钥
|
||||
if (newApiKey != null) {
|
||||
props.setProperty("ai.api.key", newApiKey);
|
||||
props.setProperty("ai.image.api.key", newApiKey); // 同时更新图片API密钥
|
||||
logger.info("API密钥已更新");
|
||||
}
|
||||
|
||||
// 更新JWT过期时间
|
||||
if (newJwtExpiration != null) {
|
||||
props.setProperty("jwt.expiration", String.valueOf(newJwtExpiration));
|
||||
logger.info("JWT过期时间已更新: {} 毫秒 ({} 小时)", newJwtExpiration, newJwtExpiration / 3600000.0);
|
||||
}
|
||||
|
||||
// 保存配置文件
|
||||
try (FileOutputStream fos = new FileOutputStream(configPath.toFile())) {
|
||||
props.store(fos, "Updated by API Key Management");
|
||||
}
|
||||
|
||||
logger.info("配置已更新到配置文件: {}", configPath);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
StringBuilder message = new StringBuilder();
|
||||
if (newApiKey != null) {
|
||||
message.append("API密钥已更新。");
|
||||
}
|
||||
if (newJwtExpiration != null) {
|
||||
message.append("JWT过期时间已更新。");
|
||||
}
|
||||
message.append("请重启应用以使配置生效。");
|
||||
response.put("message", message.toString());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("更新配置失败", e);
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "更新配置失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置文件路径
|
||||
* 优先使用外部配置文件,如果不存在则使用classpath中的配置文件
|
||||
*/
|
||||
private Path getConfigFilePath(String fileName) throws IOException {
|
||||
// 尝试从外部配置目录查找
|
||||
String externalConfigDir = System.getProperty("user.dir");
|
||||
Path externalPath = Paths.get(externalConfigDir, "config", fileName);
|
||||
if (Files.exists(externalPath)) {
|
||||
return externalPath;
|
||||
}
|
||||
|
||||
// 尝试从项目根目录查找
|
||||
Path rootPath = Paths.get(externalConfigDir, "src", "main", "resources", fileName);
|
||||
if (Files.exists(rootPath)) {
|
||||
return rootPath;
|
||||
}
|
||||
|
||||
// 尝试从classpath复制到外部目录
|
||||
ClassPathResource resource = new ClassPathResource(fileName);
|
||||
if (resource.exists()) {
|
||||
// 创建config目录
|
||||
Path configDir = Paths.get(externalConfigDir, "config");
|
||||
Files.createDirectories(configDir);
|
||||
|
||||
// 复制文件到外部目录
|
||||
Path targetPath = configDir.resolve(fileName);
|
||||
try (InputStream is = resource.getInputStream();
|
||||
FileOutputStream fos = new FileOutputStream(targetPath.toFile())) {
|
||||
is.transferTo(fos);
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
// 如果都不存在,创建新的配置文件
|
||||
Path configDir = Paths.get(externalConfigDir, "config");
|
||||
Files.createDirectories(configDir);
|
||||
return configDir.resolve(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.model.Order;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.repository.OrderRepository;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.model.Order;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/dashboard")
|
||||
@@ -130,7 +139,7 @@ public class DashboardApiController {
|
||||
|
||||
// 获取用户转化率数据
|
||||
@GetMapping("/conversion-rate")
|
||||
public ResponseEntity<Map<String, Object>> getConversionRate() {
|
||||
public ResponseEntity<Map<String, Object>> getConversionRate(@RequestParam(required = false) String year) {
|
||||
try {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -147,6 +156,12 @@ public class DashboardApiController {
|
||||
response.put("paidUsers", paidUsers);
|
||||
response.put("conversionRate", Math.round(conversionRate * 100.0) / 100.0);
|
||||
|
||||
// 如果指定了年份,返回按月转化率数据
|
||||
if (year != null && !year.isEmpty()) {
|
||||
List<Map<String, Object>> monthlyConversion = getMonthlyConversionRate(Integer.parseInt(year));
|
||||
response.put("monthlyData", monthlyConversion);
|
||||
}
|
||||
|
||||
// 按会员等级统计
|
||||
List<Map<String, Object>> membershipStats = membershipLevelRepository.findMembershipStats();
|
||||
response.put("membershipStats", membershipStats);
|
||||
@@ -160,6 +175,36 @@ public class DashboardApiController {
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取按月转化率数据
|
||||
private List<Map<String, Object>> getMonthlyConversionRate(int year) {
|
||||
List<Map<String, Object>> monthlyData = new ArrayList<>();
|
||||
|
||||
for (int month = 1; month <= 12; month++) {
|
||||
Map<String, Object> monthData = new HashMap<>();
|
||||
monthData.put("month", month);
|
||||
|
||||
// 计算该月的总用户数(注册时间在该月)
|
||||
LocalDateTime monthStart = LocalDateTime.of(year, month, 1, 0, 0, 0);
|
||||
LocalDateTime monthEnd = monthStart.plusMonths(1).minusSeconds(1);
|
||||
|
||||
long monthTotalUsers = userRepository.countByCreatedAtBetween(monthStart, monthEnd);
|
||||
|
||||
// 计算该月新增的付费用户数(会员开始时间在该月)
|
||||
long monthPaidUsers = userMembershipRepository.countByStartDateBetween(monthStart, monthEnd);
|
||||
|
||||
// 计算该月转化率
|
||||
double monthConversionRate = monthTotalUsers > 0 ? (double) monthPaidUsers / monthTotalUsers * 100 : 0.0;
|
||||
|
||||
monthData.put("totalUsers", monthTotalUsers);
|
||||
monthData.put("paidUsers", monthPaidUsers);
|
||||
monthData.put("conversionRate", Math.round(monthConversionRate * 100.0) / 100.0);
|
||||
|
||||
monthlyData.add(monthData);
|
||||
}
|
||||
|
||||
return monthlyData;
|
||||
}
|
||||
|
||||
// 获取最近订单数据
|
||||
@GetMapping("/recent-orders")
|
||||
|
||||
@@ -70,16 +70,17 @@ public class ImageToVideoApiController {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
// 验证文件大小(最大10MB)
|
||||
if (firstFrame.getSize() > 10 * 1024 * 1024) {
|
||||
// 验证文件大小(最大100MB,与文件上传配置保持一致)
|
||||
long maxFileSize = 100 * 1024 * 1024; // 100MB
|
||||
if (firstFrame.getSize() > maxFileSize) {
|
||||
response.put("success", false);
|
||||
response.put("message", "首帧图片大小不能超过10MB");
|
||||
response.put("message", "首帧图片大小不能超过100MB");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
if (lastFrame != null && !lastFrame.isEmpty() && lastFrame.getSize() > 10 * 1024 * 1024) {
|
||||
if (lastFrame != null && !lastFrame.isEmpty() && lastFrame.getSize() > maxFileSize) {
|
||||
response.put("success", false);
|
||||
response.put("message", "尾帧图片大小不能超过10MB");
|
||||
response.put("message", "尾帧图片大小不能超过100MB");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
@@ -209,42 +210,6 @@ public class ImageToVideoApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
*/
|
||||
@PostMapping("/tasks/{taskId}/cancel")
|
||||
public ResponseEntity<Map<String, Object>> cancelTask(
|
||||
@PathVariable String taskId,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
try {
|
||||
String username = extractUsernameFromToken(token);
|
||||
if (username == null) {
|
||||
response.put("success", false);
|
||||
response.put("message", "用户未登录");
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
boolean success = imageToVideoService.cancelTask(taskId, username);
|
||||
if (success) {
|
||||
response.put("success", true);
|
||||
response.put("message", "任务已取消");
|
||||
} else {
|
||||
response.put("success", false);
|
||||
response.put("message", "任务取消失败或任务不存在");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("取消任务失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "取消任务失败:" + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/members")
|
||||
@@ -260,4 +269,97 @@ public class MemberApiController {
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有会员等级配置(用于系统设置和订阅页面)
|
||||
@GetMapping("/levels")
|
||||
public ResponseEntity<Map<String, Object>> getMembershipLevels() {
|
||||
try {
|
||||
List<MembershipLevel> levels = membershipLevelRepository.findAll();
|
||||
|
||||
List<Map<String, Object>> levelList = levels.stream()
|
||||
.map(level -> {
|
||||
Map<String, Object> levelMap = new HashMap<>();
|
||||
levelMap.put("id", level.getId());
|
||||
levelMap.put("name", level.getName());
|
||||
levelMap.put("displayName", level.getDisplayName());
|
||||
levelMap.put("description", level.getDescription());
|
||||
levelMap.put("price", level.getPrice());
|
||||
levelMap.put("durationDays", level.getDurationDays());
|
||||
levelMap.put("pointsBonus", level.getPointsBonus());
|
||||
levelMap.put("features", level.getFeatures());
|
||||
levelMap.put("isActive", level.getIsActive());
|
||||
return levelMap;
|
||||
})
|
||||
.toList();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", levelList);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "获取会员等级配置失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会员等级价格和配置
|
||||
@PutMapping("/levels/{id}")
|
||||
public ResponseEntity<Map<String, Object>> updateMembershipLevel(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Object> updateData) {
|
||||
try {
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository.findById(id);
|
||||
if (levelOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
MembershipLevel level = levelOpt.get();
|
||||
|
||||
// 更新价格
|
||||
if (updateData.containsKey("price")) {
|
||||
Object priceObj = updateData.get("price");
|
||||
if (priceObj instanceof Number) {
|
||||
level.setPrice(((Number) priceObj).doubleValue());
|
||||
} else if (priceObj instanceof String) {
|
||||
level.setPrice(Double.parseDouble((String) priceObj));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新资源点数量
|
||||
if (updateData.containsKey("pointsBonus") || updateData.containsKey("resourcePoints")) {
|
||||
Object pointsObj = updateData.get("pointsBonus") != null
|
||||
? updateData.get("pointsBonus")
|
||||
: updateData.get("resourcePoints");
|
||||
if (pointsObj instanceof Number) {
|
||||
level.setPointsBonus(((Number) pointsObj).intValue());
|
||||
} else if (pointsObj instanceof String) {
|
||||
level.setPointsBonus(Integer.parseInt((String) pointsObj));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新描述
|
||||
if (updateData.containsKey("description")) {
|
||||
level.setDescription((String) updateData.get("description"));
|
||||
}
|
||||
|
||||
level.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
membershipLevelRepository.save(level);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "会员等级配置更新成功");
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "更新会员等级配置失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user