feat: 系统优化和功能完善

主要更新:
- 调整并发配置为50人(数据库连接池30,Tomcat线程150,异步线程池5/20)
- 实现无界阻塞队列(LinkedBlockingQueue)任务处理
- 实现分镜视频保存功能(保存到uploads目录)
- 统一管理页面导航栏和右上角样式
- 添加日活用户统计功能
- 优化视频拼接和保存逻辑
- 添加部署文档和快速部署指南
- 更新.gitignore排除敏感配置文件
This commit is contained in:
AIGC Developer
2025-11-07 19:09:50 +08:00
parent b5820d9be2
commit 1e71ae6a26
146 changed files with 10720 additions and 3032 deletions

47
demo/.gitignore vendored
View File

@@ -31,3 +31,50 @@ build/
### VS Code ### ### VS Code ###
.vscode/ .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
*~

View File

@@ -216,3 +216,6 @@ ngrok http 8080

View File

@@ -0,0 +1 @@

1
demo/API_FIX_SOLUTION.md Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

1
demo/CODE_LOGIC_FIXES.md Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -111,3 +111,6 @@

View File

@@ -0,0 +1 @@

306
demo/CONFIGURATION_GUIDE.md Normal file
View 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密钥是否正确
- 检查网络连接
- 查看日志文件获取详细错误信息

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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点已配置定时任务
---
**祝部署顺利!** 🎉

View File

@@ -277,3 +277,6 @@ if (result.success) {

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

209
demo/GIT_UPLOAD_GUIDE.md Normal file
View 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
```

View File

@@ -230,3 +230,6 @@ A: IJPay 是对原生 SDK 的封装,提供了更简洁的 API。底层实现

View File

@@ -297,3 +297,6 @@ grep "img2vid_abc123def456" logs/application.log

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@

View File

@@ -298,3 +298,6 @@ public TaskQueue addTextToVideoTask(String username, String taskId) {

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -39,6 +39,9 @@ public class PasswordChecker {

126
demo/QUICK_DEPLOY.md Normal file
View 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`

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@

View File

@@ -0,0 +1,8 @@

View File

@@ -0,0 +1,8 @@

View File

@@ -289,3 +289,6 @@ ResourceNotFound.TemplateNotFound

View File

@@ -307,3 +307,6 @@ const startPolling = (taskId) => {

View File

@@ -0,0 +1,8 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

98
demo/TRANSACTION_AUDIT.md Normal file
View 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`,改为方法级别配置,以提高灵活性
## 结论
**✅ 所有事务都能正常关闭,没有发现连接泄漏风险**

View 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` 的私有方法进行数据库操作
## 结论
**✅ 所有实际使用的事务配置都是正确的,不会导致连接泄漏**

View File

@@ -0,0 +1 @@

View File

@@ -177,3 +177,6 @@ const updateWork = async (workId, updateData) => {

View File

@@ -0,0 +1 @@

6
demo/add_bio_column.sql Normal file
View File

@@ -0,0 +1,6 @@
-- 添加个人简介字段到用户表
-- 执行此脚本以更新数据库schema
ALTER TABLE users ADD COLUMN bio TEXT COMMENT '个人简介';

View 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
View 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;

View File

@@ -1,26 +1,20 @@
-- 清失败任务的SQL脚本 -- 清失败任务的SQL脚本
-- 数据库aigc_platform
-- 删除失败的任务队列记录 -- 1. 清除任务队列中的失败任务
DELETE FROM task_queue WHERE status = 'FAILED'; DELETE FROM task_queue WHERE status = 'FAILED';
-- 删除失败的图生视频任务 -- 2. 清除文生视频任务中的失败任务
DELETE FROM image_to_video_tasks WHERE status = 'FAILED';
-- 删除失败的文生视频任务
DELETE FROM text_to_video_tasks WHERE status = 'FAILED'; DELETE FROM text_to_video_tasks WHERE status = 'FAILED';
-- 删除相关的积分冻结记录 -- 3. 清除图生视频任务中的失败任务
DELETE FROM points_freeze_records WHERE status IN ('FROZEN', 'RETURNED', 'DEDUCTED') DELETE FROM image_to_video_tasks WHERE status = 'FAILED';
AND task_id IN (
SELECT task_id FROM task_queue WHERE status = 'FAILED'
);
-- 显示清理结果 -- 4. 清除分镜视频任务中的失败任务
SELECT 'task_queue' as table_name, COUNT(*) as remaining_count FROM task_queue DELETE FROM storyboard_video_tasks WHERE status = 'FAILED';
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;
-- 查看删除结果(可选)
-- 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';

View 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');

View 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拼接';

View File

@@ -439,6 +439,9 @@ MIT License

View File

@@ -35,6 +35,9 @@ console.log('App.vue 加载成功')

View File

@@ -475,8 +475,7 @@ main.with-navbar {
/* 管理员页面 - 深色专业科技风全屏覆盖 */ /* 管理员页面 - 深色专业科技风全屏覆盖 */
.fullscreen-background.AdminDashboard, .fullscreen-background.AdminDashboard,
.fullscreen-background.AdminOrders, .fullscreen-background.AdminOrders {
.fullscreen-background.AdminUsers {
background: 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%), 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%); linear-gradient(135deg, #000000 0%, #0a0a0a 50%, #1a1a1a 100%);
@@ -484,8 +483,7 @@ main.with-navbar {
} }
.fullscreen-background.AdminDashboard::before, .fullscreen-background.AdminDashboard::before,
.fullscreen-background.AdminOrders::before, .fullscreen-background.AdminOrders::before {
.fullscreen-background.AdminUsers::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 50%; top: 50%;

View File

@@ -13,8 +13,9 @@ export const getMonthlyRevenue = (year = '2024') => {
} }
// 获取用户转化率数据 // 获取用户转化率数据
export const getConversionRate = () => { export const getConversionRate = (year = null) => {
return api.get('/dashboard/conversion-rate') const params = year ? { year } : {}
return api.get('/dashboard/conversion-rate', { params })
} }
// 获取最近订单数据 // 获取最近订单数据
@@ -28,3 +29,10 @@ export const getRecentOrders = (limit = 10) => {
export const getSystemStatus = () => { export const getSystemStatus = () => {
return api.get('/dashboard/system-status') return api.get('/dashboard/system-status')
} }
// 获取日活用户趋势数据
export const getDailyActiveUsersTrend = (year = '2024', granularity = 'monthly') => {
return api.get('/analytics/daily-active-users', {
params: { year, granularity }
})
}

View File

@@ -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'
})
},
/** /**
* 轮询任务状态 * 轮询任务状态

View File

@@ -24,3 +24,13 @@ export const deleteMembers = (ids) => {
export const getMemberDetail = (id) => { export const getMemberDetail = (id) => {
return api.get(`/members/${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)
}

View File

@@ -42,9 +42,9 @@ export const createOrderPayment = (id, paymentMethod) => {
}) })
} }
// 管理员订单API // 管理员订单API(使用普通订单接口,后端会根据用户角色返回相应数据)
export const getAdminOrders = (params) => { export const getAdminOrders = (params) => {
return api.get('/orders/admin', { params }) return api.get('/orders', { params })
} }
// 订单统计API // 订单统计API

View 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')
}

View File

@@ -20,3 +20,10 @@ export const getStoryboardTask = async (taskId) => {
export const getUserStoryboardTasks = async (page = 0, size = 10) => { export const getUserStoryboardTasks = async (page = 0, size = 10) => {
return api.get('/storyboard-video/tasks', { params: { page, size } }) return api.get('/storyboard-video/tasks', { params: { page, size } })
} }
/**
* 开始生成视频(从分镜图生成视频)
*/
export const startVideoGeneration = async (taskId) => {
return api.post(`/storyboard-video/task/${taskId}/start-video`)
}

View File

@@ -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'
})
},
/** /**
* 轮询任务状态 * 轮询任务状态

View File

@@ -1,10 +1,8 @@
import request from './request' import api from './request'
// 获取我的作品列表 // 获取我的作品列表
export const getMyWorks = (params = {}) => { export const getMyWorks = (params = {}) => {
return request({ return api.get('/works/my-works', {
url: '/works/my-works',
method: 'GET',
params: { params: {
page: params.page || 0, page: params.page || 0,
size: params.size || 10 size: params.size || 10
@@ -14,46 +12,29 @@ export const getMyWorks = (params = {}) => {
// 获取作品详情 // 获取作品详情
export const getWorkDetail = (workId) => { export const getWorkDetail = (workId) => {
return request({ return api.get(`/works/${workId}`)
url: `/works/${workId}`,
method: 'GET'
})
} }
// 删除作品 // 删除作品
export const deleteWork = (workId) => { export const deleteWork = (workId) => {
return request({ return api.delete(`/works/${workId}`)
url: `/works/${workId}`,
method: 'DELETE'
})
} }
// 批量删除作品 // 批量删除作品
export const batchDeleteWorks = (workIds) => { export const batchDeleteWorks = (workIds) => {
return request({ return api.post('/works/batch-delete', {
url: '/works/batch-delete', workIds: workIds
method: 'POST',
data: {
workIds: workIds
}
}) })
} }
// 更新作品信息 // 更新作品信息
export const updateWork = (workId, data) => { export const updateWork = (workId, data) => {
return request({ return api.put(`/works/${workId}`, data)
url: `/works/${workId}`,
method: 'PUT',
data: data
})
} }
// 获取作品统计信息 // 获取作品统计信息
export const getWorkStats = () => { export const getWorkStats = () => {
return request({ return api.get('/works/stats')
url: '/works/stats',
method: 'GET'
})
} }
@@ -65,3 +46,6 @@ export const getWorkStats = () => {

View File

@@ -98,6 +98,9 @@

View File

@@ -42,10 +42,6 @@
<span>后台管理</span> <span>后台管理</span>
</el-menu-item> </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"> <el-menu-item v-if="userStore.isAdmin" index="/admin/dashboard">
<span>数据仪表盘</span> <span>数据仪表盘</span>
</el-menu-item> </el-menu-item>

View File

@@ -12,7 +12,6 @@ const OrderCreate = () => import('@/views/OrderCreate.vue')
const Payments = () => import('@/views/Payments.vue') const Payments = () => import('@/views/Payments.vue')
const PaymentCreate = () => import('@/views/PaymentCreate.vue') const PaymentCreate = () => import('@/views/PaymentCreate.vue')
const AdminOrders = () => import('@/views/AdminOrders.vue') const AdminOrders = () => import('@/views/AdminOrders.vue')
const AdminUsers = () => import('@/views/AdminUsers.vue')
const AdminDashboard = () => import('@/views/AdminDashboard.vue') const AdminDashboard = () => import('@/views/AdminDashboard.vue')
const Dashboard = () => import('@/views/Dashboard.vue') const Dashboard = () => import('@/views/Dashboard.vue')
const Welcome = () => import('@/views/Welcome.vue') const Welcome = () => import('@/views/Welcome.vue')
@@ -24,6 +23,7 @@ const TextToVideo = () => import('@/views/TextToVideo.vue')
const TextToVideoCreate = () => import('@/views/TextToVideoCreate.vue') const TextToVideoCreate = () => import('@/views/TextToVideoCreate.vue')
const ImageToVideo = () => import('@/views/ImageToVideo.vue') const ImageToVideo = () => import('@/views/ImageToVideo.vue')
const ImageToVideoCreate = () => import('@/views/ImageToVideoCreate.vue') const ImageToVideoCreate = () => import('@/views/ImageToVideoCreate.vue')
const ImageToVideoDetail = () => import('@/views/ImageToVideoDetail.vue')
const StoryboardVideo = () => import('@/views/StoryboardVideo.vue') const StoryboardVideo = () => import('@/views/StoryboardVideo.vue')
const StoryboardVideoCreate = () => import('@/views/StoryboardVideoCreate.vue') const StoryboardVideoCreate = () => import('@/views/StoryboardVideoCreate.vue')
const MemberManagement = () => import('@/views/MemberManagement.vue') const MemberManagement = () => import('@/views/MemberManagement.vue')
@@ -75,6 +75,12 @@ const routes = [
component: ImageToVideoCreate, component: ImageToVideoCreate,
meta: { title: '图生视频创作', requiresAuth: true } meta: { title: '图生视频创作', requiresAuth: true }
}, },
{
path: '/image-to-video/detail/:taskId',
name: 'ImageToVideoDetail',
component: ImageToVideoDetail,
meta: { title: '图生视频详情', requiresAuth: true }
},
{ {
path: '/storyboard-video', path: '/storyboard-video',
name: 'StoryboardVideo', name: 'StoryboardVideo',
@@ -164,12 +170,6 @@ const routes = [
component: AdminOrders, component: AdminOrders,
meta: { title: '订单管理', requiresAuth: true, requiresAdmin: true } meta: { title: '订单管理', requiresAuth: true, requiresAdmin: true }
}, },
{
path: '/admin/users',
name: 'AdminUsers',
component: AdminUsers,
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }
},
{ {
path: '/admin/dashboard', path: '/admin/dashboard',
name: 'AdminDashboard', name: 'AdminDashboard',

View File

@@ -51,13 +51,32 @@ export const useOrderStore = defineStore('orders', () => {
loading.value = true loading.value = true
const response = await getOrderById(id) const response = await getOrderById(id)
if (response.success) { console.log('OrderStore: 获取订单详情响应:', response)
currentOrder.value = response.data
// 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) { } catch (error) {
console.error('Fetch order error:', error) console.error('OrderStore: 获取订单详情异常:', error)
return { success: false, message: '获取订单详情失败' } return { success: false, message: error.response?.data?.message || error.message || '获取订单详情失败' }
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -3,7 +3,8 @@
<!-- 左侧导航栏 --> <!-- 左侧导航栏 -->
<aside class="sidebar"> <aside class="sidebar">
<div class="logo"> <div class="logo">
<span class="logo-text">LOGO</span> <div class="logo-icon"></div>
<span>LOGO</span>
</div> </div>
<nav class="nav-menu"> <nav class="nav-menu">
@@ -11,7 +12,7 @@
<el-icon><Grid /></el-icon> <el-icon><Grid /></el-icon>
<span>数据仪表台</span> <span>数据仪表台</span>
</div> </div>
<div class="nav-item" @click="goToUsers"> <div class="nav-item" @click="goToMembers">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>会员管理</span> <span>会员管理</span>
</div> </div>
@@ -19,15 +20,15 @@
<el-icon><ShoppingCart /></el-icon> <el-icon><ShoppingCart /></el-icon>
<span>订单管理</span> <span>订单管理</span>
</div> </div>
<div class="nav-item"> <div class="nav-item" @click="goToAPI">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>API管理</span> <span>API管理</span>
</div> </div>
<div class="nav-item" @click="goToTasks"> <div class="nav-item" @click="goToTasks">
<el-icon><Briefcase /></el-icon> <el-icon><Document /></el-icon>
<span>生成任务记录</span> <span>生成任务记录</span>
</div> </div>
<div class="nav-item"> <div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>系统设置</span> <span>系统设置</span>
</div> </div>
@@ -35,11 +36,10 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="online-users"> <div class="online-users">
<span>当前在线用户: </span> 当前在线用户: <span class="highlight">87/500</span>
<span class="online-count">87/500</span>
</div> </div>
<div class="system-uptime"> <div class="system-uptime">
<span>系统运行时间: 48小时32分</span> 系统运行时间: <span class="highlight">48小时32分</span>
</div> </div>
</div> </div>
</aside> </aside>
@@ -52,28 +52,30 @@
<el-icon class="search-icon"><Search /></el-icon> <el-icon class="search-icon"><Search /></el-icon>
<input type="text" placeholder="搜索你的想要的内容" class="search-input"> <input type="text" placeholder="搜索你的想要的内容" class="search-input">
</div> </div>
<div class="header-right"> <div class="header-actions">
<div class="notification-icon"> <div class="notification-icon-wrapper">
<el-icon><Bell /></el-icon> <el-icon class="notification-icon"><Bell /></el-icon>
<div class="notification-badge"></div> <span class="notification-badge"></span>
</div> </div>
<div class="user-avatar"> <div class="user-avatar">
<el-icon><Avatar /></el-icon> <img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon> <el-icon class="arrow-down"><ArrowDown /></el-icon>
</div> </div>
</div> </div>
</header> </header>
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="stats-cards"> <div class="stats-cards" v-loading="loading">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon users"> <div class="stat-icon users">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-title">用户总数</div> <div class="stat-title">用户总数</div>
<div class="stat-number">12,847</div> <div class="stat-number">{{ formatNumber(stats.totalUsers) }}</div>
<div class="stat-change positive">+12% 较上月同期</div> <div class="stat-change" :class="stats.totalUsersChange >= 0 ? 'positive' : 'negative'">
{{ stats.totalUsersChange >= 0 ? '+' : '' }}{{ stats.totalUsersChange }}% 较上月同期
</div>
</div> </div>
</div> </div>
@@ -83,8 +85,10 @@
</div> </div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-title">付费用户数</div> <div class="stat-title">付费用户数</div>
<div class="stat-number">3,215</div> <div class="stat-number">{{ formatNumber(stats.paidUsers) }}</div>
<div class="stat-change negative">-5% 较上月同期</div> <div class="stat-change" :class="stats.paidUsersChange >= 0 ? 'positive' : 'negative'">
{{ stats.paidUsersChange >= 0 ? '+' : '' }}{{ stats.paidUsersChange }}% 较上月同期
</div>
</div> </div>
</div> </div>
@@ -94,8 +98,10 @@
</div> </div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-title">今日收入</div> <div class="stat-title">今日收入</div>
<div class="stat-number">¥28,450</div> <div class="stat-number">{{ formatCurrency(stats.todayRevenue) }}</div>
<div class="stat-change positive">+15% 较上月同期</div> <div class="stat-change" :class="stats.todayRevenueChange >= 0 ? 'positive' : 'negative'">
{{ stats.todayRevenueChange >= 0 ? '+' : '' }}{{ stats.todayRevenueChange }}% 较上月同期
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -106,16 +112,14 @@
<div class="chart-card"> <div class="chart-card">
<div class="chart-header"> <div class="chart-header">
<h3>日活用户趋势</h3> <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="2025年" value="2025"></el-option>
<el-option label="2024年" value="2024"></el-option> <el-option label="2024年" value="2024"></el-option>
<el-option label="2023年" value="2023"></el-option>
</el-select> </el-select>
</div> </div>
<div class="chart-content"> <div class="chart-content">
<div class="chart-placeholder"> <div ref="dailyActiveChart" style="width: 100%; height: 100%;"></div>
<div class="chart-title">日活用户趋势图</div>
<div class="chart-description">显示每日活跃用户数量变化趋势</div>
</div>
</div> </div>
</div> </div>
@@ -123,16 +127,14 @@
<div class="chart-card"> <div class="chart-card">
<div class="chart-header"> <div class="chart-header">
<h3>用户转化率</h3> <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="2025年" value="2025"></el-option>
<el-option label="2024年" value="2024"></el-option> <el-option label="2024年" value="2024"></el-option>
<el-option label="2023年" value="2023"></el-option>
</el-select> </el-select>
</div> </div>
<div class="chart-content"> <div class="chart-content">
<div class="chart-placeholder"> <div ref="conversionChart" style="width: 100%; height: 100%;"></div>
<div class="chart-title">用户转化率图</div>
<div class="chart-description">显示各月份用户转化率情况</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -141,7 +143,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
@@ -157,6 +159,7 @@ import {
ArrowDown, ArrowDown,
Money Money
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend } from '@/api/dashboard'
const router = useRouter() const router = useRouter()
@@ -164,22 +167,311 @@ const router = useRouter()
const selectedYear = ref('2025') const selectedYear = ref('2025')
const selectedYear2 = 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 = () => { const goToMembers = () => {
router.push('/admin/users') router.push('/member-management')
} }
const goToOrders = () => { const goToOrders = () => {
router.push('/admin/orders') router.push('/admin/orders')
} }
const goToAPI = () => {
router.push('/api-management')
}
const goToTasks = () => { const goToTasks = () => {
router.push('/generate-task-record') 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('后台管理页面加载完成') console.log('后台管理页面加载完成')
await loadDashboardData()
await nextTick()
await loadDailyActiveChart()
await loadConversionChart()
})
// 组件卸载时清理图表
onUnmounted(() => {
if (dailyActiveChartInstance) {
dailyActiveChartInstance.dispose()
dailyActiveChartInstance = null
}
if (conversionChartInstance) {
conversionChartInstance.dispose()
conversionChartInstance = null
}
}) })
</script> </script>
@@ -194,34 +486,45 @@ onMounted(() => {
/* 左侧导航栏 */ /* 左侧导航栏 */
.sidebar { .sidebar {
width: 240px; width: 240px;
background: #ffffff; background: white;
border-right: 1px solid #e9ecef; border-right: 1px solid #e9ecef;
display: flex; display: flex;
flex-direction: column; 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 { .logo {
padding: 24px 20px; display: flex;
border-bottom: 1px solid #e9ecef; align-items: center;
padding: 0 20px;
margin-bottom: 32px;
} }
.logo-text { .logo-icon {
font-size: 20px; width: 24px;
font-weight: bold; height: 24px;
color: #3b82f6; background: #3b82f6;
border-radius: 4px;
margin-right: 12px;
}
.logo span {
font-size: 18px;
font-weight: 600;
color: #1e293b;
} }
.nav-menu { .nav-menu {
flex: 1; flex: 1;
padding: 20px 0; padding: 0 16px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 20px; padding: 12px 16px;
margin: 4px 16px; margin-bottom: 4px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -242,19 +545,32 @@ onMounted(() => {
.nav-item .el-icon { .nav-item .el-icon {
margin-right: 12px; margin-right: 12px;
font-size: 16px; font-size: 18px;
}
.nav-item span {
font-size: 14px;
font-weight: 500;
} }
.sidebar-footer { .sidebar-footer {
padding: 20px; padding: 20px;
border-top: 1px solid #e9ecef; border-top: 1px solid #e9ecef;
background: #f8f9fa; background: #f8f9fa;
margin-top: auto;
} }
.online-users { .online-users,
margin-bottom: 8px; .system-uptime {
font-size: 13px; font-size: 14px;
color: #6b7280; color: #64748b;
margin-bottom: 5px;
line-height: 1.5;
}
.highlight {
color: #333;
font-weight: bold;
} }
.online-count { .online-count {
@@ -318,24 +634,29 @@ onMounted(() => {
color: #9ca3af; color: #9ca3af;
} }
.header-right { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 20px;
} }
.notification-icon { .notification-icon-wrapper {
position: relative; position: relative;
padding: 8px;
cursor: pointer; cursor: pointer;
padding: 8px;
border-radius: 6px; border-radius: 6px;
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.notification-icon:hover { .notification-icon-wrapper:hover {
background: #f3f4f6; background: #f3f4f6;
} }
.notification-icon {
font-size: 20px;
color: #6b7280;
}
.notification-badge { .notification-badge {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -350,8 +671,8 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px;
cursor: pointer; cursor: pointer;
padding: 4px 8px;
border-radius: 6px; border-radius: 6px;
transition: background 0.2s ease; transition: background 0.2s ease;
} }
@@ -360,7 +681,14 @@ onMounted(() => {
background: #f3f4f6; background: #f3f4f6;
} }
.arrow-down { .user-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.user-avatar .arrow-down {
font-size: 12px; font-size: 12px;
color: #6b7280; color: #6b7280;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -1,18 +1,539 @@
<template> <template>
<div class="api-management"> <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> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' 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 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> </script>
<style scoped> <style scoped>
.api-management { .api-management {
display: flex;
min-height: 100vh;
background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 左侧导航栏 */
.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; 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> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -57,11 +57,30 @@
</div> </div>
<div class="works-grid"> <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"> <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="work-overlay">
<div class="overlay-text">{{ work.text }}</div> <div class="overlay-text">{{ work.prompt || work.text || '图生视频' }}</div>
</div> </div>
<!-- 鼠标悬停时显示的做同款按钮 --> <!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)"> <div class="hover-create-btn" @click.stop="goToCreate(work)">
@@ -72,8 +91,8 @@
</div> </div>
</div> </div>
<div class="work-info"> <div class="work-info">
<div class="work-title">{{ work.title }}</div> <div class="work-title">{{ work.prompt || work.title || '图生视频' }}</div>
<div class="work-meta">{{ work.id }} · {{ work.size }}</div> <div class="work-meta">{{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
</div> </div>
<div class="work-actions" v-if="index === 0"> <div class="work-actions" v-if="index === 0">
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">做同款</el-button> <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 { useRouter } from 'vue-router'
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus' import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue' import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
const router = useRouter() const router = useRouter()
@@ -157,35 +177,7 @@ const detailDialogVisible = ref(false)
const selectedItem = ref(null) const selectedItem = ref(null)
// 已发布作品数据 // 已发布作品数据
const publishedWorks = ref([ 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 goToProfile = () => { const goToProfile = () => {
@@ -235,8 +227,63 @@ const createSimilar = () => {
router.push('/image-to-video/create') 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(() => { onMounted(() => {
// 页面初始化 // 页面初始化时加载任务列表
loadTasks()
}) })
</script> </script>
@@ -461,12 +508,45 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
.work-thumbnail img { .work-thumbnail img,
.work-thumbnail video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; 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 { .hover-create-btn {
position: absolute; position: absolute;

View File

@@ -91,10 +91,7 @@
<div class="setting-item"> <div class="setting-item">
<label>时长</label> <label>时长</label>
<select v-model="duration" class="setting-select"> <select v-model="duration" class="setting-select">
<option value="5">5s</option>
<option value="10">10s</option> <option value="10">10s</option>
<option value="15">15s</option>
<option value="30">30s</option>
</select> </select>
</div> </div>
@@ -154,7 +151,7 @@
<!-- 视频播放区域 --> <!-- 视频播放区域 -->
<div class="video-player-container"> <div class="video-player-container">
<div class="video-player"> <div class="video-player" :style="getVideoPlayerStyle()">
<video <video
v-if="currentTask.resultUrl" v-if="currentTask.resultUrl"
:src="currentTask.resultUrl" :src="currentTask.resultUrl"
@@ -219,10 +216,6 @@
</div> </div>
</div> </div>
<!-- 任务控制 -->
<div class="task-controls" v-if="inProgress">
<button class="cancel-btn" @click="cancelTask">取消任务</button>
</div>
</div> </div>
<!-- 默认提示 --> <!-- 默认提示 -->
@@ -285,7 +278,7 @@ const userStore = useUserStore()
// 表单数据 // 表单数据
const inputText = ref('') const inputText = ref('')
const aspectRatio = ref('16:9') const aspectRatio = ref('16:9')
const duration = ref('5') const duration = ref('10')
const hdMode = ref(false) const hdMode = ref(false)
const inProgress = ref(false) const inProgress = ref(false)
@@ -331,7 +324,7 @@ const goToTextToVideo = () => {
} }
const goToStoryboard = () => { const goToStoryboard = () => {
alert('分镜视频功能开发中') router.push('/storyboard-video/create')
} }
// 用户菜单相关方法 // 用户菜单相关方法
@@ -373,9 +366,10 @@ const uploadFirstFrame = () => {
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file) { if (file) {
// 验证文件大小最大10MB // 验证文件大小最大100MB,与后端配置保持一致
if (file.size > 10 * 1024 * 1024) { const maxFileSize = 100 * 1024 * 1024 // 100MB
ElMessage.error('图片文件大小不能超过10MB') if (file.size > maxFileSize) {
ElMessage.error('图片文件大小不能超过100MB')
return return
} }
@@ -403,9 +397,10 @@ const uploadLastFrame = () => {
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file) { if (file) {
// 验证文件大小最大10MB // 验证文件大小最大100MB,与后端配置保持一致
if (file.size > 10 * 1024 * 1024) { const maxFileSize = 100 * 1024 * 1024 // 100MB
ElMessage.error('图片文件大小不能超过10MB') if (file.size > maxFileSize) {
ElMessage.error('图片文件大小不能超过100MB')
return return
} }
@@ -519,6 +514,13 @@ const startPollingTask = () => {
if (progressData && progressData.status) { if (progressData && progressData.status) {
taskStatus.value = 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) console.log('任务进度:', progressData)
}, },
// 完成回调 // 完成回调
@@ -526,6 +528,15 @@ const startPollingTask = () => {
inProgress.value = false inProgress.value = false
taskProgress.value = 100 taskProgress.value = 100
taskStatus.value = 'COMPLETED' 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('视频生成完成!') 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) => { const getStatusText = (status) => {
@@ -604,6 +589,21 @@ const formatDate = (dateString) => {
return `${year}${month}${day}${hours}:${minutes}` 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 () => { const optimizePromptHandler = async () => {
if (!inputText.value.trim()) { if (!inputText.value.trim()) {
@@ -1404,22 +1404,6 @@ onUnmounted(() => {
gap: 12px; 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 { .task-description {
@@ -1528,10 +1512,13 @@ onUnmounted(() => {
.video-player { .video-player {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; /* height 由 aspect-ratio 动态计算 */
background: #1a1a1a; background: #1a1a1a;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.result-video { .result-video {
@@ -1539,6 +1526,7 @@ onUnmounted(() => {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
border-radius: 12px; border-radius: 12px;
display: block;
} }
.no-video-placeholder { .no-video-placeholder {

View File

@@ -147,14 +147,16 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { imageToVideoApi } from '@/api/imageToVideo'
import { import {
User, Setting, Bell, Document, User as Picture, User as VideoPlay, User as VideoPause, 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 User as FullScreen, User as Share, User as Download, User as Delete, User as ArrowUp, User as ArrowDown
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const videoRef = ref(null) const videoRef = ref(null)
// 视频播放状态 // 视频播放状态
@@ -162,17 +164,20 @@ const isPlaying = ref(false)
const currentTime = ref(0) const currentTime = ref(0)
const duration = ref(0) const duration = ref(0)
const showControls = ref(true) const showControls = ref(true)
const loading = ref(true)
// 详情数据 // 详情数据
const detailInput = ref('') const detailInput = ref('')
const videoData = ref({ const videoData = ref({
id: '2995697841305810', id: '',
videoUrl: '/images/backgrounds/welcome.jpg', // 临时使用图片实际应该是视频URL videoUrl: '',
description: '图1在图2中奔跑视频', description: '',
createTime: '2025/10/17 13:41', createTime: '',
duration: 5, duration: 5,
resolution: '1080p', resolution: '1080p',
aspectRatio: '16:9' aspectRatio: '16:9',
status: 'PROCESSING',
progress: 0
}) })
const thumbnails = ref([ const thumbnails = ref([
@@ -254,7 +259,54 @@ const resetControlsTimer = () => {
}, 3000) }, 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(() => { onMounted(() => {
// 加载任务详情
loadTaskDetail()
// 监听鼠标移动来显示/隐藏控制栏 // 监听鼠标移动来显示/隐藏控制栏
document.addEventListener('mousemove', resetControlsTimer) document.addEventListener('mousemove', resetControlsTimer)
resetControlsTimer() resetControlsTimer()

View File

@@ -48,13 +48,16 @@
<header class="top-header"> <header class="top-header">
<div class="search-bar"> <div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon> <el-icon class="search-icon"><Search /></el-icon>
<input type="text" placeholder="搜索你想要的内容" class="search-input" /> <input type="text" placeholder="搜索你想要的内容" class="search-input" />
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-icon class="notification-icon"><Bell /></el-icon> <div class="notification-icon-wrapper">
<el-icon class="help-icon"><QuestionFilled /></el-icon> <el-icon class="notification-icon"><Bell /></el-icon>
<span class="notification-badge"></span>
</div>
<div class="user-avatar"> <div class="user-avatar">
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" /> <img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div> </div>
</div> </div>
</header> </header>
@@ -97,7 +100,6 @@
<th>剩余资源点</th> <th>剩余资源点</th>
<th>到期时间</th> <th>到期时间</th>
<th>编辑</th> <th>编辑</th>
<th>删除</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -118,10 +120,8 @@
<td>{{ member.points.toLocaleString() }}</td> <td>{{ member.points.toLocaleString() }}</td>
<td>{{ member.expiryDate }}</td> <td>{{ member.expiryDate }}</td>
<td> <td>
<button class="action-btn edit-btn" @click="editMember(member)">编辑</button> <el-link type="primary" class="action-link" @click="editMember(member)">编辑</el-link>
</td> <el-link type="danger" class="action-link" @click="deleteMember(member)">删除</el-link>
<td>
<button class="action-btn delete-btn" @click="deleteMember(member)">删除</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -131,7 +131,7 @@
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-container"> <div class="pagination-container">
<div class="pagination"> <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 <button
v-for="page in visiblePages" v-for="page in visiblePages"
:key="page" :key="page"
@@ -140,7 +140,16 @@
@click="goToPage(page)"> @click="goToPage(page)">
{{ page }} {{ page }}
</button> </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>
</div> </div>
</section> </section>
@@ -205,11 +214,12 @@ import {
ShoppingCart, ShoppingCart,
Document, Document,
Setting, Setting,
User as Search, Search,
Bell, Bell,
User as ArrowDown, ArrowDown,
User as Edit, ArrowLeft,
User as Delete ArrowRight,
Delete
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import * as memberAPI from '@/api/members' import * as memberAPI from '@/api/members'
@@ -257,11 +267,11 @@ const memberList = ref([])
// 导航功能 // 导航功能
const goToDashboard = () => { const goToDashboard = () => {
router.push('/') router.push('/admin/dashboard')
} }
const goToOrders = () => { const goToOrders = () => {
router.push('/orders') router.push('/admin/orders')
} }
const goToAPI = () => { const goToAPI = () => {
@@ -287,11 +297,32 @@ const totalPages = computed(() => {
const visiblePages = computed(() => { const visiblePages = computed(() => {
const pages = [] const pages = []
const start = Math.max(1, currentPage.value - 2) const total = totalPages.value
const end = Math.min(totalPages.value, start + 4) const current = currentPage.value
for (let i = start; i <= end; i++) { if (total <= 7) {
pages.push(i) // 如果总页数少于等于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 return pages
}) })
@@ -466,22 +497,29 @@ const loadMembers = async () => {
level: selectedLevel.value === 'all' ? '' : selectedLevel.value level: selectedLevel.value === 'all' ? '' : selectedLevel.value
}) })
// 处理API响应数据 console.log('获取会员列表响应:', response)
if (response && response.list) {
memberList.value = response.list.map(member => ({ // 处理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, id: member.id,
username: member.username, username: member.username,
level: getMembershipLevel(member.membership), level: getMembershipLevel(member.membership),
points: member.points, points: member.points || 0,
expiryDate: getMembershipExpiry(member.membership) expiryDate: getMembershipExpiry(member.membership)
})) }))
totalMembers.value = response.total || 0 totalMembers.value = data.total || 0
console.log('设置后的会员列表:', memberList.value)
} else { } else {
console.error('API返回数据格式错误:', data)
ElMessage.error('API返回数据格式错误') ElMessage.error('API返回数据格式错误')
} }
} catch (error) { } catch (error) {
console.error('加载会员数据失败:', error) console.error('加载会员数据失败:', error)
ElMessage.error('加载会员数据失败') ElMessage.error('加载会员数据失败: ' + (error.message || '未知错误'))
} }
} }
@@ -507,24 +545,25 @@ onMounted(() => {
.member-management { .member-management {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
background: #f8fafc; background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} }
/* 左侧导航栏 */ /* 左侧导航栏 */
.sidebar { .sidebar {
width: 320px; width: 240px;
background: white; background: white;
border-right: 1px solid #e2e8f0; border-right: 1px solid #e9ecef;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 24px 0; padding: 24px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
} }
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 28px; padding: 0 20px;
margin-bottom: 32px; margin-bottom: 32px;
} }
@@ -544,58 +583,60 @@ onMounted(() => {
.nav-menu { .nav-menu {
flex: 1; flex: 1;
padding: 0 24px; padding: 0 16px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 18px 24px; padding: 12px 16px;
margin-bottom: 6px; margin-bottom: 4px;
border-radius: 10px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
color: #64748b; color: #6b7280;
font-size: 16px; font-size: 14px;
font-weight: 500;
} }
.nav-item:hover { .nav-item:hover {
background: #f1f5f9; background: #f3f4f6;
color: #334155; color: #374151;
} }
.nav-item.active { .nav-item.active {
background: #eff6ff; background: #dbeafe;
color: #3b82f6; color: #3b82f6;
} }
.nav-item .el-icon { .nav-item .el-icon {
margin-right: 16px; margin-right: 12px;
font-size: 22px; font-size: 18px;
} }
.nav-item span { .nav-item span {
font-size: 16px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
.sidebar-footer { .sidebar-footer {
padding: 0 32px 20px; padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
margin-top: auto; margin-top: auto;
} }
.online-users, .online-users,
.system-uptime { .system-uptime {
font-size: 14px; font-size: 13px;
color: #64748b; color: #6b7280;
margin-bottom: 10px; margin-bottom: 8px;
line-height: 1.5; line-height: 1.5;
} }
.highlight { .highlight {
color: #3b82f6; color: #3b82f6;
font-weight: 600; font-weight: 600;
font-size: 15px;
} }
/* 主内容区域 */ /* 主内容区域 */
@@ -603,17 +644,18 @@ onMounted(() => {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #f8fafc; background: #f8f9fa;
} }
/* 顶部搜索栏 */ /* 顶部搜索栏 */
.top-header { .top-header {
background: white; background: white;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #e9ecef;
padding: 16px 24px; padding: 16px 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
} }
.search-bar { .search-bar {
@@ -625,46 +667,77 @@ onMounted(() => {
.search-icon { .search-icon {
position: absolute; position: absolute;
left: 12px; left: 12px;
color: #94a3b8; color: #9ca3af;
font-size: 16px; font-size: 16px;
z-index: 1;
} }
.search-input { .search-input {
width: 300px; width: 300px;
padding: 8px 12px 8px 40px; padding: 10px 12px 10px 40px;
border: 1px solid #e2e8f0; border: 1px solid #d1d5db;
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
background: #f8fafc; background: white;
outline: none; outline: none;
transition: border-color 0.2s ease;
} }
.search-input:focus { .search-input:focus {
border-color: #3b82f6; border-color: #3b82f6;
background: white; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
.search-input::placeholder { .search-input::placeholder {
color: #94a3b8; color: #9ca3af;
} }
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 20px;
} }
.notification-icon, .notification-icon-wrapper {
.help-icon { position: relative;
font-size: 20px;
color: #64748b;
cursor: pointer; 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 { .user-avatar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
cursor: pointer; cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.user-avatar:hover {
background: #f3f4f6;
} }
.user-avatar img { .user-avatar img {
@@ -674,10 +747,18 @@ onMounted(() => {
object-fit: cover; object-fit: cover;
} }
.user-avatar .arrow-down {
font-size: 12px;
color: #6b7280;
}
/* 会员内容区域 */ /* 会员内容区域 */
.member-content { .member-content {
padding: 24px; padding: 24px;
flex: 1; flex: 1;
background: white;
margin: 24px;
border-radius: 8px;
} }
.content-header { .content-header {
@@ -719,7 +800,7 @@ onMounted(() => {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); border: 1px solid #e5e7eb;
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -730,7 +811,7 @@ onMounted(() => {
} }
.member-table thead { .member-table thead {
background: #f8fafc; background: #f9fafb;
} }
.member-table th { .member-table th {
@@ -772,53 +853,43 @@ onMounted(() => {
} }
.level-tag.professional { .level-tag.professional {
background: #ec4899; background: #8b5cf6;
color: white;
} }
.level-tag.standard { .level-tag.standard {
background: #3b82f6; background: #3b82f6;
color: white;
} }
.action-btn { .action-link {
background: none; margin-right: 12px;
border: none;
cursor: pointer;
font-size: 14px; font-size: 14px;
padding: 4px 8px; text-decoration: none;
border-radius: 4px;
transition: all 0.2s ease;
} }
.edit-btn { .action-link:last-child {
color: #3b82f6; margin-right: 0;
}
.edit-btn:hover {
background: #eff6ff;
}
.delete-btn {
color: #dc2626;
}
.delete-btn:hover {
background: #fef2f2;
} }
.pagination-container { .pagination-container {
display: flex; display: flex;
justify-content: center; justify-content: flex-end;
margin-top: 24px; margin-top: 24px;
} }
.pagination { .pagination {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
} }
.page-btn { .page-arrow {
padding: 8px 12px; width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
background: white; background: white;
color: #374151; color: #374151;
@@ -828,6 +899,32 @@ onMounted(() => {
transition: all 0.2s ease; 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) { .page-btn:hover:not(:disabled) {
background: #f3f4f6; background: #f3f4f6;
border-color: #9ca3af; border-color: #9ca3af;
@@ -844,6 +941,12 @@ onMounted(() => {
cursor: not-allowed; cursor: not-allowed;
} }
.page-ellipsis {
padding: 0 8px;
color: #6b7280;
font-size: 14px;
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.member-management { .member-management {

View File

@@ -51,7 +51,7 @@
<div class="header-right"> <div class="header-right">
<div class="points"> <div class="points">
<el-icon><Star /></el-icon> <el-icon><Star /></el-icon>
<span>25 | 首购优惠</span> <span>{{ userInfo.points - (userInfo.frozenPoints || 0) }} | 首购优惠</span>
</div> </div>
<div class="notifications"> <div class="notifications">
<el-icon><Bell /></el-icon> <el-icon><Bell /></el-icon>
@@ -67,12 +67,13 @@
<section class="profile-section"> <section class="profile-section">
<div class="profile-info"> <div class="profile-info">
<div class="avatar"> <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>
<div class="user-details"> <div class="user-details">
<h2 class="username">mingzi_FBx7foZYDS7inLQb</h2> <h2 class="username">{{ userInfo.nickname || userInfo.username || '未设置用户名' }}</h2>
<p class="profile-status">还没有设置个人简介,点击填写</p> <p class="profile-status" v-if="userInfo.bio">{{ userInfo.bio }}</p>
<p class="user-id">ID 2994509784706419</p> <p class="user-id">ID {{ userInfo.id || '加载中...' }}</p>
</div> </div>
</div> </div>
</section> </section>
@@ -166,6 +167,7 @@ import {
Film Film
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getMyWorks } from '@/api/userWorks' import { getMyWorks } from '@/api/userWorks'
import { getCurrentUser } from '@/api/auth'
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
@@ -174,6 +176,18 @@ const userStore = useUserStore()
const showUserMenu = ref(false) const showUserMenu = ref(false)
const userStatusRef = ref(null) const userStatusRef = ref(null)
// 用户信息
const userInfo = ref({
username: '',
nickname: '',
bio: '',
avatar: '',
id: '',
points: 0,
frozenPoints: 0
})
const userLoading = ref(false)
// 视频数据 // 视频数据
const videos = ref([]) const videos = ref([])
const loading = ref(false) 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 () => { const loadVideos = async () => {
loading.value = true loading.value = true
@@ -296,21 +341,31 @@ const loadVideos = async () => {
page: 0, page: 0,
size: 6 // 只加载前6个作品 size: 6 // 只加载前6个作品
}) })
console.log('获取作品列表响应:', response)
if (response.data.success) { if (response && response.data && response.data.success) {
const data = response.data.data || [] const data = response.data.data || []
console.log('作品数据:', data)
// 转换数据格式 // 转换数据格式
videos.value = data.map(transformWorkData) videos.value = data.map(transformWorkData)
console.log('转换后的作品列表:', videos.value)
} else { } else {
console.error('获取作品列表失败:', response.data.message) console.error('获取作品列表失败:', response?.data?.message || '未知错误')
} }
} catch (error) { } catch (error) {
console.error('加载作品列表失败:', error) console.error('加载作品列表失败:', error)
ElMessage.error('加载作品列表失败: ' + (error.message || '未知错误'))
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// 编辑个人资料
const editProfile = () => {
// TODO: 可以跳转到编辑页面或打开编辑对话框
ElMessage.info('个人简介编辑功能待实现')
}
// 点击外部关闭菜单 // 点击外部关闭菜单
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
const userStatus = event.target.closest('.user-status') const userStatus = event.target.closest('.user-status')
@@ -343,6 +398,7 @@ const onVideoLoaded = (event) => {
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
loadUserInfo()
loadVideos() loadVideos()
}) })
@@ -642,6 +698,13 @@ onUnmounted(() => {
border-radius: 2px; border-radius: 2px;
} }
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-details { .user-details {
flex: 1; flex: 1;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -111,7 +111,7 @@
<div class="package-header"> <div class="package-header">
<h4 class="package-title">免费版</h4> <h4 class="package-title">免费版</h4>
</div> </div>
<div class="package-price">$0/</div> <div class="package-price">${{ membershipPrices.free }}/</div>
<button class="package-button current">当前套餐</button> <button class="package-button current">当前套餐</button>
<div class="package-features"> <div class="package-features">
<div class="feature-item"> <div class="feature-item">
@@ -127,7 +127,7 @@
<h4 class="package-title">标准版</h4> <h4 class="package-title">标准版</h4>
<div class="discount-tag">首购低至8.5</div> <div class="discount-tag">首购低至8.5</div>
</div> </div>
<div class="package-price">$59/</div> <div class="package-price">${{ membershipPrices.standard }}/</div>
<div class="points-box">每月200积分</div> <div class="points-box">每月200积分</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">立即订阅</button> <button class="package-button subscribe" @click.stop="handleSubscribe('standard')">立即订阅</button>
<div class="package-features"> <div class="package-features">
@@ -152,7 +152,7 @@
<h4 class="package-title">专业版</h4> <h4 class="package-title">专业版</h4>
<div class="value-tag">超值之选</div> <div class="value-tag">超值之选</div>
</div> </div>
<div class="package-price">$259/</div> <div class="package-price">${{ membershipPrices.premium }}/</div>
<div class="points-box">每月1000积分</div> <div class="points-box">每月1000积分</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">立即订阅</button> <button class="package-button premium" @click.stop="handleSubscribe('premium')">立即订阅</button>
<div class="package-features"> <div class="package-features">
@@ -180,46 +180,55 @@
</main> </main>
</div> </div>
<!-- 订单详情模态框 --> <!-- 积分详情模态框 -->
<el-dialog <el-dialog
v-model="orderDialogVisible" v-model="pointsHistoryDialogVisible"
title="订单详情" title="积分使用情况"
width="80%" width="80%"
class="order-dialog" class="points-history-dialog"
:modal="true" :modal="true"
:close-on-click-modal="true" :close-on-click-modal="true"
:close-on-press-escape="true" :close-on-press-escape="true"
@close="handleOrderDialogClose" @close="handlePointsHistoryDialogClose"
> >
<div class="order-content"> <div class="points-history-content">
<div class="order-summary"> <div class="points-summary">
<h3>账户订单总览</h3> <h3>积分使用总览</h3>
<div class="summary-stats"> <div class="summary-stats">
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">订单数</span> <span class="stat-label">充值</span>
<span class="stat-value">{{ orders.length }}</span> <span class="stat-value positive">+{{ totalRecharge || 0 }}</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">金额</span> <span class="stat-label">消耗</span>
<span class="stat-value">¥{{ totalAmount }}</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>
</div> </div>
<div class="orders-list"> <div class="points-history-list" v-loading="pointsHistoryLoading">
<div class="order-item" v-for="order in orders" :key="order.id"> <div v-if="pointsHistory.length === 0 && !pointsHistoryLoading" class="empty-history">
<div class="order-header"> <p>暂无积分使用记录</p>
<span class="order-id">订单号{{ order.id }}</span> </div>
<span class="order-status" :class="order.status">{{ order.statusText }}</span> <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>
<div class="order-details"> <div class="history-details">
<div class="order-info"> <div class="history-info">
<p><strong>创建时间</strong>{{ order.createdAt }}</p> <p><strong>描述</strong>{{ item.description }}</p>
<p><strong>订单类型</strong>{{ order.type }}</p> <p><strong>时间</strong>{{ formatDateTime(item.time) }}</p>
<p><strong>金额</strong>¥{{ order.amount }}</p> <p v-if="item.orderNumber"><strong>订单号</strong>{{ item.orderNumber }}</p>
</div> <p v-if="item.taskId"><strong>任务ID</strong>{{ item.taskId }}</p>
<div class="order-actions">
<el-button type="primary" size="small" @click="viewOrderDetail(order)">查看详情</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -245,6 +254,8 @@ import PaymentModal from '@/components/PaymentModal.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments' import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments'
import { getPointsHistory } from '@/api/points'
import { getMembershipLevels } from '@/api/members'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { import {
User, User,
@@ -277,6 +288,13 @@ const subscriptionInfo = ref({
paidAt: null paidAt: null
}) })
// 会员等级价格配置
const membershipPrices = ref({
free: 0,
standard: 59,
premium: 259
})
// 加载用户订阅信息 // 加载用户订阅信息
const loadUserSubscriptionInfo = async () => { const loadUserSubscriptionInfo = async () => {
try { try {
@@ -337,6 +355,8 @@ const loadUserSubscriptionInfo = async () => {
console.log('用户信息加载成功:', userInfo.value) console.log('用户信息加载成功:', userInfo.value)
console.log('订阅信息加载成功:', subscriptionInfo.value) console.log('订阅信息加载成功:', subscriptionInfo.value)
console.log('后端返回的 currentPlan:', data.currentPlan)
console.log('设置后的 subscriptionInfo.currentPlan:', subscriptionInfo.value.currentPlan)
} else { } else {
// 如果响应结构不同尝试直接使用response.data // 如果响应结构不同尝试直接使用response.data
console.warn('响应格式不符合预期尝试直接使用response.data') console.warn('响应格式不符合预期尝试直接使用response.data')
@@ -356,6 +376,8 @@ const loadUserSubscriptionInfo = async () => {
paidAt: data.paidAt || null paidAt: data.paidAt || null
} }
console.log('用户信息加载成功(备用路径):', userInfo.value) console.log('用户信息加载成功(备用路径):', userInfo.value)
console.log('后端返回的 currentPlan备用路径:', data.currentPlan)
console.log('设置后的 subscriptionInfo.currentPlan备用路径:', subscriptionInfo.value.currentPlan)
} else { } else {
console.error('获取用户订阅信息失败: 响应数据为空或格式不正确') console.error('获取用户订阅信息失败: 响应数据为空或格式不正确')
console.error('完整响应:', JSON.stringify(response.data, null, 2)) 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 () => { onMounted(async () => {
// 先加载会员等级价格配置(不需要登录)
await loadMembershipPrices()
// 确保用户store已初始化 // 确保用户store已初始化
if (!userStore.initialized) { if (!userStore.initialized) {
await userStore.init() await userStore.init()
@@ -436,55 +491,72 @@ const goToStoryboardVideo = () => {
router.push('/storyboard-video/create') 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 paymentModalVisible = ref(false)
const currentPaymentData = ref({}) 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(() => { const totalRecharge = computed(() => {
return orders.value.reduce((sum, order) => sum + order.amount, 0).toFixed(2) return pointsHistory.value
.filter(item => item.type === '充值')
.reduce((sum, item) => sum + (item.points || 0), 0)
}) })
// 显示订单详情模态框 const totalConsume = computed(() => {
const goToOrderDetails = () => { return Math.abs(pointsHistory.value
orderDialogVisible.value = true .filter(item => item.type === '消耗')
.reduce((sum, item) => sum + (item.points || 0), 0))
})
// 显示积分详情模态框
const goToOrderDetails = async () => {
pointsHistoryDialogVisible.value = true
await loadPointsHistory()
} }
// 关闭订单模态框 // 加载积分使用历史
const handleOrderDialogClose = () => { const loadPointsHistory = async () => {
orderDialogVisible.value = false 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; } .top-merged-card .row-bottom { grid-template-columns: 1fr; gap: 12px; }
} }
/* 订单详情模态框样式 */ /* 积分详情模态框样式 */
.order-dialog { .points-history-dialog {
background: #1a1a1a; background: #1a1a1a;
color: white; color: white;
} }
.order-content { /* 修改对话框边框为淡蓝色 */
background: #1a1a1a; .points-history-dialog :deep(.el-dialog) {
color: white; 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; margin-bottom: 30px;
padding: 20px; padding: 20px;
background: #2a2a2a; background: #2a2a2a;
border-radius: 8px; border-radius: 8px;
} }
.order-summary h3 { .points-summary h3 {
color: white; color: white;
margin: 0 0 15px 0; margin: 0 0 15px 0;
font-size: 18px; font-size: 18px;
@@ -1108,66 +1192,100 @@ const createSubscriptionOrder = async (planType, planInfo) => {
} }
.stat-value { .stat-value {
color: #60a5fa; font-size: 18px;
font-size: 16px;
font-weight: 600; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 15px;
max-height: 600px;
overflow-y: auto;
} }
.order-item { .empty-history {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.history-item {
background: #2a2a2a; background: #2a2a2a;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
border: 1px solid #333; border: 1px solid #333;
transition: all 0.3s ease;
} }
.order-header { .history-item:hover {
border-color: #444;
transform: translateY(-2px);
}
.history-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 15px; margin-bottom: 15px;
} }
.order-id { .history-type {
color: white;
font-weight: 600;
}
.order-status {
padding: 4px 12px; padding: 4px 12px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
} }
.order-status.completed { .history-type.recharge {
background: #10b981; background: #10b981;
color: white; color: white;
} }
.order-status.pending { .history-type.consume {
background: #f59e0b; background: #ef4444;
color: white; 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.order-info p { .history-info p {
margin: 5px 0; margin: 5px 0;
color: #d1d5db; color: #d1d5db;
font-size: 14px; font-size: 14px;
} }
.order-info strong { .history-info strong {
color: white; color: white;
} }
</style> </style>

View File

@@ -51,10 +51,13 @@
<input type="text" placeholder="搜索你想要的内容" class="search-input"> <input type="text" placeholder="搜索你想要的内容" class="search-input">
</div> </div>
<div class="header-actions"> <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="user-avatar">
<div class="avatar-placeholder"></div> <img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="dropdown-icon"><ArrowDown /></el-icon> <el-icon class="arrow-down"><ArrowDown /></el-icon>
</div> </div>
</div> </div>
</header> </header>
@@ -89,8 +92,8 @@
<h3>{{ level.name }}</h3> <h3>{{ level.name }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="price">${{ level.price }}/</p> <p class="price">${{ level.price || 0 }}/</p>
<p class="description">{{ level.description }}</p> <p class="description">{{ level.description || `包含${level.resourcePoints || 0}资源点/月` }}</p>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<el-button type="primary" @click="editLevel(level)">编辑</el-button> <el-button type="primary" @click="editLevel(level)">编辑</el-button>
@@ -374,7 +377,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
@@ -390,6 +393,7 @@ import {
Refresh Refresh
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import cleanupApi from '@/api/cleanup' import cleanupApi from '@/api/cleanup'
import { getMembershipLevels, updateMembershipLevel } from '@/api/members'
const router = useRouter() const router = useRouter()
@@ -397,11 +401,8 @@ const router = useRouter()
const activeTab = ref('membership') const activeTab = ref('membership')
// 会员收费标准相关 // 会员收费标准相关
const membershipLevels = ref([ const membershipLevels = ref([])
{ id: 1, name: '免费版会员', price: '0', resourcePoints: 200, description: '包含200资源点/月' }, const loadingLevels = ref(false)
{ id: 2, name: '标准版会员', price: '50', resourcePoints: 500, description: '包含500资源点/月' },
{ id: 3, name: '专业版会员', price: '250', resourcePoints: 2000, description: '包含2000资源点/月' }
])
const editDialogVisible = ref(false) const editDialogVisible = ref(false)
const editFormRef = ref(null) const editFormRef = ref(null)
@@ -473,7 +474,12 @@ const goToSettings = () => {
} }
const editLevel = (level) => { 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 editDialogVisible.value = true
} }
@@ -491,13 +497,96 @@ const handlePriceInput = (value) => {
const saveEdit = async () => { const saveEdit = async () => {
const valid = await editFormRef.value.validate() 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) const index = membershipLevels.value.findIndex(level => level.id === editForm.id)
if (index !== -1) { if (index !== -1) {
Object.assign(membershipLevels.value[index], editForm) membershipLevels.value[index].price = parseFloat(editForm.price)
ElMessage.success('会员等级更新成功') membershipLevels.value[index].pointsBonus = parseInt(editForm.resourcePoints)
editDialogVisible.value = false 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> </script>
<style scoped> <style scoped>
@@ -627,19 +719,31 @@ refreshStats()
/* 左侧导航栏 */ /* 左侧导航栏 */
.sidebar { .sidebar {
width: 320px; width: 240px;
background: white; background: white;
border-right: 1px solid #e2e8f0; border-right: 1px solid #e9ecef;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 24px 0; padding: 24px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
} }
.logo { .logo {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 28px; padding: 0 20px;
margin-bottom: 32px; margin-bottom: 32px;
}
.logo-icon {
width: 24px;
height: 24px;
background: #3b82f6;
border-radius: 4px;
margin-right: 12px;
}
.logo span {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
@@ -647,43 +751,46 @@ refreshStats()
.nav-menu { .nav-menu {
flex: 1; flex: 1;
padding: 0 24px; padding: 0 16px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 18px 24px; padding: 12px 16px;
margin-bottom: 6px; margin-bottom: 4px;
border-radius: 10px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
color: #64748b; color: #6b7280;
font-size: 16px; font-size: 14px;
font-weight: 500;
} }
.nav-item:hover { .nav-item:hover {
background: #f1f5f9; background: #f3f4f6;
color: #334155; color: #374151;
} }
.nav-item.active { .nav-item.active {
background: #eff6ff; background: #dbeafe;
color: #3b82f6; color: #3b82f6;
} }
.nav-item .el-icon { .nav-item .el-icon {
margin-right: 16px; margin-right: 12px;
font-size: 22px; font-size: 18px;
} }
.nav-item span { .nav-item span {
font-size: 16px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
.sidebar-footer { .sidebar-footer {
padding: 0 32px 20px; padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
margin-top: auto; margin-top: auto;
} }
@@ -691,13 +798,8 @@ refreshStats()
.system-uptime { .system-uptime {
font-size: 14px; font-size: 14px;
color: #64748b; color: #64748b;
margin-bottom: 10px;
line-height: 1.5;
}
.online-users,
.system-uptime {
margin-bottom: 5px; margin-bottom: 5px;
line-height: 1.5;
} }
.highlight { .highlight {
@@ -748,38 +850,60 @@ refreshStats()
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; 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 { .notification-icon {
font-size: 20px; font-size: 20px;
color: #606266; color: #6b7280;
margin-right: 20px; }
cursor: pointer;
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
} }
.user-avatar { .user-avatar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
cursor: pointer; 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; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
margin-right: 8px; object-fit: cover;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
font-weight: bold;
} }
.dropdown-icon { .user-avatar .arrow-down {
font-size: 12px; font-size: 12px;
color: #909399; color: #6b7280;
} }
.content-section { .content-section {

View File

@@ -57,11 +57,24 @@
</div> </div>
<div class="works-grid"> <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"> <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="work-overlay">
<div class="overlay-text">{{ work.text }}</div> <div class="overlay-text">{{ work.prompt || work.text || '文生视频' }}</div>
</div> </div>
<!-- 鼠标悬停时显示的做同款按钮 --> <!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)"> <div class="hover-create-btn" @click.stop="goToCreate(work)">
@@ -72,8 +85,8 @@
</div> </div>
</div> </div>
<div class="work-info"> <div class="work-info">
<div class="work-title">{{ work.title }}</div> <div class="work-title">{{ work.prompt || work.title || '文生视频' }}</div>
<div class="work-meta">{{ work.id }} · {{ work.size }}</div> <div class="work-meta">{{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
</div> </div>
<div class="work-actions" v-if="index === 0"> <div class="work-actions" v-if="index === 0">
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">做同款</el-button> <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 { useRouter } from 'vue-router'
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus' import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue' import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
import { textToVideoApi } from '@/api/textToVideo'
const router = useRouter() const router = useRouter()
@@ -157,35 +171,7 @@ const detailDialogVisible = ref(false)
const selectedItem = ref(null) const selectedItem = ref(null)
// 已发布作品数据 // 已发布作品数据
const publishedWorks = ref([ 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 goToProfile = () => { const goToProfile = () => {
@@ -235,8 +221,62 @@ const createSimilar = () => {
router.push('/text-to-video/create') 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(() => { onMounted(() => {
// 页面初始化 // 页面初始化时加载任务列表
loadTasks()
}) })
</script> </script>
@@ -462,12 +502,40 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
.work-thumbnail img { .work-thumbnail img,
.work-thumbnail video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; 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 { .hover-create-btn {
position: absolute; position: absolute;

View File

@@ -64,7 +64,6 @@
<select v-model="duration" class="setting-select"> <select v-model="duration" class="setting-select">
<option value="5">5s</option> <option value="5">5s</option>
<option value="10">10s</option> <option value="10">10s</option>
<option value="15">15s</option>
</select> </select>
</div> </div>
@@ -189,10 +188,6 @@
</div> </div>
</div> </div>
<!-- 任务控制 -->
<div class="task-controls" v-if="inProgress">
<button class="cancel-btn" @click="cancelTask">取消任务</button>
</div>
</div> </div>
<!-- 初始状态 --> <!-- 初始状态 -->
@@ -394,10 +389,10 @@ const startPollingTask = () => {
if (progressData && progressData.status) { if (progressData && progressData.status) {
taskStatus.value = progressData.status taskStatus.value = progressData.status
} }
// 更新resultUrl如果存在 // 更新resultUrl如果存在且不为空
if (progressData && progressData.resultUrl && currentTask.value) { if (progressData && progressData.resultUrl && progressData.resultUrl.trim() && currentTask.value) {
currentTask.value.resultUrl = progressData.resultUrl currentTask.value.resultUrl = progressData.resultUrl
console.log('更新resultUrl:', progressData.resultUrl) console.log('更新resultUrl:', progressData.resultUrl.substring(0, 50) + '...')
} }
console.log('任务进度:', progressData) console.log('任务进度:', progressData)
}, },
@@ -407,9 +402,11 @@ const startPollingTask = () => {
taskProgress.value = 100 taskProgress.value = 100
taskStatus.value = 'COMPLETED' taskStatus.value = 'COMPLETED'
// 更新currentTask的resultUrl // 更新currentTask的resultUrl
if (taskData && taskData.resultUrl && currentTask.value) { if (taskData && taskData.resultUrl && taskData.resultUrl.trim() && currentTask.value) {
currentTask.value.resultUrl = taskData.resultUrl 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('视频生成完成!') 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) => { const getStatusText = (status) => {
@@ -1078,6 +1049,14 @@ onUnmounted(() => {
.right-panel { .right-panel {
padding: 24px; padding: 24px;
} }
.video-preview-container {
max-height: 65vh;
}
.video-player-container {
max-height: calc(65vh - 100px);
}
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -1095,6 +1074,18 @@ onUnmounted(() => {
.right-panel { .right-panel {
padding: 20px; 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) { @media (max-width: 768px) {
@@ -1123,6 +1114,21 @@ onUnmounted(() => {
.tab { .tab {
text-align: left; 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; 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 { .task-description {
@@ -1252,11 +1243,14 @@ onUnmounted(() => {
border: 2px solid #2a2a2a; border: 2px solid #2a2a2a;
border-radius: 12px; border-radius: 12px;
min-height: 300px; min-height: 300px;
max-height: 70vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 15px 0; margin: 15px 0;
overflow: hidden; overflow: hidden;
padding: 20px;
box-sizing: border-box;
} }
/* 生成中状态 */ /* 生成中状态 */
@@ -1299,9 +1293,13 @@ onUnmounted(() => {
.completed-container { .completed-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 70vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0; padding: 20px;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
} }
/* 任务信息头部 */ /* 任务信息头部 */
@@ -1334,22 +1332,36 @@ onUnmounted(() => {
flex: 1; flex: 1;
position: relative; position: relative;
margin-bottom: 20px; margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
max-height: calc(70vh - 100px);
width: 100%;
} }
.video-player { .video-player {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; max-width: 100%;
height: auto;
max-height: 100%;
background: #1a1a1a; background: #1a1a1a;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.result-video { .result-video {
width: 100%; width: 100%;
height: 100%; height: auto;
max-height: 100%;
max-width: 100%;
object-fit: contain; object-fit: contain;
border-radius: 12px; border-radius: 12px;
display: block;
} }
.no-video-placeholder { .no-video-placeholder {

View File

@@ -146,6 +146,18 @@
<version>3.14.2</version> <version>3.14.2</version>
</dependency> </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 --> <!-- 腾讯云SDK -->
<dependency> <dependency>
<groupId>com.tencentcloudapi</groupId> <groupId>com.tencentcloudapi</groupId>

View File

@@ -2,11 +2,9 @@ package com.example.demo;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableAsync
@EnableScheduling @EnableScheduling
public class DemoApplication { public class DemoApplication {
@@ -17,6 +15,13 @@ public class DemoApplication {
System.setProperty("sun.net.client.defaultConnectTimeout", "30000"); System.setProperty("sun.net.client.defaultConnectTimeout", "30000");
System.setProperty("sun.net.client.defaultReadTimeout", "120000"); 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); SpringApplication.run(DemoApplication.class, args);
} }

View 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;
}
}

View File

@@ -19,8 +19,8 @@ public class PollingConfig implements SchedulingConfigurer {
@Override @Override
public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) { public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) {
// 使用自定义线程池执行定时任务 // 使用自定义线程池执行定时任务支持50人并发
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
taskRegistrar.setScheduler(executor); taskRegistrar.setScheduler(executor);
} }
} }

View File

@@ -50,6 +50,7 @@ public class SecurityConfig {
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证 .requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证 .requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
.requestMatchers("/api/dashboard/**").hasRole("ADMIN") // 仪表盘API需要管理员权限 .requestMatchers("/api/dashboard/**").hasRole("ADMIN") // 仪表盘API需要管理员权限
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 管理员API需要管理员权限
.requestMatchers("/settings", "/settings/**").hasRole("ADMIN") .requestMatchers("/settings", "/settings/**").hasRole("ADMIN")
.requestMatchers("/users/**").hasRole("ADMIN") .requestMatchers("/users/**").hasRole("ADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()

View File

@@ -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;
}
}

View File

@@ -2,10 +2,12 @@ package com.example.demo.config;
import java.util.Locale; import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 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.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver; import org.springframework.web.servlet.i18n.SessionLocaleResolver;
@@ -13,6 +15,9 @@ import org.springframework.web.servlet.i18n.SessionLocaleResolver;
@Configuration @Configuration
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
@Value("${app.upload.path:uploads}")
private String uploadPath;
@Bean @Bean
public LocaleResolver localeResolver() { public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver(); SessionLocaleResolver slr = new SessionLocaleResolver();
@@ -32,6 +37,28 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(localeChangeInterceptor()); 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避免冲突 // CORS配置已移至SecurityConfig避免冲突
} }

View File

@@ -1,15 +1,28 @@
package com.example.demo.controller; package com.example.demo.controller;
import com.example.demo.service.UserService; import java.util.HashMap;
import com.example.demo.util.JwtUtils; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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 com.example.demo.model.User;
import java.util.Map; import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtils;
/** /**
* 管理员控制器 * 管理员控制器
@@ -129,5 +142,241 @@ public class AdminController {
return null; 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);
}
}
} }

View File

@@ -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);
}
}

View File

@@ -1,19 +1,28 @@
package com.example.demo.controller; 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.OrderRepository;
import com.example.demo.repository.PaymentRepository; import com.example.demo.repository.PaymentRepository;
import com.example.demo.repository.UserMembershipRepository; import com.example.demo.repository.UserMembershipRepository;
import com.example.demo.repository.MembershipLevelRepository; import com.example.demo.repository.UserRepository;
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;
@RestController @RestController
@RequestMapping("/api/dashboard") @RequestMapping("/api/dashboard")
@@ -130,7 +139,7 @@ public class DashboardApiController {
// 获取用户转化率数据 // 获取用户转化率数据
@GetMapping("/conversion-rate") @GetMapping("/conversion-rate")
public ResponseEntity<Map<String, Object>> getConversionRate() { public ResponseEntity<Map<String, Object>> getConversionRate(@RequestParam(required = false) String year) {
try { try {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -147,6 +156,12 @@ public class DashboardApiController {
response.put("paidUsers", paidUsers); response.put("paidUsers", paidUsers);
response.put("conversionRate", Math.round(conversionRate * 100.0) / 100.0); 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(); List<Map<String, Object>> membershipStats = membershipLevelRepository.findMembershipStats();
response.put("membershipStats", membershipStats); response.put("membershipStats", membershipStats);
@@ -161,6 +176,36 @@ public class DashboardApiController {
} }
} }
// 获取按月转化率数据
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") @GetMapping("/recent-orders")
public ResponseEntity<Map<String, Object>> getRecentOrders(@RequestParam(defaultValue = "10") int limit) { public ResponseEntity<Map<String, Object>> getRecentOrders(@RequestParam(defaultValue = "10") int limit) {

View File

@@ -70,16 +70,17 @@ public class ImageToVideoApiController {
return ResponseEntity.badRequest().body(response); return ResponseEntity.badRequest().body(response);
} }
// 验证文件大小最大10MB // 验证文件大小最大100MB,与文件上传配置保持一致
if (firstFrame.getSize() > 10 * 1024 * 1024) { long maxFileSize = 100 * 1024 * 1024; // 100MB
if (firstFrame.getSize() > maxFileSize) {
response.put("success", false); response.put("success", false);
response.put("message", "首帧图片大小不能超过10MB"); response.put("message", "首帧图片大小不能超过100MB");
return ResponseEntity.badRequest().body(response); 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("success", false);
response.put("message", "尾帧图片大小不能超过10MB"); response.put("message", "尾帧图片大小不能超过100MB");
return ResponseEntity.badRequest().body(response); 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);
}
}
/** /**
* 获取任务状态 * 获取任务状态

View File

@@ -1,23 +1,32 @@
package com.example.demo.controller; package com.example.demo.controller;
import com.example.demo.model.User; import java.util.HashMap;
import com.example.demo.model.UserMembership; import java.util.List;
import com.example.demo.model.MembershipLevel; import java.util.Map;
import com.example.demo.repository.UserRepository; import java.util.Optional;
import com.example.demo.repository.UserMembershipRepository;
import com.example.demo.repository.MembershipLevelRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity; 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 com.example.demo.model.MembershipLevel;
import java.util.List; import com.example.demo.model.User;
import java.util.Map; import com.example.demo.model.UserMembership;
import java.util.Optional; import com.example.demo.repository.MembershipLevelRepository;
import com.example.demo.repository.UserMembershipRepository;
import com.example.demo.repository.UserRepository;
@RestController @RestController
@RequestMapping("/api/members") @RequestMapping("/api/members")
@@ -260,4 +269,97 @@ public class MemberApiController {
return ResponseEntity.status(500).body(error); 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