perf(backend+frontend): 列表API响应体积优化 3.1MB→145KB (↓95.4%)
- 后端: JPQL构造器投影排除LONGTEXT大字段(uploadedImages/videoReferenceImages) - 后端: DTO层过滤非分镜图类型的base64内联resultUrl - 前端: 列表缩略图从video改为img loading=lazy,消除172并发请求 - 前端: download函数增加resultUrl懒加载(详情接口兜底) - 文档: 新增性能优化报告 docs/performance-optimization-report.md
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
72
.gitignore
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# 依赖目录
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
|
||||
# 构建输出
|
||||
dist/
|
||||
build/
|
||||
target/
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 环境变量文件
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE文件
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 操作系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
temp/
|
||||
|
||||
# 上传文件
|
||||
uploads/
|
||||
|
||||
# Java编译文件
|
||||
*.class
|
||||
|
||||
# Maven
|
||||
.mvn/
|
||||
mvnw
|
||||
mvnw.cmd
|
||||
|
||||
# Spring Boot
|
||||
application-*.properties
|
||||
!application.properties
|
||||
!application-dev.properties
|
||||
!application-prod.properties
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# 测试文件
|
||||
test.html
|
||||
test-*.html
|
||||
test-*.sh
|
||||
test-*.md
|
||||
|
||||
# 启动脚本
|
||||
start-service.bat
|
||||
startup.log
|
||||
|
||||
# 其他
|
||||
*.jar
|
||||
!mysql-connector-java-8.0.33.jar
|
||||
|
||||
# Windows 保留名占位文件
|
||||
nul
|
||||
289
SORA2_INTEGRATION_TASKS.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Sora 2 模型集成 — 任务执行清单(后台配置方案)
|
||||
|
||||
> **核心思路**:视频生成模型由管理员在系统后台统一指定,用户无感知。后端根据当前配置的模型名自动切换 API 请求参数格式。
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库层(4 个表 + 1 个 SQL 脚本)
|
||||
|
||||
### 1.1 SystemSettings 新增 `video_model` 字段
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `AIGC/src/main/java/com/example/demo/model/SystemSettings.java` | 新增 `videoModel` 字段(VARCHAR(50)),默认值 `"grok-video-3"`,加 getter/setter |
|
||||
|
||||
### 1.2 任务表新增 `video_model` 字段(记录创建时使用的模型)
|
||||
|
||||
| 表名 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| `text_to_video_tasks` | `AIGC/src/main/java/com/example/demo/model/TextToVideoTask.java` | 新增 `videoModel` 字段(VARCHAR(50)),默认值 `"grok-video-3"`,加 getter/setter |
|
||||
| `image_to_video_tasks` | `AIGC/src/main/java/com/example/demo/model/ImageToVideoTask.java` | 同上 |
|
||||
| `storyboard_video_tasks` | `AIGC/src/main/java/com/example/demo/model/StoryboardVideoTask.java` | 同上(注意:已有 `imageModel` 字段是分镜图模型,新增的 `videoModel` 是视频生成模型,不冲突) |
|
||||
| `user_works` | `AIGC/src/main/java/com/example/demo/model/UserWork.java` | 新增 `videoModel` 字段(VARCHAR(50)),默认值 `"grok-video-3"` |
|
||||
|
||||
### 1.3 数据库迁移脚本
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `AIGC/init_database.sql` | 末尾追加 5 条 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS video_model VARCHAR(50) DEFAULT 'grok-video-3'`(system_settings + 3 任务表 + user_works) |
|
||||
|
||||
---
|
||||
|
||||
## 二、后端 Service 层(5 个文件)
|
||||
|
||||
### 2.1 SystemSettingsService — 提供模型配置读取
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/service/SystemSettingsService.java` |
|
||||
|------|------|
|
||||
| `getOrCreate()` | 初始化默认值时设置 `videoModel = "grok-video-3"` |
|
||||
|
||||
### 2.2 RealAIService — 根据模型名切换 API 参数格式(核心改动)
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/service/RealAIService.java` |
|
||||
|------|------|
|
||||
| **参数构建逻辑** | 从 `SystemSettings` 读取 `videoModel`,根据模型名构建不同的请求体 |
|
||||
| `submitTextToVideoTask()` | 移除外部传入的 `videoModel` 参数,改为内部读取系统设置。根据模型名选择参数格式(见下方参数对照表) |
|
||||
| `submitImageToVideoTask()` | 同上 |
|
||||
| `submitStoryboardVideoTask()` | 同上 |
|
||||
| `selectXxxModel()` 系列 | 不再使用,模型由系统设置决定 |
|
||||
|
||||
#### 参数对照表
|
||||
|
||||
| 参数 | `grok-video-3` | `sora-2` / `sora-2-pro` |
|
||||
|------|----------------|--------------------------|
|
||||
| `model` | `"grok-video-3"` | `"sora-2"` 或 `"sora-2-pro"` |
|
||||
| 宽高比 | `"ratio"`: `"3:2"` / `"2:3"` / `"1:1"`(`convertAspectRatioToGrokRatio()`) | `"size"`: `"1280x720"` / `"720x1280"` 等(`convertAspectRatioToSize()`) |
|
||||
| 时长 | `"duration"`: 整数 5 或 10(`convertDurationToGrokDuration()`) | `"duration"`: 字符串 `"10s"` / `"15s"` / `"25s"` |
|
||||
| 分辨率 | `"resolution"`: `"1080P"` / `"720P"` | 通过 `size` 参数中的像素值体现,HD 时选 sora-2-pro |
|
||||
| API 端点 | `/v2/videos/generations` | `/v2/videos/generations`(相同) |
|
||||
|
||||
#### 模型选择逻辑
|
||||
|
||||
```java
|
||||
// 从系统设置读取当前模型
|
||||
String videoModel = systemSettingsService.getOrCreate().getVideoModel();
|
||||
if (videoModel == null || videoModel.isEmpty()) {
|
||||
videoModel = "grok-video-3"; // 默认值
|
||||
}
|
||||
|
||||
// 根据模型构建请求参数
|
||||
if (videoModel.startsWith("sora-2")) {
|
||||
// Sora 2 参数格式
|
||||
requestMap.put("model", hdMode ? "sora-2-pro" : "sora-2");
|
||||
requestMap.put("size", convertAspectRatioToSize(aspectRatio, hdMode));
|
||||
requestMap.put("duration", duration + "s"); // "10s", "15s"
|
||||
} else {
|
||||
// Grok 参数格式(默认)
|
||||
requestMap.put("model", videoModel);
|
||||
requestMap.put("ratio", convertAspectRatioToGrokRatio(aspectRatio));
|
||||
requestMap.put("duration", convertDurationToGrokDuration(duration));
|
||||
requestMap.put("resolution", hdMode ? "1080P" : "720P");
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 TextToVideoService — 记录模型到任务
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/service/TextToVideoService.java` |
|
||||
|------|------|
|
||||
| `createTask()` | 移除 `videoModel` 参数,改为从 `SystemSettings` 读取当前模型,`task.setVideoModel(videoModel)` |
|
||||
| `processTaskWithRealAPI()` | 从 task 读取 `videoModel` 传给 RealAIService(保证使用创建时的模型,即使后台后来切换了) |
|
||||
| `retryTask()` | 复用原任务的 `videoModel` |
|
||||
|
||||
### 2.4 ImageToVideoService — 记录模型到任务
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/service/ImageToVideoService.java` |
|
||||
|------|------|
|
||||
| `createTask()` | 同上逻辑 |
|
||||
| `createTaskByUrl()` | 同上 |
|
||||
| `processTaskWithRealAPI()` | 从 task 读取 `videoModel` 传给 RealAIService |
|
||||
| `retryTask()` | 复用原任务的 `videoModel` |
|
||||
|
||||
### 2.5 StoryboardVideoService — 记录模型到任务
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/service/StoryboardVideoService.java` |
|
||||
|------|------|
|
||||
| 创建任务方法 | 从 `SystemSettings` 读取当前模型,写入任务 |
|
||||
| 视频生成调用处 | 从 task 读取 `videoModel` 传给 RealAIService |
|
||||
| `retryTask()` | 复用原任务的 `videoModel` |
|
||||
|
||||
### 2.6 UserWorkService — 创建作品时写入模型
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/service/UserWorkService.java` |
|
||||
|------|------|
|
||||
| `createProcessingTextToVideoWork()` | 从 TextToVideoTask 读取 `videoModel`,`work.setVideoModel(task.getVideoModel())` |
|
||||
| `createProcessingImageToVideoWork()` | 从 ImageToVideoTask 读取 `videoModel`,同上 |
|
||||
| `createProcessingStoryboardVideoWork()` | 从 StoryboardVideoTask 读取 `videoModel`,同上 |
|
||||
| `createTextToVideoWork()` | 同上(完成时创建的方法) |
|
||||
| `createImageToVideoWork()` | 同上 |
|
||||
| `createStoryboardVideoWork()` | 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 三、后端 Controller 层(1 个文件)
|
||||
|
||||
### 3.1 PublicApiController — 公共配置接口暴露当前模型
|
||||
|
||||
| 文件 | `AIGC/src/main/java/com/example/demo/controller/PublicApiController.java` |
|
||||
|------|------|
|
||||
| `getPublicConfig()` | 在返回的 config Map 中新增 `videoModel` 字段(从 SystemSettings 读取),前端据此动态渲染比例/时长选项 |
|
||||
|
||||
---
|
||||
|
||||
## 四、前端 — 管理后台设置页(1 个文件)
|
||||
|
||||
### 4.1 SystemSettings.vue — AI 模型选项卡新增视频模型配置
|
||||
|
||||
| 文件 | `AIGC/frontend/src/views/SystemSettings.vue` |
|
||||
|------|------|
|
||||
| 改动 | 在已有的"AI模型设置"选项卡中,新增视频模型选择下拉框(el-select),选项:`grok-video-3` / `sora-2`。保存时写入 SystemSettings |
|
||||
|
||||
---
|
||||
|
||||
## 五、前端 — 创建页面动态参数(3 个文件)
|
||||
|
||||
> **核心变化**:前端启动时从 `/api/public/config` 获取当前 `videoModel`,据此动态渲染不同的比例和时长选项。
|
||||
|
||||
#### 各模型前端参数对照表
|
||||
|
||||
| 参数 | `grok-video-3`(当前) | `sora-2` / `sora-2-pro` |
|
||||
|------|------------------------|---------------------------|
|
||||
| 比例选项 | `3:2`(横屏)、`2:3`(竖屏) | `16:9`(横屏)、`9:16`(竖屏) |
|
||||
| 时长选项 | `5s`、`10s` | `10s`、`15s`(HD 模式下 sora-2-pro 还支持 `25s`) |
|
||||
| 默认比例 | `2:3` | `16:9` |
|
||||
| 默认时长 | `5` | `10` |
|
||||
|
||||
### 5.1 TextToVideoCreate.vue
|
||||
|
||||
| 文件 | `AIGC/frontend/src/views/TextToVideoCreate.vue` |
|
||||
|------|------|
|
||||
| 获取模型 | `onMounted` 时调用 `/api/public/config` 获取 `videoModel`,存入响应式变量 |
|
||||
| 比例下拉框 | 将 `<option>` 改为动态列表,根据 `videoModel` 渲染不同选项 |
|
||||
| 时长下拉框 | 同上,根据 `videoModel` 渲染不同选项 |
|
||||
| 默认值 | 模型切换时重置 `aspectRatio` 和 `duration` 为对应默认值 |
|
||||
|
||||
### 5.2 ImageToVideoCreate.vue
|
||||
|
||||
| 文件 | `AIGC/frontend/src/views/ImageToVideoCreate.vue` |
|
||||
|------|------|
|
||||
| 改动 | 同 TextToVideoCreate.vue,动态渲染比例/时长选项 |
|
||||
|
||||
### 5.3 StoryboardVideoCreate.vue
|
||||
|
||||
| 文件 | `AIGC/frontend/src/views/StoryboardVideoCreate.vue` |
|
||||
|------|------|
|
||||
| 改动 | 同上,动态渲染比例/时长选项 |
|
||||
|
||||
#### 实现方式参考
|
||||
|
||||
```javascript
|
||||
// 模型参数配置表
|
||||
const MODEL_CONFIGS = {
|
||||
'grok-video-3': {
|
||||
ratios: [
|
||||
{ value: '3:2', label: '3:2' },
|
||||
{ value: '2:3', label: '2:3' }
|
||||
],
|
||||
durations: [
|
||||
{ value: '5', label: '5s' },
|
||||
{ value: '10', label: '10s' }
|
||||
],
|
||||
defaultRatio: '2:3',
|
||||
defaultDuration: '5'
|
||||
},
|
||||
'sora-2': {
|
||||
ratios: [
|
||||
{ value: '16:9', label: '16:9' },
|
||||
{ value: '9:16', label: '9:16' }
|
||||
],
|
||||
durations: [
|
||||
{ value: '10', label: '10s' },
|
||||
{ value: '15', label: '15s' }
|
||||
],
|
||||
defaultRatio: '16:9',
|
||||
defaultDuration: '10'
|
||||
}
|
||||
}
|
||||
|
||||
// 从公共配置获取当前模型
|
||||
const videoModel = ref('grok-video-3')
|
||||
const currentConfig = computed(() => MODEL_CONFIGS[videoModel.value] || MODEL_CONFIGS['grok-video-3'])
|
||||
|
||||
onMounted(async () => {
|
||||
const config = await fetch('/api/public/config').then(r => r.json())
|
||||
videoModel.value = config.videoModel || 'grok-video-3'
|
||||
// 重置为对应模型的默认值
|
||||
aspectRatio.value = currentConfig.value.defaultRatio
|
||||
duration.value = currentConfig.value.defaultDuration
|
||||
})
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- 比例选项动态渲染 -->
|
||||
<select v-model="aspectRatio" class="setting-select">
|
||||
<option v-for="r in currentConfig.ratios" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
|
||||
<!-- 时长选项动态渲染 -->
|
||||
<select v-model="duration" class="setting-select">
|
||||
<option v-for="d in currentConfig.durations" :key="d.value" :value="d.value">{{ d.label }}</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、前端 — 作品展示(2 个文件)
|
||||
|
||||
### 6.1 MyWorks.vue — 显示模型标签
|
||||
|
||||
| 文件 | `AIGC/frontend/src/views/MyWorks.vue` |
|
||||
|------|------|
|
||||
| 改动 | 在作品卡片上新增一个不可点击的 `el-tag`,根据 `work.videoModel` 显示模型名称。不同模型用不同颜色区分(Sora 2 紫色,Grok 蓝色) |
|
||||
|
||||
### 6.2 Profile.vue — 个人主页作品集显示模型标签
|
||||
|
||||
| 文件 | `AIGC/frontend/src/views/Profile.vue` |
|
||||
|------|------|
|
||||
| `transformWorkData()` | 新增 `videoModel: work.videoModel \|\| 'grok-video-3'` 映射 |
|
||||
| 作品卡片模板(video-grid) | 在 `.video-thumbnail` 区域内新增不可点击的 `el-tag` 显示模型名,与 MyWorks 保持一致风格 |
|
||||
| 详情弹窗(metadata-section) | 新增一行 metadata-item 显示"视频模型:Sora 2 / Grok Video 3" |
|
||||
|
||||
---
|
||||
|
||||
## 七、国际化(2 个文件)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `AIGC/frontend/src/locales/zh.js` | 新增:`videoModel: '视频生成模型'`、`modelGrok: 'Grok Video 3'`、`modelSora2: 'Sora 2'`、`videoModelTip: '切换后新创建的任务将使用所选模型'` 等 |
|
||||
| `AIGC/frontend/src/locales/en.js` | 新增对应英文翻译 |
|
||||
|
||||
---
|
||||
|
||||
## 八、积分计算
|
||||
|
||||
Sora 2 和 Grok 积分价格统一,不需要改动 `calculateCost()` 逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 改动汇总
|
||||
|
||||
| 层级 | 文件数 | 复杂度 |
|
||||
|------|--------|--------|
|
||||
| 数据库 Model | 5(SystemSettings + 3 任务表 + UserWork) | 低(各加 1 个字段) |
|
||||
| 数据库 SQL | 1 | 低(5 条 ALTER) |
|
||||
| 后端 Service | 6(SystemSettingsService + RealAIService + 3 视频 Service + UserWorkService) | **中**(RealAIService 需根据模型分支构建请求参数) |
|
||||
| 后端 Controller | 1(PublicApiController) | 低(公共配置中暴露 videoModel) |
|
||||
| 前端创建页面 | 3(TextToVideoCreate + ImageToVideoCreate + StoryboardVideoCreate) | **中**(动态渲染比例/时长选项) |
|
||||
| 前端管理后台 | 1(SystemSettings.vue) | 低(加下拉框) |
|
||||
| 前端作品展示 | 2(MyWorks + Profile) | 低(加标签) |
|
||||
| 国际化 | 2 | 低(加几个 key) |
|
||||
| **合计** | **~21 个文件** | **整体工作量:1-2 天** |
|
||||
|
||||
---
|
||||
|
||||
## 不需要改动的部分
|
||||
|
||||
- ❌ 前端 API 层(textToVideo.js / imageToVideo.js / storyboardVideo.js)— 不传 videoModel,比例/时长参数名不变
|
||||
- ❌ 任务 ID 生成逻辑 — 内部 ID 与模型无关
|
||||
- ❌ 任务状态轮询 — `getTaskStatus()` 用 realTaskId 查询,与模型无关
|
||||
- ❌ COS 存储逻辑 — 存的是视频文件,与模型无关
|
||||
- ❌ 任务队列调度 — 队列只管调度,不关心模型
|
||||
81
config/examples/application-prod.properties.example
Normal file
@@ -0,0 +1,81 @@
|
||||
# ============================================
|
||||
# AIGC平台生产环境配置文件模板
|
||||
# ============================================
|
||||
# 使用说明:
|
||||
# 1. 将此文件复制到服务器:/www/server/aigc-backend/application-prod.properties
|
||||
# 2. 修改下面标记为【必改】的配置项
|
||||
# 3. 根据需要修改【可选】配置项
|
||||
|
||||
# ============================================
|
||||
# 服务器配置
|
||||
# ============================================
|
||||
server.port=8080
|
||||
|
||||
# ============================================
|
||||
# 数据库配置【必改】
|
||||
# ============================================
|
||||
spring.datasource.url=jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||
spring.datasource.username=aigc_platform
|
||||
spring.datasource.password=YOUR_DB_PASSWORD
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
# ============================================
|
||||
# JPA配置
|
||||
# ============================================
|
||||
spring.jpa.hibernate.ddl-auto=none
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.properties.hibernate.format_sql=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# ============================================
|
||||
logging.level.root=INFO
|
||||
logging.level.com.example=INFO
|
||||
logging.file.name=/www/server/aigc-backend/logs/app.log
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
|
||||
|
||||
# ============================================
|
||||
# 文件上传配置【可选修改】
|
||||
# ============================================
|
||||
spring.servlet.multipart.max-file-size=100MB
|
||||
spring.servlet.multipart.max-request-size=100MB
|
||||
|
||||
# 文件存储路径
|
||||
file.upload-dir=/www/server/aigc-backend/uploads
|
||||
|
||||
# 临时文件目录
|
||||
app.temp.dir=/www/server/aigc-backend/temp
|
||||
|
||||
# ============================================
|
||||
# 腾讯云配置【如使用腾讯云存储,必改】
|
||||
# ============================================
|
||||
# 腾讯云API密钥
|
||||
# tencent.cloud.secret-id=YOUR_SECRET_ID
|
||||
# tencent.cloud.secret-key=YOUR_SECRET_KEY
|
||||
|
||||
# 腾讯云区域
|
||||
# tencent.cloud.region=ap-guangzhou
|
||||
|
||||
# COS对象存储配置
|
||||
# tencent.cos.bucket-name=YOUR_BUCKET_NAME
|
||||
# tencent.cos.region=ap-guangzhou
|
||||
|
||||
# ============================================
|
||||
# CORS跨域配置【根据前端域名修改】
|
||||
# ============================================
|
||||
# cors.allowed-origins=https://your-domain.com,http://your-domain.com
|
||||
|
||||
# ============================================
|
||||
# 应用配置【可选】
|
||||
# ============================================
|
||||
# 应用名称
|
||||
spring.application.name=aigc-platform
|
||||
|
||||
# 启用压缩
|
||||
server.compression.enabled=true
|
||||
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
|
||||
|
||||
# Session配置
|
||||
server.servlet.session.timeout=30m
|
||||
43
config/examples/env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# 环境变量配置示例
|
||||
# 复制此文件为 .env 并根据实际情况修改
|
||||
|
||||
# 数据库配置
|
||||
DB_URL=jdbc:mysql://localhost:3306/aigc?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=your_database_password
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-very-long-and-secure-jwt-secret-key-at-least-256-bits-long
|
||||
JWT_EXPIRATION=604800000
|
||||
|
||||
# 支付宝配置
|
||||
ALIPAY_APP_ID=your_alipay_app_id
|
||||
ALIPAY_PRIVATE_KEY=your_alipay_private_key
|
||||
ALIPAY_PUBLIC_KEY=alipay_public_key
|
||||
ALIPAY_NOTIFY_URL=https://yourdomain.com/api/payments/alipay/notify
|
||||
ALIPAY_RETURN_URL=https://yourdomain.com/api/payments/alipay/return
|
||||
|
||||
|
||||
# 日志配置
|
||||
LOG_FILE_PATH=./logs/application.log
|
||||
|
||||
# 服务器配置
|
||||
SERVER_PORT=8080
|
||||
SERVER_CONTEXT_PATH=/
|
||||
|
||||
# 邮件配置(可选)
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your_email@gmail.com
|
||||
MAIL_PASSWORD=your_email_password
|
||||
|
||||
# Redis配置(可选)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_PATH=./uploads
|
||||
MAX_FILE_SIZE=10MB
|
||||
|
||||
|
||||
29
config/examples/frpc.ini.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# FRP 客户端配置文件示例
|
||||
# 使用 OpenFrp 或其他免费 FRP 服务时使用此配置
|
||||
|
||||
[common]
|
||||
# 服务器地址(从 FRP 服务提供商控制台获取)
|
||||
server_addr = frp.example.com
|
||||
# 服务器端口(通常是 7000)
|
||||
server_port = 7000
|
||||
# 认证 token(从 FRP 服务提供商控制台获取)
|
||||
token = your_token_here
|
||||
|
||||
[payment]
|
||||
# 隧道类型:http
|
||||
type = http
|
||||
# 本地 IP(通常是 127.0.0.1)
|
||||
local_ip = 127.0.0.1
|
||||
# 本地端口(Spring Boot 运行端口)
|
||||
local_port = 8080
|
||||
# 自定义域名(从 FRP 服务提供商控制台获取)
|
||||
custom_domains = your-domain.openfrp.net
|
||||
|
||||
# 如果需要多个服务,可以添加更多配置段
|
||||
# [other-service]
|
||||
# type = http
|
||||
# local_ip = 127.0.0.1
|
||||
# local_port = 3000
|
||||
# custom_domains = other-domain.openfrp.net
|
||||
|
||||
|
||||
72
config/examples/paypal-config.properties.example
Normal file
@@ -0,0 +1,72 @@
|
||||
# ============================================
|
||||
# PayPal支付配置示例
|
||||
# ============================================
|
||||
# 使用说明:
|
||||
# 1. 将PayPal配置添加到 application-prod.properties 或 application-dev.properties 文件中
|
||||
# 2. 从PayPal开发者平台获取Client ID和Client Secret
|
||||
# 3. 根据环境选择sandbox(测试)或live(生产)模式
|
||||
#
|
||||
# PayPal开发者平台: https://developer.paypal.com/
|
||||
# - 登录后在 Dashboard > My Apps & Credentials 中创建应用
|
||||
# - 获取 Client ID 和 Secret
|
||||
# - Sandbox环境用于测试,Live环境用于生产
|
||||
|
||||
# ============================================
|
||||
# PayPal基础配置
|
||||
# ============================================
|
||||
# PayPal Client ID(必填)
|
||||
# 测试环境示例:
|
||||
# paypal.client-id=AeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
paypal.client-id=YOUR_PAYPAL_CLIENT_ID
|
||||
|
||||
# PayPal Client Secret(必填)
|
||||
# 测试环境示例:
|
||||
# paypal.client-secret=EXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
paypal.client-secret=YOUR_PAYPAL_CLIENT_SECRET
|
||||
|
||||
# PayPal模式(必填)
|
||||
# sandbox: 测试环境(推荐先使用测试环境)
|
||||
# live: 生产环境(正式上线后使用)
|
||||
paypal.mode=sandbox
|
||||
|
||||
# ============================================
|
||||
# PayPal回调URL配置
|
||||
# ============================================
|
||||
# 支付成功后的返回URL(必填)
|
||||
# 本地开发:
|
||||
# paypal.success-url=http://localhost:8080/api/payment/paypal/success
|
||||
# 生产环境:
|
||||
paypal.success-url=https://your-domain.com/api/payment/paypal/success
|
||||
|
||||
# 支付取消后的返回URL(必填)
|
||||
# 本地开发:
|
||||
# paypal.cancel-url=http://localhost:8080/api/payment/paypal/cancel
|
||||
# 生产环境:
|
||||
paypal.cancel-url=https://your-domain.com/api/payment/paypal/cancel
|
||||
|
||||
# ============================================
|
||||
# 重要提示
|
||||
# ============================================
|
||||
# 1. 测试账号:
|
||||
# - 在PayPal Sandbox中可以创建测试买家和卖家账号
|
||||
# - 测试账号信息在 Dashboard > Sandbox > Accounts 中查看
|
||||
#
|
||||
# 2. 货币支持:
|
||||
# - PayPal不直接支持CNY(人民币)
|
||||
# - 系统会自动将CNY转换为USD
|
||||
# - 建议在前端显示时做汇率转换说明
|
||||
#
|
||||
# 3. 回调URL要求:
|
||||
# - 必须是公网可访问的HTTPS地址(生产环境)
|
||||
# - 本地测试可使用HTTP
|
||||
# - 可使用ngrok等工具将本地服务暴露到公网进行测试
|
||||
#
|
||||
# 4. Webhook配置(可选但推荐):
|
||||
# - 在PayPal应用设置中配置Webhook URL
|
||||
# - 用于接收支付状态变更通知
|
||||
# - URL格式: https://your-domain.com/api/payment/paypal/webhook
|
||||
#
|
||||
# 5. 安全建议:
|
||||
# - 不要将此配置文件提交到版本控制系统
|
||||
# - 生产环境的Client Secret必须妥善保管
|
||||
# - 定期更新API凭证
|
||||
42
config/examples/tencent-config-template.properties
Normal file
@@ -0,0 +1,42 @@
|
||||
# 腾讯云邮件推送服务配置模板
|
||||
# 请根据您的腾讯云账号信息填写以下配置
|
||||
|
||||
# ===========================================
|
||||
# 1. API密钥配置(必填)
|
||||
# ===========================================
|
||||
# 在腾讯云控制台 → 访问管理 → API密钥管理 中获取
|
||||
tencent.cloud.secret-id=请填写您的SecretId
|
||||
tencent.cloud.secret-key=请填写您的SecretKey
|
||||
|
||||
# ===========================================
|
||||
# 2. 邮件推送服务配置(必填)
|
||||
# ===========================================
|
||||
# 服务地域(通常使用北京)
|
||||
tencent.cloud.ses.region=ap-beijing
|
||||
|
||||
# 发件人邮箱(需要在腾讯云SES中验证)
|
||||
tencent.cloud.ses.from-email=请填写您的发件人邮箱
|
||||
|
||||
# 发件人名称
|
||||
tencent.cloud.ses.from-name=AIGC Demo
|
||||
|
||||
# 邮件模板ID(可选,如不使用模板可留空)
|
||||
tencent.cloud.ses.template-id=
|
||||
|
||||
# ===========================================
|
||||
# 3. 使用说明
|
||||
# ===========================================
|
||||
# 1. 复制此文件为 application-tencent.properties
|
||||
# 2. 填写上述配置信息
|
||||
# 3. 在 application.properties 中设置 spring.profiles.active=tencent
|
||||
# 4. 重启应用即可使用腾讯云邮件服务
|
||||
|
||||
# ===========================================
|
||||
# 4. 配置示例
|
||||
# ===========================================
|
||||
# tencent.cloud.secret-id=AKID1234567890abcdef1234567890abcdef
|
||||
# tencent.cloud.secret-key=abcdef1234567890abcdef1234567890
|
||||
# tencent.cloud.ses.region=ap-beijing
|
||||
# tencent.cloud.ses.from-email=noreply@yourdomain.com
|
||||
# tencent.cloud.ses.from-name=AIGC Demo
|
||||
# tencent.cloud.ses.template-id=123456
|
||||
152
docs/performance-optimization-report.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# AIGC 后端性能优化报告
|
||||
|
||||
> 日期:2026-04-10
|
||||
> 范围:列表 API 响应体积优化 + 前端缩略图加载优化
|
||||
|
||||
---
|
||||
|
||||
## 一、问题背景
|
||||
|
||||
"我的作品"页面加载缓慢,172 条记录的 `/api/works/my-works` 接口响应体积达 **3.1MB**,导致:
|
||||
- 网络传输耗时长
|
||||
- 前端 JSON 解析阻塞主线程
|
||||
- 浏览器同时加载 172 个 `<video>` 元素,并发请求导致页面卡死
|
||||
|
||||
---
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 2.1 后端:大字段未做投影
|
||||
|
||||
原 JPA 查询 `SELECT uw FROM UserWork uw ...` 加载全部列,包含多个 LONGTEXT 字段:
|
||||
|
||||
| 字段 | 类型 | 172 条总体积 | 说明 |
|
||||
|------|------|-------------|------|
|
||||
| `resultUrl` | LONGTEXT | **2512 KB** | 7 条含 `data:image/jpeg;base64,...` 内联图片,单条最长 473KB |
|
||||
| `uploadedImages` | LONGTEXT | 514 KB | 用户上传的参考图 base64 |
|
||||
| `videoReferenceImages` | LONGTEXT | ~0 KB | 视频参考图(当前数据为空) |
|
||||
| 其他字段合计 | — | ~30 KB | 正常 |
|
||||
|
||||
**关键发现**:82% 的体积来自 7 条分镜图作品的 `resultUrl`,它们存储的不是 CDN URL 而是 base64 内联图片数据。
|
||||
|
||||
### 2.2 前端:缩略图加载策略低效
|
||||
|
||||
| 问题 | 影响 |
|
||||
|------|------|
|
||||
| 自定义 `v-lazy` 指令使用 `IntersectionObserver` + `new Image()` 预加载 | 172 个元素同时触发,创建 172 个并发图片请求 |
|
||||
| `<video preload="metadata">` 用于列表缩略图 | 浏览器为每个视频卡片发起元数据请求,并发量爆炸 |
|
||||
| `<video>` 无 `poster` 属性 | `preload="none"` 后视频区域透明,露出 CSS 渐变背景 |
|
||||
|
||||
---
|
||||
|
||||
## 三、优化方案
|
||||
|
||||
### 3.1 后端:JPQL 构造器投影(SQL 层排除大字段)
|
||||
|
||||
**原理**:使用 `SELECT new UserWorkListDTO(...)` JPQL 语法,让 Hibernate 生成的 SQL 只 SELECT 需要的列,从数据库到 JVM 就不传输大字段。
|
||||
|
||||
**改动文件**:
|
||||
|
||||
#### `UserWorkListDTO.java`
|
||||
- 新增全参数构造函数,供 JPQL 构造器投影使用
|
||||
- 排除 `uploadedImages` 和 `videoReferenceImages` 字段
|
||||
- 对 `data:` 开头的 base64 `resultUrl` 智能过滤(STORYBOARD_IMAGE 类型保留,其他类型置 null)
|
||||
|
||||
#### `UserWorkRepository.java`
|
||||
- 新增 11 个 JPQL 投影查询方法(方法名后缀 `DTO`),返回 `Page<UserWorkListDTO>` / `List<UserWorkListDTO>`
|
||||
- 覆盖所有列表查询场景:按用户名、按类型、按状态、公共作品、搜索、标签
|
||||
|
||||
#### `UserWorkService.java`
|
||||
- 所有列表查询方法返回类型从 `Page<UserWork>` 改为 `Page<UserWorkListDTO>`
|
||||
- `getProcessingWorks` 返回类型从 `List<UserWork>` 改为 `List<UserWorkListDTO>`
|
||||
|
||||
#### `UserWorkApiController.java`
|
||||
- 所有列表接口直接返回 DTO,移除 `stream().map()` 转换
|
||||
- 增加 `[PERF]` 计时日志(保留),输出查询/统计/组装各阶段耗时
|
||||
|
||||
### 3.2 后端:base64 resultUrl 智能过滤
|
||||
|
||||
```java
|
||||
// UserWorkListDTO 构造函数中
|
||||
boolean isStoryboardImage = workType == UserWork.WorkType.STORYBOARD_IMAGE;
|
||||
this.resultUrl = (!isStoryboardImage && resultUrl != null && resultUrl.startsWith("data:"))
|
||||
? null : resultUrl;
|
||||
```
|
||||
|
||||
- **非分镜图类型**:`data:` 开头的超长 resultUrl 置为 null(列表不需要)
|
||||
- **分镜图类型**:保留 base64 resultUrl(它就是图片本身,且无 thumbnailUrl)
|
||||
- **详情/下载场景**:通过 `GET /api/works/{id}` 详情接口获取完整数据
|
||||
|
||||
### 3.3 前端:下载函数懒加载适配
|
||||
|
||||
```javascript
|
||||
// MyWorks.vue / Profile.vue 的 download 函数
|
||||
if (!item.resultUrl) {
|
||||
const detailResp = await getWorkDetail(item.id)
|
||||
const detail = detailResp?.data?.data || detailResp?.data
|
||||
if (detail?.resultUrl) {
|
||||
item.resultUrl = detail.resultUrl
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当列表 DTO 中 resultUrl 为 null 时,下载前自动通过详情接口获取完整值。
|
||||
|
||||
### 3.4 前端:缩略图加载策略重构
|
||||
|
||||
| 改动 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| 图片缩略图 | `v-lazy:loading="item.cover"` (自定义指令) | `<img :src="item.cover" loading="lazy">` (原生懒加载) |
|
||||
| 视频缩略图 | `<video :src preload="metadata">` | `<img :src="item.cover" loading="lazy">` (统一用封面图) |
|
||||
| 视频播放 | 列表卡片中 `<video>` 加载元数据 | 仅在详情弹窗中 `<video>` 播放 |
|
||||
|
||||
**核心思路**:列表网格只需要展示封面图(静态图片),不需要 `<video>` 元素。视频播放只在用户点击进入详情弹窗时才加载。
|
||||
|
||||
---
|
||||
|
||||
## 四、性能对比数据
|
||||
|
||||
### 测试条件
|
||||
- 用户:984523799,记录数:172 条
|
||||
- 测试方法:JVM 内部 A/B 对比,3 轮热身取稳定值
|
||||
|
||||
### 结果
|
||||
|
||||
| 指标 | 优化前 | 优化后 | 改善 |
|
||||
|------|--------|--------|------|
|
||||
| **JSON 响应体积** | 3164 KB (3.1 MB) | **145 KB** | **↓ 95.4%** |
|
||||
| **DB 查询耗时** | ~20 ms | **~10 ms** | ↓ 50% |
|
||||
| **总接口耗时**(含统计) | — | **12–45 ms** | — |
|
||||
| **前端并发请求** | 172 个 video + 172 个 Image | 按需原生懒加载 | 大幅减少 |
|
||||
|
||||
---
|
||||
|
||||
## 五、改动文件清单
|
||||
|
||||
### 后端(Java)
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `dto/UserWorkListDTO.java` | 修改 | 全参构造函数 + base64 过滤逻辑 |
|
||||
| `repository/UserWorkRepository.java` | 修改 | 新增 11 个 JPQL 投影查询方法 |
|
||||
| `service/UserWorkService.java` | 修改 | 返回类型改为 DTO |
|
||||
| `controller/UserWorkApiController.java` | 修改 | 类型同步 + PERF 计时日志 |
|
||||
| `resources/logback-spring.xml` | 还原 | 临时 DEBUG 日志已恢复为 WARN |
|
||||
|
||||
### 前端(Vue)
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `views/MyWorks.vue` | 修改 | 缩略图改用 `<img loading="lazy">`,download 加懒加载 |
|
||||
| `views/Profile.vue` | 修改 | 缩略图改用 `<img loading="lazy">`,download 加懒加载 |
|
||||
|
||||
---
|
||||
|
||||
## 六、遗留与建议
|
||||
|
||||
| 项目 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 分镜图 base64 存储问题 | 中 | 建议生成分镜图时上传 CDN,存 URL 而非 base64,可再减少 ~2.5MB |
|
||||
| 列表分页优化 | 低 | 当前默认 size=1000 加载全部,可改为前端虚拟滚动 + 按需加载 |
|
||||
| GZIP 压缩 | 低 | 确认 Nginx/Spring Boot 开启 GZIP,145KB JSON 可压缩至 ~20KB |
|
||||
| `v-lazy` 指令清理 | 低 | `directives/lazyLoad.js` 已不再使用,可考虑删除 |
|
||||
3
frontend/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置(Vue CLI)
|
||||
VUE_APP_API_URL=http://localhost:8080/api
|
||||
NODE_ENV=development
|
||||
3
frontend/.env.development.vite
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置(Vite - 项目当前使用)
|
||||
VITE_APP_API_URL=http://localhost:8080/api
|
||||
NODE_ENV=development
|
||||
2
frontend/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VUE_APP_API_URL=/api
|
||||
NODE_ENV=production
|
||||
4
frontend/.env.production.vite
Normal file
@@ -0,0 +1,4 @@
|
||||
# Vite 环境变量(项目当前使用 Vite)
|
||||
# Vite 使用 VITE_ 前缀,而不是 VUE_APP_
|
||||
VITE_APP_API_URL=/api
|
||||
NODE_ENV=production
|
||||
81
frontend/dev-server.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 3000;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
|
||||
// 设置CORS头
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = req.url === '/' ? '/index.html' : req.url;
|
||||
|
||||
// 如果是API请求,代理到后端
|
||||
if (req.url.startsWith('/api')) {
|
||||
const http = require('http');
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 8080,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态文件服务
|
||||
const fullPath = path.join(__dirname, 'dist', filePath);
|
||||
|
||||
fs.readFile(fullPath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>开发服务器</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>开发服务器正在运行</h1>
|
||||
<p>端口: ${PORT}</p>
|
||||
<p>请先运行 <code>npm run build</code> 构建项目</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(fullPath);
|
||||
const contentType = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json'
|
||||
}[ext] || 'text/plain';
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 开发服务器运行在 http://localhost:${PORT}`);
|
||||
console.log(`🌐 网络访问: http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AIGC Demo - Vue.js Frontend</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
2331
frontend/package-lock.json
generated
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "aigc-demo-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "AIGC Demo Frontend with Vue.js",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"axios": "^1.5.0",
|
||||
"element-plus": "^2.3.8",
|
||||
"pinia": "^2.1.6",
|
||||
"qrcode": "^1.5.3",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^9.8.0",
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.3.4",
|
||||
"sass": "^1.66.1",
|
||||
"terser": "^5.44.1",
|
||||
"vite": "^4.4.9"
|
||||
},
|
||||
"keywords": [
|
||||
"vue",
|
||||
"frontend",
|
||||
"aigc"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
BIN
frontend/public/fonts/TaipeiSansTC.ttf
Normal file
BIN
frontend/public/images/backgrounds/1.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
16
frontend/public/images/backgrounds/avatar-default.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="104" height="104" viewBox="0 0 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_601_168)">
|
||||
<circle cx="52" cy="52" r="51.5" fill="#1BAFFF" stroke="white"/>
|
||||
<path d="M16.9548 9.33656C10.1123 10.3489 3.30243 13.5493 -1.94346 18.2193C-7.90619 23.5097 -12.1746 31.8372 -13.2498 40.3607C-13.5431 42.6467 -13.7386 43.2672 -14.1622 43.2672C-15.4003 43.2672 -20.2226 45.2592 -22.5686 46.7288C-34.7873 54.3379 -38.6647 70.601 -31.2032 82.8801C-27.1303 89.5094 -20.5485 94.0161 -13.2173 95.1917L-10.7409 95.5836L-9.6331 98.0329C-5.69053 106.818 1.83619 113.12 10.9595 115.243C16.3031 116.484 22.0378 116.19 27.3488 114.427C32.8554 112.565 37.7755 109.136 41.1967 104.728L43.1843 102.148L45.1067 102.899C48.3976 104.27 51.6559 104.76 55.8591 104.564C60.2904 104.368 62.5061 103.813 66.4487 101.919C75.018 97.739 80.4594 89.4115 81.1437 79.2878L81.3718 76.218L83.4897 74.1933C87.3345 70.5031 89.7131 66.4536 91.0816 61.3918C92.0591 57.7342 92.0591 51.9866 91.0816 48.329C88.5727 38.8585 81.665 31.8372 72.2485 29.1593C70.1631 28.5715 68.925 28.4409 65.3083 28.4735C61.4634 28.4735 60.5511 28.6042 58.14 29.3553C56.6085 29.8451 55.1423 30.3023 54.8816 30.3677C54.5558 30.4983 54.1648 30.0084 53.6435 28.8981C47.4527 15.574 31.715 7.18121 16.9548 9.33656ZM20.6693 43.5937C23.113 44.2795 24.7748 45.6838 25.9152 48.0351C26.644 49.5175 27.023 51.1474 27.023 52.7993V62.5348V72.2702C27.023 73.9221 26.644 75.552 25.9152 77.0345C24.7422 79.4511 23.0804 80.8227 20.5064 81.5085C18.8772 81.9657 15.4885 81.6718 14.0875 80.9533C12.2954 80.0716 10.6011 78.1775 9.91682 76.3487C9.29774 74.7158 9.26515 73.7688 9.26515 62.7307C9.26515 49.472 9.3629 48.6556 11.4156 46.2716C13.6313 43.659 17.2155 42.614 20.6693 43.5937ZM42.0438 43.757C44.1943 44.3775 46.1493 46.1083 47.0942 48.231C47.8762 49.9618 47.8762 49.9618 47.8762 62.5348C47.8762 75.1077 47.8762 75.1077 47.0942 76.8385C45.3022 80.8227 40.7405 82.6841 36.1137 81.3125C33.9306 80.6594 32.3015 79.2225 31.1936 76.9692L30.2813 75.1077V62.5348V49.9618L31.1936 48.1004C33.1486 44.0836 37.417 42.3854 42.0438 43.757Z" fill="#151515"/>
|
||||
<path d="M20.6693 43.5937C23.113 44.2795 24.7748 45.6838 25.9152 48.0351C26.644 49.5175 27.023 51.1474 27.023 52.7993V62.5348V72.2702C27.023 73.9221 26.644 75.552 25.9152 77.0345C24.7422 79.4511 23.0804 80.8227 20.5064 81.5085C18.8772 81.9657 15.4885 81.6718 14.0875 80.9533C12.2954 80.0716 10.6011 78.1775 9.91682 76.3487C9.29774 74.7158 9.26515 73.7688 9.26515 62.7307C9.26515 49.472 9.3629 48.6556 11.4156 46.2716C13.6313 43.659 17.2155 42.614 20.6693 43.5937Z" fill="#151515"/>
|
||||
<path d="M42.0438 43.757C44.1943 44.3775 46.1493 46.1083 47.0942 48.231C47.8762 49.9618 47.8762 49.9618 47.8762 62.5348C47.8762 75.1077 47.8762 75.1077 47.0942 76.8385C45.3022 80.8227 40.7405 82.6841 36.1137 81.3125C33.9306 80.6594 32.3015 79.2225 31.1936 76.9692L30.2813 75.1077V62.5348V49.9618L31.1936 48.1004C33.1486 44.0836 37.417 42.3854 42.0438 43.757Z" fill="#151515"/>
|
||||
<rect x="36.3332" y="45.9877" width="7.92593" height="17.1728" rx="3.96296" fill="white"/>
|
||||
<rect x="49.543" y="45.9875" width="7.92593" height="17.1728" rx="3.96296" fill="white"/>
|
||||
</g>
|
||||
<rect x="0.5" y="0.5" width="103" height="103" rx="51.5" stroke="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_601_168">
|
||||
<rect width="104" height="104" rx="52" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
233
frontend/public/images/backgrounds/login-bg.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/images/backgrounds/login_bg.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
15
frontend/public/images/backgrounds/logo-admin.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="194" height="41" viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM93.1291 38.6957C91.2536 38.6957 89.6317 38.2973 88.2633 37.5004C86.903 36.6955 85.8526 35.5766 85.112 34.1439C84.3715 32.7031 84.0012 31.0328 84.0012 29.1332C84.0012 27.2175 84.3715 25.5432 85.112 24.1105C85.8526 22.6697 86.903 21.5508 88.2633 20.7539C89.6317 19.949 91.2536 19.5466 93.1291 19.5466C95.0046 19.5466 96.6225 19.949 97.9828 20.7539C99.3511 21.5508 100.406 22.6697 101.146 24.1105C101.887 25.5432 102.257 27.2175 102.257 29.1332C102.257 31.0328 101.887 32.7031 101.146 34.1439C100.406 35.5766 99.3511 36.6955 97.9828 37.5004C96.6225 38.2973 95.0046 38.6957 93.1291 38.6957ZM93.1532 34.7113C94.0065 34.7113 94.7188 34.4699 95.2903 33.9869C95.8618 33.4959 96.2924 32.8278 96.5822 31.9826C96.88 31.1375 97.0289 30.1756 97.0289 29.097C97.0289 28.0184 96.88 27.0565 96.5822 26.2113C96.2924 25.3662 95.8618 24.6981 95.2903 24.2071C94.7188 23.7161 94.0065 23.4706 93.1532 23.4706C92.292 23.4706 91.5675 23.7161 90.9799 24.2071C90.4004 24.6981 89.9617 25.3662 89.6639 26.2113C89.3741 27.0565 89.2292 28.0184 89.2292 29.097C89.2292 30.1756 89.3741 31.1375 89.6639 31.9826C89.9617 32.8278 90.4004 33.4959 90.9799 33.9869C91.5675 34.4699 92.292 34.7113 93.1532 34.7113ZM110.745 27.6119V38.3335H105.601V19.788H110.503V23.0601H110.721C111.131 21.9815 111.819 21.1282 112.785 20.5004C113.751 19.8645 114.922 19.5466 116.299 19.5466C117.587 19.5466 118.71 19.8283 119.667 20.3917C120.625 20.9552 121.37 21.7601 121.901 22.8065C122.432 23.8449 122.698 25.0844 122.698 26.5253V38.3335H117.555V27.4429C117.563 26.3079 117.273 25.4225 116.685 24.7866C116.098 24.1427 115.289 23.8207 114.258 23.8207C113.566 23.8207 112.954 23.9696 112.423 24.2674C111.9 24.5653 111.489 24.9999 111.192 25.5714C110.902 26.1349 110.753 26.815 110.745 27.6119ZM135.131 38.6957C133.256 38.6957 131.634 38.2973 130.265 37.5004C128.905 36.6955 127.855 35.5766 127.114 34.1439C126.373 32.7031 126.003 31.0328 126.003 29.1332C126.003 27.2175 126.373 25.5432 127.114 24.1105C127.855 22.6697 128.905 21.5508 130.265 20.7539C131.634 19.949 133.256 19.5466 135.131 19.5466C137.007 19.5466 138.624 19.949 139.985 20.7539C141.353 21.5508 142.408 22.6697 143.148 24.1105C143.889 25.5432 144.259 27.2175 144.259 29.1332C144.259 31.0328 143.889 32.7031 143.148 34.1439C142.408 35.5766 141.353 36.6955 139.985 37.5004C138.624 38.2973 137.007 38.6957 135.131 38.6957ZM135.155 34.7113C136.008 34.7113 136.721 34.4699 137.292 33.9869C137.864 33.4959 138.294 32.8278 138.584 31.9826C138.882 31.1375 139.031 30.1756 139.031 29.097C139.031 28.0184 138.882 27.0565 138.584 26.2113C138.294 25.3662 137.864 24.6981 137.292 24.2071C136.721 23.7161 136.008 23.4706 135.155 23.4706C134.294 23.4706 133.569 23.7161 132.982 24.2071C132.402 24.6981 131.964 25.3662 131.666 26.2113C131.376 27.0565 131.231 28.0184 131.231 29.097C131.231 30.1756 131.376 31.1375 131.666 31.9826C131.964 32.8278 132.402 33.4959 132.982 33.9869C133.569 34.4699 134.294 34.7113 135.155 34.7113ZM150.797 38.3335L145.75 19.788H150.954L153.827 32.2483H153.996L156.991 19.788H162.098L165.141 32.1758H165.298L168.123 19.788H173.315L168.28 38.3335H162.835L159.647 26.6701H159.418L156.23 38.3335H150.797Z" fill="#1D2129"/>
|
||||
<g clip-path="url(#clip0_445_10776)">
|
||||
<path d="M5.74048 1.6455C2.43981 1.6455 0.0100346 1.57286 0.000244138 1.6455C0.000244144 1.71889 2.11281 5.37372 4.7063 9.58593C7.28518 13.8128 12.3555 22.0903 15.9543 27.9609L20.8684 35.9883C21.8795 37.6395 23.6773 38.6455 25.6135 38.6455L26.4299 38.6455C26.4299 38.6455 31.0566 38.7202 31.9963 37.2227C32.2253 36.8286 32.3459 36.3806 32.3459 35.9248L32.3459 21.8994L31.1799 21.8994L31.1799 26.5967C31.1799 31.1021 31.1657 31.3081 30.8889 31.5869C30.7286 31.7483 30.4661 31.8799 30.3059 31.8799C29.9854 31.8798 29.7815 31.5857 27.1448 27.2568C26.256 25.8038 24.0844 22.237 22.2922 19.3311C20.5147 16.4251 17.3532 11.2594 15.2698 7.85449L11.467 1.64551L5.74048 1.6455ZM31.6614 2.99609C31.4428 4.17012 30.5249 6.06336 29.6653 7.10547C28.456 8.60245 26.4303 9.83514 24.5071 10.2314C23.4726 10.4516 23.1959 10.5986 23.8079 10.5986C24.0266 10.5987 24.58 10.701 25.0315 10.833C28.2661 11.7576 31.0493 14.7672 31.6467 17.9814L31.8362 18.9795L32.011 18.1133C32.5647 15.3247 34.4441 12.8149 36.9065 11.5674C38.0138 10.995 39.2814 10.5986 39.9807 10.5986C40.6216 10.5986 40.4029 10.4956 39.2083 10.2461C37.4599 9.87917 36.1048 9.11605 34.6624 7.66309C33.2637 6.26884 32.5495 5.02109 32.0833 3.17187L31.8215 2.11523L31.6614 2.99609Z" fill="url(#paint0_linear_445_10776)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_445_10776" x1="23.4864" y1="5.39407" x2="32.9094" y2="11.1241" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.0001" stop-color="#33DDE5"/>
|
||||
<stop offset="1" stop-color="#0F9CFF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_445_10776">
|
||||
<rect width="40.3334" height="40.3334" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
BIN
frontend/public/images/backgrounds/logo.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
15
frontend/public/images/backgrounds/logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="101" height="21" viewBox="0 0 101 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.4343 7.58893L31.5459 17.3698H31.6653L34.7831 7.58893H37.8003L33.3625 20.4624H29.855L25.4108 7.58893H28.4343ZM39.2995 20.4624V10.8073H41.9773V20.4624H39.2995ZM40.6447 9.56269C40.2466 9.56269 39.905 9.43069 39.6201 9.16668C39.3393 8.89848 39.1989 8.5779 39.1989 8.20494C39.1989 7.83617 39.3393 7.51978 39.6201 7.25577C39.905 6.98758 40.2466 6.85348 40.6447 6.85348C41.0428 6.85348 41.3822 6.98758 41.663 7.25577C41.9479 7.51978 42.0904 7.83617 42.0904 8.20494C42.0904 8.5779 41.9479 8.89848 41.663 9.16668C41.3822 9.43069 41.0428 9.56269 40.6447 9.56269ZM48.4847 20.651C47.5083 20.651 46.6639 20.4435 45.9515 20.0287C45.2433 19.6096 44.6964 19.0271 44.3109 18.2812C43.9254 17.5311 43.7326 16.6615 43.7326 15.6726C43.7326 14.6752 43.9254 13.8036 44.3109 13.0576C44.6964 12.3075 45.2433 11.725 45.9515 11.3102C46.6639 10.8911 47.5083 10.6816 48.4847 10.6816C49.4611 10.6816 50.3034 10.8911 51.0116 11.3102C51.724 11.725 52.273 12.3075 52.6585 13.0576C53.0441 13.8036 53.2368 14.6752 53.2368 15.6726C53.2368 16.6615 53.0441 17.5311 52.6585 18.2812C52.273 19.0271 51.724 19.6096 51.0116 20.0287C50.3034 20.4435 49.4611 20.651 48.4847 20.651ZM48.4973 18.5766C48.9415 18.5766 49.3124 18.4509 49.6099 18.1995C49.9074 17.9439 50.1316 17.596 50.2825 17.156C50.4375 16.716 50.5151 16.2152 50.5151 15.6537C50.5151 15.0922 50.4375 14.5914 50.2825 14.1514C50.1316 13.7114 49.9074 13.3636 49.6099 13.1079C49.3124 12.8523 48.9415 12.7245 48.4973 12.7245C48.0489 12.7245 47.6717 12.8523 47.3658 13.1079C47.0641 13.3636 46.8357 13.7114 46.6807 14.1514C46.5298 14.5914 46.4544 15.0922 46.4544 15.6537C46.4544 16.2152 46.5298 16.716 46.6807 17.156C46.8357 17.596 47.0641 17.9439 47.3658 18.1995C47.6717 18.4509 48.0489 18.5766 48.4973 18.5766ZM57.6558 14.8805V20.4624H54.978V10.8073H57.5301V12.5108H57.6432C57.857 11.9492 58.2153 11.505 58.7181 11.1782C59.221 10.8471 59.8307 10.6816 60.5473 10.6816C61.2178 10.6816 61.8024 10.8282 62.3011 11.1216C62.7998 11.4149 63.1874 11.834 63.464 12.3788C63.7405 12.9193 63.8788 13.5647 63.8788 14.3148V20.4624H61.2011V14.7925C61.2052 14.2017 61.0544 13.7407 60.7485 13.4096C60.4426 13.0744 60.0214 12.9068 59.485 12.9068C59.1246 12.9068 58.8061 12.9843 58.5296 13.1394C58.2572 13.2944 58.0434 13.5207 57.8884 13.8182C57.7375 14.1116 57.66 14.4657 57.6558 14.8805ZM70.3517 20.651C69.3753 20.651 68.5309 20.4435 67.8185 20.0287C67.1103 19.6096 66.5634 19.0271 66.1779 18.2812C65.7924 17.5311 65.5996 16.6615 65.5996 15.6726C65.5996 14.6752 65.7924 13.8036 66.1779 13.0576C66.5634 12.3075 67.1103 11.725 67.8185 11.3102C68.5309 10.8911 69.3753 10.6816 70.3517 10.6816C71.3281 10.6816 72.1704 10.8911 72.8786 11.3102C73.591 11.725 74.14 12.3075 74.5255 13.0576C74.9111 13.8036 75.1038 14.6752 75.1038 15.6726C75.1038 16.6615 74.9111 17.5311 74.5255 18.2812C74.14 19.0271 73.591 19.6096 72.8786 20.0287C72.1704 20.4435 71.3281 20.651 70.3517 20.651ZM70.3643 18.5766C70.8085 18.5766 71.1794 18.4509 71.4769 18.1995C71.7744 17.9439 71.9986 17.596 72.1495 17.156C72.3045 16.716 72.3821 16.2152 72.3821 15.6537C72.3821 15.0922 72.3045 14.5914 72.1495 14.1514C71.9986 13.7114 71.7744 13.3636 71.4769 13.1079C71.1794 12.8523 70.8085 12.7245 70.3643 12.7245C69.9159 12.7245 69.5387 12.8523 69.2328 13.1079C68.9311 13.3636 68.7027 13.7114 68.5477 14.1514C68.3968 14.5914 68.3214 15.0922 68.3214 15.6537C68.3214 16.2152 68.3968 16.716 68.5477 17.156C68.7027 17.596 68.9311 17.9439 69.2328 18.1995C69.5387 18.4509 69.9159 18.5766 70.3643 18.5766ZM78.5076 20.4624L75.8801 10.8073H78.5894L80.0854 17.2943H80.1734L81.7323 10.8073H84.3912L85.9753 17.2566H86.057L87.5279 10.8073H90.2308L87.6096 20.4624H84.7747L83.1152 14.3902H82.9958L81.3363 20.4624H78.5076Z" fill="white"/>
|
||||
<g clip-path="url(#clip0_287_18768)">
|
||||
<path d="M2.98853 0.856444C1.27414 0.856441 0.0111275 0.81906 0.000244139 0.856443C0.000244136 0.894647 1.10026 2.79734 2.45044 4.99023C3.79308 7.19083 6.43228 11.5012 8.30591 14.5576L10.8645 18.7363C11.3909 19.5959 12.3272 20.1201 13.3352 20.1201L13.76 20.1201C13.76 20.1201 16.1679 20.1583 16.6575 19.3789C16.7767 19.1737 16.8401 18.9404 16.8401 18.7031L16.8401 11.4014L16.2327 11.4014L16.2327 13.8467C16.2327 16.1919 16.2254 16.2992 16.0813 16.4443C15.9979 16.5284 15.861 16.5977 15.7776 16.5977C15.6109 16.5975 15.5042 16.4431 14.1321 14.1904C13.6694 13.434 12.5387 11.5774 11.6057 10.0645C10.6803 8.55154 9.03419 5.86157 7.94946 4.08887L5.96997 0.856444L2.98853 0.856444ZM16.4836 1.55957C16.3699 2.17078 15.8921 3.15667 15.4446 3.69922C14.815 4.47857 13.7603 5.12082 12.759 5.32715C12.2205 5.44176 12.0762 5.51758 12.3948 5.51758C12.5085 5.51759 12.7965 5.57095 13.0315 5.63965C14.7155 6.12103 16.1648 7.68795 16.4758 9.36133L16.5745 9.88086L16.6653 9.42969C16.9536 7.97804 17.9323 6.67195 19.2141 6.02246C19.7906 5.72446 20.4506 5.51758 20.8147 5.51758C21.1484 5.51757 21.0343 5.46388 20.4124 5.33398C19.5023 5.14293 18.797 4.74562 18.0461 3.98926C17.318 3.26338 16.9461 2.61411 16.7034 1.65137L16.5667 1.10156L16.4836 1.55957Z" fill="url(#paint0_linear_287_18768)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_287_18768" x1="12.2274" y1="2.80849" x2="17.1333" y2="5.79162" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.0001" stop-color="#33DDE5"/>
|
||||
<stop offset="1" stop-color="#0F9CFF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_287_18768">
|
||||
<rect width="20.9983" height="20.9983" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
9
frontend/public/images/backgrounds/welcome-bg.svg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
frontend/public/images/backgrounds/welcome_bg.jpg
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
frontend/public/images/login.png
Normal file
|
After Width: | Height: | Size: 848 KiB |
BIN
frontend/public/images/welcome.jpg
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
703
frontend/src/App.vue
Normal file
@@ -0,0 +1,703 @@
|
||||
<template>
|
||||
<el-config-provider :locale="elementLocale">
|
||||
<div id="app" :data-route="route.name">
|
||||
<!-- 全屏背景层 -->
|
||||
<div class="fullscreen-background" :class="route.name"></div>
|
||||
|
||||
<!-- 导航栏 - 根据路由条件显示 -->
|
||||
<NavBar v-if="shouldShowNavBar" />
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main :class="{ 'with-navbar': shouldShowNavBar }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- 页脚 - 根据路由条件显示 -->
|
||||
<Footer v-if="shouldShowFooter" />
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import en from 'element-plus/dist/locale/en.mjs'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import Footer from '@/components/Footer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
|
||||
// 动态计算 Element Plus 的语言配置
|
||||
const elementLocale = computed(() => {
|
||||
console.log('[App.vue] 当前语言切换为:', locale.value)
|
||||
return locale.value === 'zh' ? zhCn : en
|
||||
})
|
||||
|
||||
// 计算是否显示导航栏和页脚
|
||||
const shouldShowNavBar = computed(() => {
|
||||
// 所有页面都不显示导航栏
|
||||
return false
|
||||
})
|
||||
|
||||
const shouldShowFooter = computed(() => {
|
||||
// 所有页面都是全屏固定布局,不显示页脚
|
||||
return false
|
||||
})
|
||||
|
||||
// 监听路由变化,动态设置页面样式
|
||||
watch(route, (newRoute) => {
|
||||
console.log('路由变化:', newRoute.name)
|
||||
}, { immediate: true })
|
||||
|
||||
console.log('App.vue 加载成功')
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #000510;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #000510;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 全屏背景层 */
|
||||
.fullscreen-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
z-index: -10; /* 确保在最底层 */
|
||||
pointer-events: none;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main.with-navbar {
|
||||
padding-top: 0; /* NavBar 是 fixed 定位,不需要 padding-top */
|
||||
}
|
||||
|
||||
/* 确保登录页面内容正确显示 */
|
||||
#app[data-route="Login"] main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 登录页面特殊背景处理 */
|
||||
#app[data-route="Login"] .fullscreen-background {
|
||||
background: url('/images/backgrounds/login.png') center/cover no-repeat;
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ========== 页面特殊样式 ========== */
|
||||
|
||||
/* 欢迎页面 - 参考Welcome页面样式 */
|
||||
.fullscreen-background.Welcome {
|
||||
background: url('/images/backgrounds/welcome.jpg') center/cover no-repeat;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* 首页 - 深色科技风全屏覆盖 */
|
||||
.fullscreen-background.Home {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(20, 40, 80, 0.8) 0%, rgba(10, 20, 40, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
|
||||
animation: homeGlow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.Home::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(100, 150, 255, 0.4) 0%, rgba(50, 100, 200, 0.2) 40%, transparent 70%);
|
||||
animation: homeCentralGlow 7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes homeGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes homeCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 个人主页 - 深色科技风全屏覆盖 */
|
||||
.fullscreen-background.Profile {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 30, 60, 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%);
|
||||
animation: profileGlow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.Profile::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60%;
|
||||
height: 40%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(64, 158, 255, 0.2) 0%, rgba(103, 194, 58, 0.1) 30%, transparent 70%);
|
||||
animation: profileCentralGlow 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes profileGlow {
|
||||
0% { opacity: 0.9; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes profileCentralGlow {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* 订单管理 - 深色商务风全屏覆盖 */
|
||||
.fullscreen-background.Orders {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(80, 20, 60, 0.8) 0%, rgba(40, 10, 30, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #1a0a1a 50%, #2a0a2a 100%);
|
||||
animation: ordersGlow 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.Orders::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 75%;
|
||||
height: 55%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(240, 147, 251, 0.3) 0%, rgba(245, 87, 108, 0.2) 40%, transparent 70%);
|
||||
animation: ordersCentralGlow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ordersGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes ordersCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
/* 支付记录 - 深色金色风全屏覆盖 */
|
||||
.fullscreen-background.Payments {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(100, 80, 20, 0.8) 0%, rgba(50, 40, 10, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #1a1a0a 50%, #2a2a0a 100%);
|
||||
animation: paymentsGlow 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.Payments::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 45%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(246, 211, 101, 0.3) 0%, rgba(253, 160, 133, 0.2) 40%, transparent 70%);
|
||||
animation: paymentsCentralGlow 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes paymentsGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes paymentsCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: translate(-50%, -50%) scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
/* 我的作品 - 深色创意风全屏覆盖 */
|
||||
.fullscreen-background.MyWorks {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(20, 60, 40, 0.8) 0%, rgba(10, 30, 20, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #0a1a0a 50%, #0a2a0a 100%);
|
||||
animation: worksGlow 7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.MyWorks::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(168, 237, 234, 0.3) 0%, rgba(254, 214, 227, 0.2) 40%, transparent 70%);
|
||||
animation: worksCentralGlow 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes worksGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes worksCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1.05) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文生视频 - 深色蓝色科技风全屏覆盖 */
|
||||
.fullscreen-background.TextToVideo {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(20, 40, 80, 0.8) 0%, rgba(10, 20, 40, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #0a1a2e 50%, #1a2a4e 100%);
|
||||
animation: textVideoGlow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.TextToVideo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 75%;
|
||||
height: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.3) 40%, transparent 70%);
|
||||
animation: textVideoCentralGlow 7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes textVideoGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes textVideoCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* 图生视频 - 深色紫色梦幻风全屏覆盖 */
|
||||
.fullscreen-background.ImageToVideo {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(40, 20, 60, 0.8) 0%, rgba(20, 10, 30, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #1a0a2e 50%, #2a0a4e 100%);
|
||||
animation: imageVideoGlow 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.ImageToVideo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 80%;
|
||||
height: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(168, 192, 255, 0.3) 0%, rgba(63, 43, 150, 0.3) 40%, transparent 70%);
|
||||
animation: imageVideoCentralGlow 9s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes imageVideoGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes imageVideoCentralGlow {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* 分镜视频 - 深色橙色活力风全屏覆盖 */
|
||||
.fullscreen-background.StoryboardVideo {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(80, 40, 20, 0.8) 0%, rgba(40, 20, 10, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #2a0a0a 50%, #4a0a0a 100%);
|
||||
animation: storyboardGlow 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.StoryboardVideo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 45%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(255, 154, 158, 0.3) 0%, rgba(254, 207, 239, 0.2) 40%, transparent 70%);
|
||||
animation: storyboardCentralGlow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes storyboardGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes storyboardCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: translate(-50%, -50%) scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
/* 会员订阅 - 深色奢华金色风全屏覆盖 */
|
||||
.fullscreen-background.Subscription {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(100, 80, 40, 0.8) 0%, rgba(50, 40, 20, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #2a2a0a 50%, #4a4a0a 100%);
|
||||
animation: subscriptionGlow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.Subscription::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 75%;
|
||||
height: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(255, 215, 0, 0.3) 0%, rgba(252, 182, 159, 0.2) 40%, transparent 70%);
|
||||
animation: subscriptionCentralGlow 7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes subscriptionGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes subscriptionCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: translate(-50%, -50%) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* 管理员页面 - 深色专业科技风全屏覆盖 */
|
||||
.fullscreen-background.AdminDashboard,
|
||||
.fullscreen-background.AdminOrders {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 20, 40, 0.9) 0%, rgba(0, 10, 20, 0.95) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #000000 0%, #0a0a0a 50%, #1a1a1a 100%);
|
||||
animation: adminGlow 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.AdminDashboard::before,
|
||||
.fullscreen-background.AdminOrders::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60%;
|
||||
height: 40%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 150, 255, 0.2) 0%, rgba(255, 0, 150, 0.1) 30%, rgba(0, 255, 150, 0.1) 60%, transparent 80%);
|
||||
animation: adminCentralGlow 9s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes adminGlow {
|
||||
0% { opacity: 0.9; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes adminCentralGlow {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* 注册页面 - 深色清新风全屏覆盖 */
|
||||
.fullscreen-background.Register {
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(20, 40, 80, 0.8) 0%, rgba(10, 20, 40, 0.9) 50%, rgba(0, 0, 0, 1) 100%),
|
||||
linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
|
||||
animation: registerGlow 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.fullscreen-background.Register::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 45%;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.1) 0%, rgba(100, 150, 255, 0.1) 40%, transparent 70%);
|
||||
animation: registerCentralGlow 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes registerGlow {
|
||||
0% { opacity: 0.8; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes registerCentralGlow {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translate(-50%, -50%) scale(1.02) rotate(1deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 内容层级确保在所有背景效果之上 */
|
||||
#app main > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 确保导航栏在背景之上 */
|
||||
#app .navbar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 确保页脚在背景之上 */
|
||||
#app .footer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端减少动画效果 */
|
||||
#app[data-route]::before {
|
||||
animation-duration: 10s;
|
||||
}
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 修复 el-select 选中值不显示的问题 */
|
||||
.el-select .el-select__wrapper .el-select__selected-item {
|
||||
color: inherit !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.el-select .el-select__wrapper .el-select__selection {
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.el-select .el-select__wrapper .el-select__input-wrapper {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* 移除 el-dialog 的所有可能的白色边框 */
|
||||
.payment-modal-dialog,
|
||||
.payment-modal-dialog.el-dialog,
|
||||
.payment-modal-dialog.el-dialog--center,
|
||||
.payment-modal-dialog.el-dialog--center.payment-modal,
|
||||
.el-overlay-dialog .payment-modal-dialog,
|
||||
.el-overlay-dialog .payment-modal-dialog.el-dialog,
|
||||
.el-overlay-dialog .payment-modal-dialog.el-dialog--center,
|
||||
.el-overlay-dialog .payment-modal-dialog.el-dialog--center.payment-modal,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog.el-dialog,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog.el-dialog--center,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog.el-dialog--center.payment-modal {
|
||||
background: #000000 !important;
|
||||
background-color: #000000 !important;
|
||||
border: none !important;
|
||||
border-width: 0 !important;
|
||||
border-style: none !important;
|
||||
border-color: transparent !important;
|
||||
outline: none !important;
|
||||
outline-width: 0 !important;
|
||||
outline-style: none !important;
|
||||
outline-color: transparent !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.payment-modal-dialog .el-dialog__body,
|
||||
.payment-modal-dialog .el-dialog__header {
|
||||
background: #000000 !important;
|
||||
background-color: #000000 !important;
|
||||
border: none !important;
|
||||
border-width: 0 !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 全局覆盖所有可能的对话框背景 */
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .el-dialog,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .el-dialog.el-dialog--center,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .el-dialog.el-dialog--center.payment-modal,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog.el-dialog,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog.el-dialog--center,
|
||||
.el-overlay.payment-modal-overlay.el-modal-dialog .payment-modal-dialog.el-dialog--center.payment-modal,
|
||||
.el-overlay-dialog .el-dialog.el-dialog--center.payment-modal,
|
||||
.el-overlay-dialog .payment-modal-dialog.el-dialog--center.payment-modal,
|
||||
.el-dialog.el-dialog--center.payment-modal,
|
||||
.payment-modal-dialog.el-dialog.el-dialog--center.payment-modal,
|
||||
/* 使用属性选择器覆盖所有包含 payment-modal 的对话框 */
|
||||
[class*="payment-modal"][class*="el-dialog"],
|
||||
[class*="payment-modal"][class*="el-dialog--center"],
|
||||
.el-dialog[class*="payment-modal"],
|
||||
.payment-modal-dialog[class*="el-dialog"] {
|
||||
background: #000000 !important;
|
||||
background-color: #000000 !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #050515;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/api/analytics.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import api from './request'
|
||||
|
||||
// 获取日活用户趋势数据
|
||||
export const getDailyActiveUsersTrend = (year = '2024', granularity = 'monthly') => {
|
||||
return api.get('/analytics/daily-active-users', {
|
||||
params: { year, granularity }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户活跃度概览
|
||||
export const getUserActivityOverview = () => {
|
||||
return api.get('/analytics/user-activity-overview')
|
||||
}
|
||||
|
||||
// 获取用户活跃度热力图数据
|
||||
export const getUserActivityHeatmap = (year = '2024') => {
|
||||
return api.get('/analytics/user-activity-heatmap', {
|
||||
params: { year }
|
||||
})
|
||||
}
|
||||
83
frontend/src/api/auth.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import api from './request'
|
||||
|
||||
// 认证相关API
|
||||
// 注意:用户名密码登录已禁用,仅支持邮箱验证码登录
|
||||
|
||||
// 邮箱验证码登录(唯一登录方式)
|
||||
export const loginWithEmail = (credentials) => {
|
||||
return api.post('/auth/login/email', credentials)
|
||||
}
|
||||
|
||||
// 向后兼容(已禁用)
|
||||
export const login = (credentials) => {
|
||||
return api.post('/auth/login', credentials)
|
||||
}
|
||||
|
||||
export const register = (userData) => {
|
||||
return api.post('/auth/register', userData)
|
||||
}
|
||||
|
||||
export const logout = () => {
|
||||
return api.post('/auth/logout')
|
||||
}
|
||||
|
||||
export const getCurrentUser = () => {
|
||||
return api.get('/auth/me')
|
||||
}
|
||||
|
||||
// 修改当前登录用户密码
|
||||
export const changePassword = (data) => {
|
||||
return api.post('/auth/change-password', data)
|
||||
}
|
||||
|
||||
// 用户相关API
|
||||
export const getUsers = (params) => {
|
||||
return api.get('/users', { params })
|
||||
}
|
||||
|
||||
export const getUserById = (id) => {
|
||||
return api.get(`/users/${id}`)
|
||||
}
|
||||
|
||||
export const createUser = (userData) => {
|
||||
return api.post('/users', userData)
|
||||
}
|
||||
|
||||
export const updateUser = (id, userData) => {
|
||||
return api.put(`/users/${id}`, userData)
|
||||
}
|
||||
|
||||
export const deleteUser = (id) => {
|
||||
return api.delete(`/users/${id}`)
|
||||
}
|
||||
|
||||
// 检查用户名是否存在
|
||||
export const checkUsernameExists = (username) => {
|
||||
return api.get(`/public/users/exists/username`, {
|
||||
params: { value: username }
|
||||
})
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
export const checkEmailExists = (email) => {
|
||||
return api.get(`/public/users/exists/email`, {
|
||||
params: { value: email }
|
||||
})
|
||||
}
|
||||
|
||||
// 发送邮箱验证码
|
||||
export const sendEmailCode = (email) => {
|
||||
return api.post('/verification/email/send', { email })
|
||||
}
|
||||
|
||||
// 开发环境:设置验证码(用于开发测试)
|
||||
export const setDevEmailCode = (email, code) => {
|
||||
return api.post('/verification/email/dev-set', { email, code })
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
88
frontend/src/api/cleanup.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// 任务清理API服务
|
||||
import request from './request'
|
||||
import { getApiBaseURL } from '@/utils/apiHelper'
|
||||
|
||||
export const cleanupApi = {
|
||||
// 获取清理统计信息
|
||||
getCleanupStats() {
|
||||
return request({
|
||||
url: '/cleanup/cleanup-stats',
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
// 执行完整清理
|
||||
performFullCleanup() {
|
||||
return request({
|
||||
url: '/cleanup/full-cleanup',
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
// 清理指定用户任务
|
||||
cleanupUserTasks(username) {
|
||||
return request({
|
||||
url: `/cleanup/user-tasks/${username}`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
// 获取清理统计信息(原始fetch方式,用于测试)
|
||||
async getCleanupStatsRaw() {
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseURL()}/cleanup/cleanup-stats`)
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
} else {
|
||||
throw new Error('获取统计信息失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 执行完整清理(原始fetch方式,用于测试)
|
||||
async performFullCleanupRaw() {
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseURL()}/cleanup/full-cleanup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
} else {
|
||||
throw new Error('执行完整清理失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行完整清理失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 清理指定用户任务(原始fetch方式,用于测试)
|
||||
async cleanupUserTasksRaw(username) {
|
||||
try {
|
||||
const response = await fetch(`/api/cleanup/user-tasks/${username}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
} else {
|
||||
throw new Error('清理用户任务失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理用户任务失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default cleanupApi
|
||||
38
frontend/src/api/dashboard.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import api from './request'
|
||||
|
||||
// 获取仪表盘概览数据
|
||||
export const getDashboardOverview = () => {
|
||||
return api.get('/dashboard/overview')
|
||||
}
|
||||
|
||||
// 获取月度收入趋势数据
|
||||
export const getMonthlyRevenue = (year = '2024') => {
|
||||
return api.get('/dashboard/monthly-revenue', {
|
||||
params: { year }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户转化率数据
|
||||
export const getConversionRate = (year = null) => {
|
||||
const params = year ? { year } : {}
|
||||
return api.get('/dashboard/conversion-rate', { params })
|
||||
}
|
||||
|
||||
// 获取最近订单数据
|
||||
export const getRecentOrders = (limit = 10) => {
|
||||
return api.get('/dashboard/recent-orders', {
|
||||
params: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取系统状态
|
||||
export const getSystemStatus = () => {
|
||||
return api.get('/dashboard/system-status')
|
||||
}
|
||||
|
||||
// 获取日活用户趋势数据
|
||||
export const getDailyActiveUsersTrend = (year = '2024', granularity = 'monthly') => {
|
||||
return api.get('/analytics/daily-active-users', {
|
||||
params: { year, granularity }
|
||||
})
|
||||
}
|
||||
249
frontend/src/api/imageToVideo.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 图生视频API服务
|
||||
*/
|
||||
export const imageToVideoApi = {
|
||||
/**
|
||||
* 创建图生视频任务
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {File} params.firstFrame - 首帧图片
|
||||
* @param {File} params.lastFrame - 尾帧图片(可选)
|
||||
* @param {string} params.prompt - 描述文字
|
||||
* @param {string} params.aspectRatio - 视频比例
|
||||
* @param {number} params.duration - 视频时长
|
||||
* @param {boolean} params.hdMode - 是否高清模式
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
createTask(params) {
|
||||
// 参数验证
|
||||
if (!params) {
|
||||
throw new Error('参数不能为空')
|
||||
}
|
||||
if (!params.firstFrame) {
|
||||
throw new Error('首帧图片不能为空')
|
||||
}
|
||||
if (!params.prompt || params.prompt.trim() === '') {
|
||||
throw new Error('描述文字不能为空')
|
||||
}
|
||||
if (!params.aspectRatio) {
|
||||
throw new Error('视频比例不能为空')
|
||||
}
|
||||
if (!params.duration || ![5, 10, 15].includes(params.duration)) {
|
||||
throw new Error('视频时长必须为5秒、10秒或15秒')
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
// 添加必填参数
|
||||
formData.append('firstFrame', params.firstFrame)
|
||||
formData.append('prompt', params.prompt.trim())
|
||||
formData.append('aspectRatio', params.aspectRatio)
|
||||
formData.append('duration', params.duration.toString())
|
||||
formData.append('hdMode', params.hdMode.toString())
|
||||
formData.append('videoModel', params.videoModel || 'grok-video-3')
|
||||
|
||||
// 添加可选参数
|
||||
if (params.lastFrame) {
|
||||
formData.append('lastFrame', params.lastFrame)
|
||||
}
|
||||
|
||||
return request({
|
||||
url: '/image-to-video/create',
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 通过图片URL创建图生视频任务(用于"做同款"功能)
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {string} params.imageUrl - 图片URL
|
||||
* @param {string} params.prompt - 描述文字
|
||||
* @param {string} params.aspectRatio - 视频比例
|
||||
* @param {number} params.duration - 视频时长
|
||||
* @param {boolean} params.hdMode - 是否高清模式
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
createTaskByUrl(params) {
|
||||
if (!params) {
|
||||
throw new Error('参数不能为空')
|
||||
}
|
||||
if (!params.imageUrl) {
|
||||
throw new Error('图片URL不能为空')
|
||||
}
|
||||
if (!params.prompt || params.prompt.trim() === '') {
|
||||
throw new Error('描述文字不能为空')
|
||||
}
|
||||
|
||||
return request({
|
||||
url: '/image-to-video/create-by-url',
|
||||
method: 'POST',
|
||||
data: {
|
||||
imageUrl: params.imageUrl,
|
||||
prompt: params.prompt.trim(),
|
||||
aspectRatio: params.aspectRatio || '3:2',
|
||||
duration: params.duration || 5,
|
||||
hdMode: params.hdMode || false,
|
||||
videoModel: params.videoModel || 'grok-video-3'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户任务列表
|
||||
* @param {number} page - 页码
|
||||
* @param {number} size - 每页数量
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
getTasks(page = 0, size = 10) {
|
||||
return request({
|
||||
url: '/image-to-video/tasks',
|
||||
method: 'GET',
|
||||
params: { page, size }
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
getTaskDetail(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}`,
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
getTaskStatus(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}/status`,
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除任务
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
deleteTask(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 重试失败的任务
|
||||
* 复用原task_id和已上传的图片,重新提交至外部API
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
retryTask(taskId) {
|
||||
return request({
|
||||
url: `/image-to-video/tasks/${taskId}/retry`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
* @param {Function} onProgress - 进度回调
|
||||
* @param {Function} onComplete - 完成回调
|
||||
* @param {Function} onError - 错误回调
|
||||
* @returns {Function} 停止轮询的函数
|
||||
*/
|
||||
pollTaskStatus(taskId, onProgress, onComplete, onError) {
|
||||
let isPolling = true
|
||||
let pollCount = 0
|
||||
const maxPolls = 30 // 最大轮询次数(1小时,每2分钟一次)
|
||||
|
||||
const poll = async () => {
|
||||
if (!isPolling || pollCount >= maxPolls) {
|
||||
if (pollCount >= maxPolls) {
|
||||
onError && onError(new Error('任务超时'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/image-to-video/tasks/${taskId}/status`,
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
// 检查响应是否有效
|
||||
if (!response || !response.data || !response.data.success) {
|
||||
onError && onError(new Error('获取任务状态失败'))
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
const taskData = response.data.data
|
||||
|
||||
// 检查taskData是否有效
|
||||
if (!taskData || !taskData.status) {
|
||||
onError && onError(new Error('无效的任务数据'))
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
if (taskData.status === 'COMPLETED') {
|
||||
onComplete && onComplete(taskData)
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
if (taskData.status === 'FAILED' || taskData.status === 'CANCELLED') {
|
||||
console.error('任务失败:', {
|
||||
taskId: taskId,
|
||||
status: taskData.status,
|
||||
errorMessage: taskData.errorMessage,
|
||||
pollCount: pollCount
|
||||
})
|
||||
onError && onError(new Error(taskData.errorMessage || '任务失败'))
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
// 调用进度回调
|
||||
onProgress && onProgress({
|
||||
status: taskData.status,
|
||||
progress: taskData.progress || 0,
|
||||
resultUrl: taskData.resultUrl
|
||||
})
|
||||
|
||||
pollCount++
|
||||
|
||||
// 继续轮询
|
||||
setTimeout(poll, 120000) // 每2分钟轮询一次
|
||||
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error)
|
||||
onError && onError(error)
|
||||
isPolling = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
poll()
|
||||
|
||||
// 返回停止轮询的函数
|
||||
return () => {
|
||||
isPolling = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default imageToVideoApi
|
||||
46
frontend/src/api/members.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import api from './request'
|
||||
|
||||
// 获取会员列表
|
||||
export const getMembers = (params) => {
|
||||
return api.get('/members', { params })
|
||||
}
|
||||
|
||||
// 更新会员信息
|
||||
export const updateMember = (id, data) => {
|
||||
return api.put(`/members/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除会员
|
||||
export const deleteMember = (id) => {
|
||||
return api.delete(`/members/${id}`)
|
||||
}
|
||||
|
||||
// 批量删除会员
|
||||
export const deleteMembers = (ids) => {
|
||||
return api.delete('/members/batch', { data: { ids } })
|
||||
}
|
||||
|
||||
// 获取会员详情
|
||||
export const getMemberDetail = (id) => {
|
||||
return api.get(`/members/${id}`)
|
||||
}
|
||||
|
||||
// 获取所有会员等级配置
|
||||
export const getMembershipLevels = () => {
|
||||
return api.get('/members/levels')
|
||||
}
|
||||
|
||||
// 更新会员等级配置
|
||||
export const updateMembershipLevel = (id, data) => {
|
||||
return api.put(`/members/levels/${id}`, data)
|
||||
}
|
||||
|
||||
// 封禁/解封会员
|
||||
export const toggleBanMember = (id, isActive) => {
|
||||
return api.put(`/members/${id}/ban`, { isActive })
|
||||
}
|
||||
|
||||
// 设置用户角色(仅超级管理员可用)
|
||||
export const setUserRole = (id, role) => {
|
||||
return api.put(`/members/${id}/role`, { role })
|
||||
}
|
||||
63
frontend/src/api/orders.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import api from './request'
|
||||
|
||||
// 订单相关API
|
||||
export const getOrders = (params) => {
|
||||
return api.get('/orders', { params })
|
||||
}
|
||||
|
||||
export const getOrderById = (id) => {
|
||||
return api.get(`/orders/${id}`)
|
||||
}
|
||||
|
||||
export const createOrder = (orderData) => {
|
||||
return api.post('/orders/create', orderData)
|
||||
}
|
||||
|
||||
export const updateOrderStatus = (id, status, notes) => {
|
||||
return api.post(`/orders/${id}/status`, {
|
||||
status,
|
||||
notes
|
||||
})
|
||||
}
|
||||
|
||||
export const cancelOrder = (id, reason) => {
|
||||
return api.post(`/orders/${id}/cancel`, {
|
||||
reason
|
||||
})
|
||||
}
|
||||
|
||||
export const shipOrder = (id, trackingNumber) => {
|
||||
return api.post(`/orders/${id}/ship`, {
|
||||
trackingNumber
|
||||
})
|
||||
}
|
||||
|
||||
export const completeOrder = (id) => {
|
||||
return api.post(`/orders/${id}/complete`)
|
||||
}
|
||||
|
||||
export const createOrderPayment = (id, paymentMethod) => {
|
||||
return api.post(`/orders/${id}/pay`, {
|
||||
paymentMethod
|
||||
})
|
||||
}
|
||||
|
||||
// 管理员订单API(使用普通订单接口,后端会根据用户角色返回相应数据)
|
||||
export const getAdminOrders = (params) => {
|
||||
return api.get('/orders', { params })
|
||||
}
|
||||
|
||||
// 订单统计API
|
||||
export const getOrderStats = () => {
|
||||
return api.get('/orders/stats')
|
||||
}
|
||||
|
||||
// 批量删除订单
|
||||
export const deleteOrders = (orderIds) => {
|
||||
return api.delete('/orders/batch', { data: orderIds })
|
||||
}
|
||||
|
||||
// 删除单个订单
|
||||
export const deleteOrder = (id) => {
|
||||
return api.delete(`/orders/${id}`)
|
||||
}
|
||||
82
frontend/src/api/payments.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import api from './request'
|
||||
|
||||
// 支付相关API
|
||||
export const getPayments = (params) => {
|
||||
return api.get('/payments', { params })
|
||||
}
|
||||
|
||||
export const getPaymentById = (id) => {
|
||||
return api.get(`/payments/${id}`)
|
||||
}
|
||||
|
||||
export const createPayment = (paymentData) => {
|
||||
return api.post('/payments/create', paymentData)
|
||||
}
|
||||
|
||||
export const createTestPayment = (paymentData) => {
|
||||
return api.post('/payments/create-test', paymentData)
|
||||
}
|
||||
|
||||
export const updatePaymentStatus = (id, status) => {
|
||||
return api.put(`/payments/${id}/status`, { status })
|
||||
}
|
||||
|
||||
export const confirmPaymentSuccess = (id, externalTransactionId) => {
|
||||
return api.post(`/payments/${id}/success`, {
|
||||
externalTransactionId
|
||||
})
|
||||
}
|
||||
|
||||
export const confirmPaymentFailure = (id, failureReason) => {
|
||||
return api.post(`/payments/${id}/failure`, {
|
||||
failureReason
|
||||
})
|
||||
}
|
||||
|
||||
// 测试支付完成API
|
||||
export const testPaymentComplete = (id) => {
|
||||
return api.post(`/payments/${id}/test-complete`)
|
||||
}
|
||||
|
||||
// 支付宝支付API
|
||||
export const createAlipayPayment = (paymentData) => {
|
||||
return api.post(`/payments/alipay/create`, paymentData)
|
||||
}
|
||||
|
||||
export const handleAlipayCallback = (params) => {
|
||||
return api.post('/payments/alipay/callback', params)
|
||||
}
|
||||
|
||||
// 噜噜支付(彩虹易支付)API
|
||||
export const createLuluPayment = (paymentData) => {
|
||||
return api.post('/payments/lulupay/create', paymentData)
|
||||
}
|
||||
|
||||
// PayPal支付API
|
||||
export const createPayPalPayment = (paymentData) => {
|
||||
return api.post('/payment/paypal/create', paymentData)
|
||||
}
|
||||
|
||||
export const getPayPalPaymentStatus = (paymentId) => {
|
||||
return api.get(`/payment/paypal/status/${paymentId}`)
|
||||
}
|
||||
|
||||
// 支付统计API
|
||||
export const getPaymentStats = () => {
|
||||
return api.get('/payments/stats')
|
||||
}
|
||||
|
||||
// 获取用户订阅信息
|
||||
export const getUserSubscriptionInfo = () => {
|
||||
return api.get('/payments/subscription/info')
|
||||
}
|
||||
|
||||
// 删除单个支付记录
|
||||
export const deletePayment = (id) => {
|
||||
return api.delete(`/payments/${id}`)
|
||||
}
|
||||
|
||||
// 批量删除支付记录
|
||||
export const deletePayments = (paymentIds) => {
|
||||
return api.delete('/payments/batch', { data: paymentIds })
|
||||
}
|
||||
23
frontend/src/api/points.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
31
frontend/src/api/promptOptimizer.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import api from './request'
|
||||
|
||||
/**
|
||||
* 优化提示词
|
||||
* @param {string} prompt - 原始提示词
|
||||
* @param {string} type - 优化类型: 'text-to-video' | 'image-to-video' | 'storyboard'
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
export const optimizePrompt = async (prompt, type = 'text-to-video') => {
|
||||
// 参数验证
|
||||
if (!prompt || !prompt.trim()) {
|
||||
throw new Error('提示词不能为空')
|
||||
}
|
||||
|
||||
if (prompt.length > 2000) {
|
||||
throw new Error('提示词过长,请控制在2000字符以内')
|
||||
}
|
||||
|
||||
// 设置较长的超时时间(30秒),因为AI优化可能需要较长时间
|
||||
return api.post('/prompt/optimize', {
|
||||
prompt: prompt.trim(),
|
||||
type
|
||||
}, {
|
||||
timeout: 30000
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
optimizePrompt
|
||||
}
|
||||
|
||||
203
frontend/src/api/request.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
import { getApiBaseURL } from '@/utils/apiHelper'
|
||||
|
||||
// 创建axios实例
|
||||
// 自动检测:如果通过 Nginx 访问(包含 ngrok),使用相对路径;否则使用完整 URL
|
||||
const api = axios.create({
|
||||
baseURL: getApiBaseURL(),
|
||||
timeout: 900000, // 增加到15分钟,适应视频生成时间
|
||||
withCredentials: true,
|
||||
maxRedirects: 0, // 不自动跟随重定向,手动处理302
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
validateStatus: function (status) {
|
||||
// 允许所有状态码,包括302,让拦截器处理
|
||||
return status >= 200 && status < 600
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// 登录相关的接口不需要添加token
|
||||
const loginUrls = [
|
||||
'/auth/login',
|
||||
'/auth/login/email',
|
||||
'/auth/register',
|
||||
'/verification/email/send',
|
||||
'/verification/email/verify',
|
||||
'/verification/email/dev-set',
|
||||
'/public/'
|
||||
]
|
||||
|
||||
// 检查当前请求是否是登录相关接口
|
||||
const isLoginRequest = loginUrls.some(url => config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
// 非登录请求才添加Authorization头
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && token !== 'null' && token.trim() !== '') {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
// 打印token前30字符用于调试
|
||||
console.log('请求拦截器:添加Authorization头,token前30字符:', token.substring(0, 30), '请求URL:', config.url)
|
||||
} else {
|
||||
console.warn('请求拦截器:未找到有效的token,请求URL:', config.url)
|
||||
}
|
||||
} else {
|
||||
console.log('请求拦截器:登录相关请求,不添加token:', config.url)
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求拦截器错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// 检查是否是HTML响应(可能是302重定向的结果)
|
||||
if (response.data && typeof response.data === 'string' && response.data.trim().startsWith('<!DOCTYPE')) {
|
||||
console.error('收到HTML响应,可能是认证失败:', response.config.url)
|
||||
|
||||
// 只有非登录请求才清除token并跳转
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
// 清除无效的token并跳转到欢迎页
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// 避免重复跳转
|
||||
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
// 返回错误,让调用方知道这是认证失败
|
||||
return Promise.reject(new Error('认证失败:收到HTML响应'))
|
||||
}
|
||||
|
||||
// 检查401未授权(Token过期)
|
||||
if (response.status === 401) {
|
||||
console.error('收到401,Token已过期:', response.config.url)
|
||||
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||
|
||||
// /auth/me 请求的401不跳转,只静默清除token(用于初始化时检查token有效性)
|
||||
const isAuthMeRequest = response.config.url.includes('/auth/me')
|
||||
|
||||
if (!isLoginRequest) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// /auth/me 请求不显示提示也不跳转
|
||||
if (!isAuthMeRequest) {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error('认证失败:Token已过期'))
|
||||
}
|
||||
|
||||
// 检查302重定向
|
||||
if (response.status === 302) {
|
||||
console.error('收到302重定向,可能是认证失败:', response.config.url)
|
||||
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
return Promise.reject(new Error('认证失败:302重定向'))
|
||||
}
|
||||
|
||||
// 直接返回response,让调用方处理data
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
// 检查响应数据是否是HTML(302重定向的结果)
|
||||
if (data && typeof data === 'string' && data.trim().startsWith('<!DOCTYPE')) {
|
||||
console.error('收到HTML响应(可能是302重定向):', error.config.url)
|
||||
|
||||
// 只有非登录请求才清除token并跳转
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => error.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
case 302:
|
||||
// 只有非登录请求才清除token并跳转
|
||||
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest = loginUrls.some(url => error.config.url.includes(url))
|
||||
|
||||
// /auth/me 请求的401不跳转,只静默清除token
|
||||
const isAuthMeRequest = error.config.url.includes('/auth/me')
|
||||
|
||||
if (!isLoginRequest) {
|
||||
// 302也可能是认证失败导致的
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// /auth/me 请求不显示提示也不跳转
|
||||
if (!isAuthMeRequest && router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
// 403可能是权限不足或CORS问题
|
||||
// 如果是登录请求的403,不要显示"权限不足",而是显示具体错误信息
|
||||
const loginUrls403 = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
|
||||
const isLoginRequest403 = loginUrls403.some(url => error.config.url.includes(url))
|
||||
|
||||
if (!isLoginRequest403) {
|
||||
ElMessage.error('权限不足')
|
||||
} else {
|
||||
// 登录请求的403,显示具体错误或网络问题
|
||||
ElMessage.error(data?.message || '请求失败,请检查网络连接')
|
||||
}
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(data?.message || '请求失败')
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('网络错误,请检查网络连接')
|
||||
} else {
|
||||
ElMessage.error('请求配置错误')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
56
frontend/src/api/storyboardVideo.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import api from './request'
|
||||
|
||||
/**
|
||||
* 创建分镜视频任务
|
||||
*/
|
||||
export const createStoryboardTask = async (data) => {
|
||||
return api.post('/storyboard-video/create', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
|
||||
* @param {object} data - 包含 storyboardImage, prompt, aspectRatio, hdMode, duration, referenceImages
|
||||
*/
|
||||
export const createVideoDirectTask = async (data) => {
|
||||
return api.post('/storyboard-video/create-video-direct', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
export const getStoryboardTask = async (taskId) => {
|
||||
return api.get(`/storyboard-video/task/${taskId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户任务列表
|
||||
*/
|
||||
export const getUserStoryboardTasks = async (page = 0, size = 10) => {
|
||||
return api.get('/storyboard-video/tasks', { params: { page, size } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始生成视频(从分镜图生成视频)
|
||||
* @param {string} taskId - 任务ID
|
||||
* @param {object} params - 视频参数(duration, aspectRatio, hdMode)
|
||||
*/
|
||||
export const startVideoGeneration = async (taskId, params = {}) => {
|
||||
return api.post(`/storyboard-video/task/${taskId}/start-video`, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接多张图片为六宫格(2×3)
|
||||
* @param {Array<string>} images - Base64图片数组
|
||||
* @param {number} cols - 列数,默认3(2×3布局)
|
||||
*/
|
||||
export const mergeImagesToGrid = async (images, cols = 3) => {
|
||||
return api.post('/image-grid/merge', { images, cols })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试失败的分镜视频任务
|
||||
* @param {string} taskId - 任务ID
|
||||
*/
|
||||
export const retryStoryboardTask = async (taskId) => {
|
||||
return api.post(`/storyboard-video/task/${taskId}/retry`)
|
||||
}
|
||||
25
frontend/src/api/taskStatus.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import api from './request'
|
||||
|
||||
export const taskStatusApi = {
|
||||
// 获取任务状态
|
||||
getTaskStatus(taskId) {
|
||||
return api.get(`/task-status/${taskId}`)
|
||||
},
|
||||
|
||||
// 获取用户的所有任务状态
|
||||
getUserTaskStatuses(username) {
|
||||
return api.get(`/task-status/user/${username}`)
|
||||
},
|
||||
|
||||
// 手动触发轮询(管理员功能)
|
||||
triggerPolling() {
|
||||
return api.post('/task-status/poll')
|
||||
},
|
||||
|
||||
// 获取所有任务记录(管理员功能)
|
||||
getAllTaskRecords(params) {
|
||||
return api.get('/task-status/admin/all', { params })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
180
frontend/src/api/textToVideo.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import request from './request'
|
||||
|
||||
/**
|
||||
* 文生视频API服务
|
||||
*/
|
||||
export const textToVideoApi = {
|
||||
/**
|
||||
* 创建文生视频任务
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {string} params.prompt - 文本描述
|
||||
* @param {string} params.aspectRatio - 视频比例
|
||||
* @param {number} params.duration - 视频时长
|
||||
* @param {boolean} params.hdMode - 是否高清模式
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
createTask(params) {
|
||||
// 参数验证
|
||||
if (!params) {
|
||||
throw new Error('参数不能为空')
|
||||
}
|
||||
if (!params.prompt || params.prompt.trim() === '') {
|
||||
throw new Error('文本描述不能为空')
|
||||
}
|
||||
if (!params.aspectRatio) {
|
||||
throw new Error('视频比例不能为空')
|
||||
}
|
||||
if (!params.duration || ![5, 10, 15].includes(params.duration)) {
|
||||
throw new Error('视频时长必须为5秒、10秒或15秒')
|
||||
}
|
||||
|
||||
return request({
|
||||
url: '/text-to-video/create',
|
||||
method: 'POST',
|
||||
data: {
|
||||
prompt: params.prompt.trim(),
|
||||
aspectRatio: params.aspectRatio,
|
||||
duration: params.duration,
|
||||
hdMode: params.hdMode,
|
||||
videoModel: params.videoModel || 'grok-video-3'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户的所有文生视频任务
|
||||
* @param {number} page - 页码
|
||||
* @param {number} size - 每页数量
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
getTasks(page = 0, size = 10) {
|
||||
return request({
|
||||
url: '/text-to-video/tasks',
|
||||
method: 'GET',
|
||||
params: {
|
||||
page,
|
||||
size
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个文生视频任务详情
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
getTaskDetail(taskId) {
|
||||
return request({
|
||||
url: `/text-to-video/tasks/${taskId}`,
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文生视频任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
getTaskStatus(taskId) {
|
||||
return request({
|
||||
url: `/text-to-video/tasks/${taskId}/status`,
|
||||
method: 'GET'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 重试失败的任务
|
||||
* 复用原task_id,重新提交至外部API
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
retryTask(taskId) {
|
||||
return request({
|
||||
url: `/text-to-video/tasks/${taskId}/retry`,
|
||||
method: 'POST'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
* @param {Function} onProgress - 进度回调
|
||||
* @param {Function} onComplete - 完成回调
|
||||
* @param {Function} onError - 错误回调
|
||||
* @returns {Function} 停止轮询的函数
|
||||
*/
|
||||
pollTaskStatus(taskId, onProgress, onComplete, onError) {
|
||||
let isPolling = true
|
||||
let pollCount = 0
|
||||
const maxPolls = 30 // 最大轮询次数(1小时,每2分钟一次)
|
||||
|
||||
const poll = async () => {
|
||||
if (!isPolling || pollCount >= maxPolls) {
|
||||
if (pollCount >= maxPolls) {
|
||||
onError && onError(new Error('任务超时'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: `/text-to-video/tasks/${taskId}/status`,
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
// 检查响应是否有效
|
||||
if (!response || !response.data || !response.data.success) {
|
||||
onError && onError(new Error('获取任务状态失败'))
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
const taskData = response.data.data
|
||||
|
||||
// 检查taskData是否有效
|
||||
if (!taskData || !taskData.status) {
|
||||
onError && onError(new Error('无效的任务数据'))
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
if (taskData.status === 'COMPLETED') {
|
||||
onComplete && onComplete(taskData)
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
if (taskData.status === 'FAILED' || taskData.status === 'CANCELLED') {
|
||||
onError && onError(new Error(taskData.errorMessage || '任务失败'))
|
||||
isPolling = false
|
||||
return
|
||||
}
|
||||
|
||||
// 调用进度回调
|
||||
onProgress && onProgress({
|
||||
status: taskData.status,
|
||||
progress: taskData.progress || 0,
|
||||
resultUrl: taskData.resultUrl
|
||||
})
|
||||
|
||||
pollCount++
|
||||
|
||||
// 继续轮询
|
||||
setTimeout(poll, 120000) // 每2分钟轮询一次
|
||||
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error)
|
||||
onError && onError(error)
|
||||
isPolling = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
poll()
|
||||
|
||||
// 返回停止轮询的函数
|
||||
return () => {
|
||||
isPolling = false
|
||||
}
|
||||
}
|
||||
}
|
||||
87
frontend/src/api/userWorks.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import api from './request'
|
||||
|
||||
// 获取我的作品列表
|
||||
export const getMyWorks = (params = {}) => {
|
||||
return api.get('/works/my-works', {
|
||||
params: {
|
||||
page: params.page || 0,
|
||||
size: params.size || 10,
|
||||
includeProcessing: params.includeProcessing !== false, // 默认包含正在处理中的作品
|
||||
workType: params.workType || null // 按作品类型筛选
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 按类型获取我的作品(用于历史记录)
|
||||
export const getMyWorksByType = (workType, params = {}) => {
|
||||
return api.get('/works/my-works', {
|
||||
params: {
|
||||
page: params.page || 0,
|
||||
size: params.size || 1000,
|
||||
includeProcessing: true,
|
||||
workType: workType // TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO, STORYBOARD_IMAGE
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取正在进行中的作品
|
||||
export const getProcessingWorks = () => {
|
||||
return api.get('/works/processing')
|
||||
}
|
||||
|
||||
// 获取作品详情
|
||||
export const getWorkDetail = (workId) => {
|
||||
return api.get(`/works/${workId}`)
|
||||
}
|
||||
|
||||
// 删除作品
|
||||
export const deleteWork = (workId) => {
|
||||
return api.delete(`/works/${workId}`)
|
||||
}
|
||||
|
||||
// 批量删除作品
|
||||
export const batchDeleteWorks = (workIds) => {
|
||||
return api.post('/works/batch-delete', {
|
||||
workIds: workIds
|
||||
})
|
||||
}
|
||||
|
||||
// 更新作品信息
|
||||
export const updateWork = (workId, data) => {
|
||||
return api.put(`/works/${workId}`, data)
|
||||
}
|
||||
|
||||
// 获取作品统计信息
|
||||
export const getWorkStats = () => {
|
||||
return api.get('/works/stats')
|
||||
}
|
||||
|
||||
// 记录下载(增加下载次数)
|
||||
export const recordDownload = (workId) => {
|
||||
return api.post(`/works/${workId}/download`)
|
||||
}
|
||||
|
||||
// 获取作品文件下载URL
|
||||
export const getWorkFileUrl = (workId, download = false) => {
|
||||
// 构建URL,直接返回完整路径用于浏览器打开
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
// 去掉 /api 前缀,因为 nginx 会自动转发
|
||||
let url = `${baseUrl}/works/${workId}/file`
|
||||
|
||||
// 添加 download 参数(可选)
|
||||
if (download) {
|
||||
url += '?download=true'
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
401
frontend/src/components/DailyActiveUsersChart.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="daily-active-users-chart">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">{{ $t('dashboard.dailyActive') }}</h3>
|
||||
<div class="chart-controls">
|
||||
<el-select v-model="selectedYear" @change="loadChartData" :placeholder="$t('dashboard.selectYear')">
|
||||
<el-option
|
||||
v-for="year in availableYears"
|
||||
:key="year"
|
||||
:label="`${year}${$t('dashboard.yearSuffix')}`"
|
||||
:value="year">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" ref="chartContainer"></div>
|
||||
|
||||
<div class="chart-footer">
|
||||
<div class="chart-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ $t('dashboard.todayDAU') }}:</span>
|
||||
<span class="stat-value">{{ formatNumber(todayDAU) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ $t('dashboard.dayGrowthRate') }}:</span>
|
||||
<span class="stat-value" :class="dayGrowthRate >= 0 ? 'positive' : 'negative'">
|
||||
{{ dayGrowthRate >= 0 ? '+' : '' }}{{ dayGrowthRate.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ $t('dashboard.monthlyAvgDAU') }}:</span>
|
||||
<span class="stat-value">{{ formatNumber(monthlyAvgDAU) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ $t('dashboard.monthGrowthRate') }}:</span>
|
||||
<span class="stat-value" :class="monthGrowthRate >= 0 ? 'positive' : 'negative'">
|
||||
{{ monthGrowthRate >= 0 ? '+' : '' }}{{ monthGrowthRate.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as analyticsAPI from '@/api/analytics'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 响应式数据
|
||||
const chartContainer = ref(null)
|
||||
const selectedYear = ref(2024)
|
||||
const availableYears = ref([2023, 2024, 2025])
|
||||
const chartInstance = ref(null)
|
||||
|
||||
// 统计数据
|
||||
const todayDAU = ref(0)
|
||||
const dayGrowthRate = ref(0)
|
||||
const monthlyAvgDAU = ref(0)
|
||||
const monthGrowthRate = ref(0)
|
||||
|
||||
// 动态加载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 loadChartData = async () => {
|
||||
try {
|
||||
// 并行加载图表数据和概览数据
|
||||
const [chartRes, overviewRes] = await Promise.all([
|
||||
analyticsAPI.getDailyActiveUsersTrend(selectedYear.value, 'monthly'),
|
||||
analyticsAPI.getUserActivityOverview()
|
||||
])
|
||||
|
||||
// 处理图表数据
|
||||
if (chartRes && chartRes.monthlyData) {
|
||||
await nextTick()
|
||||
initChart(chartRes.monthlyData)
|
||||
}
|
||||
|
||||
// 处理概览数据
|
||||
if (overviewRes) {
|
||||
todayDAU.value = overviewRes.todayDAU || 0
|
||||
dayGrowthRate.value = overviewRes.dayGrowthRate || 0
|
||||
monthlyAvgDAU.value = overviewRes.monthlyAvgDAU || 0
|
||||
monthGrowthRate.value = overviewRes.monthGrowthRate || 0
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载图表数据失败:', error)
|
||||
ElMessage.error(t('common.chartDataLoadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
const initChart = async (data) => {
|
||||
try {
|
||||
const echarts = await loadECharts()
|
||||
|
||||
if (!chartContainer.value) return
|
||||
|
||||
// 销毁现有图表实例
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.dispose()
|
||||
}
|
||||
|
||||
// 创建新图表实例
|
||||
chartInstance.value = echarts.init(chartContainer.value)
|
||||
|
||||
// 准备数据
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||
const values = data.map(item => item.avgDailyActive || 0)
|
||||
const maxValues = data.map(item => item.maxDailyActive || 0)
|
||||
const minValues = data.map(item => item.minDailyActive || 0)
|
||||
|
||||
// 图表配置
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 12
|
||||
},
|
||||
formatter: function(params) {
|
||||
const dataIndex = params[0].dataIndex
|
||||
const month = months[dataIndex]
|
||||
const avgValue = values[dataIndex]
|
||||
const maxValue = maxValues[dataIndex]
|
||||
const minValue = minValues[dataIndex]
|
||||
|
||||
return `${month}<br/>
|
||||
平均日活: ${formatNumber(avgValue)}<br/>
|
||||
最高日活: ${formatNumber(maxValue)}<br/>
|
||||
最低日活: ${formatNumber(minValue)}`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '10%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: months,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#e0e0e0'
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666',
|
||||
fontSize: 12
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#666',
|
||||
fontSize: 12,
|
||||
formatter: function(value) {
|
||||
return formatNumber(value)
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#f0f0f0',
|
||||
type: 'dashed'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '日活用户',
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
color: '#3b82f6',
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#3b82f6',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
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.05)'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
itemStyle: {
|
||||
color: '#1d4ed8',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(59, 130, 246, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
animation: true,
|
||||
animationDuration: 1000,
|
||||
animationEasing: 'cubicOut'
|
||||
}
|
||||
|
||||
// 设置图表配置
|
||||
chartInstance.value.setOption(option)
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化图表失败:', error)
|
||||
ElMessage.error(t('common.chartInitFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理窗口大小变化
|
||||
const handleResize = () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.resize()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return Math.round(num).toLocaleString()
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadChartData()
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.dispose()
|
||||
chartInstance.value = null
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.daily-active-users-chart {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-footer {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-value.positive {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stat-value.negative {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.chart-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.daily-active-users-chart {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
112
frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<el-footer class="footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<p>© 2024 AIGC Demo. All rights reserved.</p>
|
||||
<p>基于 Vue.js 3 + Element Plus 构建</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">关于我们</a>
|
||||
<a href="#" class="footer-link">联系我们</a>
|
||||
<a href="#" class="footer-link">隐私政策</a>
|
||||
<a href="#" class="footer-link">服务条款</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Footer组件逻辑
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
height: 60px;
|
||||
background-color: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
color: #e5e7ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-info p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #e5e7ff;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
71
frontend/src/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<button class="language-switcher" @click="toggleLanguage" :title="currentLanguage === 'zh' ? '切换到英文' : 'Switch to Chinese'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M4.16602 12.4998V14.1665C4.16602 15.0452 4.84592 15.765 5.7083 15.8286L5.83268 15.8332H8.33268V17.4998H5.83268C3.99173 17.4998 2.49935 16.0074 2.49935 14.1665V12.4998H4.16602ZM14.9993 8.33317L18.666 17.4998H16.8702L15.8694 14.9998H12.461L11.4618 17.4998H9.66685L13.3327 8.33317H14.9993ZM14.166 10.7375L13.1268 13.3332H15.2035L14.166 10.7375ZM6.66602 1.6665V3.33317H9.99935V9.1665H6.66602V11.6665H4.99935V9.1665H1.66602V3.33317H4.99935V1.6665H6.66602ZM14.166 2.49984C16.0069 2.49984 17.4993 3.99222 17.4993 5.83317V7.49984H15.8327V5.83317C15.8327 4.9127 15.0865 4.1665 14.166 4.1665H11.666V2.49984H14.166ZM4.99935 4.99984H3.33268V7.49984H4.99935V4.99984ZM8.33268 4.99984H6.66602V7.49984H8.33268V4.99984Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="lang-text">{{ currentLanguage === 'zh' ? '中' : 'EN' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const currentLanguage = computed(() => locale.value)
|
||||
|
||||
const toggleLanguage = () => {
|
||||
console.log('[LanguageSwitcher] 当前语言:', locale.value)
|
||||
|
||||
// 切换语言
|
||||
const newLang = locale.value === 'zh' ? 'en' : 'zh'
|
||||
console.log('[LanguageSwitcher] 切换到:', newLang)
|
||||
|
||||
// 直接更新 locale(响应式切换)
|
||||
locale.value = newLang
|
||||
|
||||
// 保存到 localStorage 以便下次刷新时使用
|
||||
localStorage.setItem('language', newLang)
|
||||
console.log('[LanguageSwitcher] localStorage 已保存:', localStorage.getItem('language'))
|
||||
console.log('[LanguageSwitcher] 语言切换完成(无刷新)')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-switcher {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.language-switcher:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.language-switcher:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.language-switcher svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lang-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
275
frontend/src/components/NavBar.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<el-header class="navbar">
|
||||
<div class="navbar-container">
|
||||
<!-- Logo -->
|
||||
<div class="navbar-brand">
|
||||
<router-link to="/" class="brand-link">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" class="brand-logo" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<el-menu
|
||||
mode="horizontal"
|
||||
class="navbar-menu"
|
||||
background-color="#409EFF"
|
||||
text-color="#fff"
|
||||
active-text-color="#ffd04b"
|
||||
router
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-menu-item index="/welcome">
|
||||
<span>{{ $t('common.welcome') }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/admin/dashboard">
|
||||
<span>{{ $t('common.home') }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAuthenticated" index="/profile">
|
||||
<span>{{ $t('common.profile') }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAuthenticated" index="/admin/orders">
|
||||
<span>{{ $t('common.orders') }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAuthenticated" index="/payments">
|
||||
<span>{{ $t('common.payments') }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="userStore.isAdmin" index="/admin/dashboard">
|
||||
<span>{{ $t('common.adminPanel') }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 快速切换提示(暂时隐藏) -->
|
||||
<!-- <div class="quick-switch-hint" v-if="showShortcutHint">
|
||||
<el-tooltip content="使用 Alt + 数字键快速切换页面" placement="bottom">
|
||||
<el-icon><Keyboard /></el-icon>
|
||||
</el-tooltip>
|
||||
</div> -->
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<div class="navbar-user">
|
||||
<template v-if="userStore.isAuthenticated">
|
||||
<LanguageSwitcher />
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<span class="user-dropdown">
|
||||
<span>{{ userStore.username }}</span>
|
||||
<el-tag v-if="userStore.availablePoints > 0" size="small" type="success" class="points-tag">
|
||||
{{ userStore.availablePoints }}{{ $t('common.points') }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
{{ $t('common.userProfile') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="userStore.isAdmin" command="admin">
|
||||
{{ $t('common.adminPanel') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="settings">
|
||||
{{ $t('common.settings') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
{{ $t('common.logout') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<LanguageSwitcher />
|
||||
<el-button type="primary" plain @click="$router.push('/login')">
|
||||
{{ $t('common.login') }}
|
||||
</el-button>
|
||||
<el-button type="success" plain @click="$router.push('/register')">
|
||||
{{ $t('common.register') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import LanguageSwitcher from './LanguageSwitcher.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 显示快捷键提示(暂时禁用)
|
||||
// const showShortcutHint = ref(true)
|
||||
|
||||
// 快速切换处理函数
|
||||
const handleMenuSelect = (index) => {
|
||||
// 使用replace而不是push,避免浏览器历史记录堆积
|
||||
router.replace(index)
|
||||
}
|
||||
|
||||
// 暂时禁用快捷键功能,确保基本功能正常
|
||||
// const handleKeydown = (event) => {
|
||||
// // 快捷键功能暂时禁用
|
||||
// }
|
||||
|
||||
// onMounted(() => {
|
||||
// // 暂时不添加键盘事件监听
|
||||
// })
|
||||
|
||||
// onUnmounted(() => {
|
||||
// // 暂时不移除键盘事件监听
|
||||
// })
|
||||
|
||||
const handleUserCommand = async (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
ElMessage.info(t('common.profileDevMsg'))
|
||||
break
|
||||
case 'admin':
|
||||
// 检查管理员权限
|
||||
if (userStore.isAdmin) {
|
||||
router.push('/admin/dashboard')
|
||||
} else {
|
||||
ElMessage.warning(t('common.noPermissionMsg'))
|
||||
}
|
||||
break
|
||||
case 'settings':
|
||||
ElMessage.info(t('common.settingsDevMsg'))
|
||||
break
|
||||
case 'logout':
|
||||
try {
|
||||
await ElMessageBox.confirm(t('common.logoutConfirm'), t('common.tip'), {
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await userStore.logoutUser()
|
||||
ElMessage.success(t('common.logoutSuccess'))
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
// 用户取消
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.navbar-menu .el-menu-item {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.navbar-menu .el-menu-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.navbar-user {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.user-dropdown:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.points-tag {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-dropdown .el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.navbar-user .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.shortcut-hint {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
margin-left: 8px;
|
||||
padding: 2px 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.quick-switch-hint {
|
||||
margin-right: 20px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.quick-switch-hint:hover {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
1214
frontend/src/components/PaymentModal.vue
Normal file
389
frontend/src/components/TaskStatusDisplay.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div class="task-status-display">
|
||||
<div class="status-header">
|
||||
<h3>任务状态</h3>
|
||||
<div class="status-badge" :class="statusClass">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-section" v-if="taskStatus">
|
||||
<!-- 排队中:不确定进度条 -->
|
||||
<div v-if="taskStatus.status === 'PENDING'" class="progress-bar indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<!-- 生成中:动态进度条 -->
|
||||
<div v-else class="progress-bar">
|
||||
<div
|
||||
class="progress-fill animated"
|
||||
:style="{ width: taskStatus.progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-text" v-if="taskStatus.status !== 'PENDING'">{{ taskStatus.progress }}%</div>
|
||||
<div class="progress-text" v-else>排队中...</div>
|
||||
</div>
|
||||
|
||||
<div class="task-info">
|
||||
<div class="info-item">
|
||||
<span class="label">任务ID:</span>
|
||||
<span class="value">{{ taskStatus?.taskId }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">创建时间:</span>
|
||||
<span class="value">{{ formatDate(taskStatus?.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="taskStatus?.completedAt">
|
||||
<span class="label">完成时间:</span>
|
||||
<span class="value">{{ formatDate(taskStatus.completedAt) }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="taskStatus?.resultUrl">
|
||||
<span class="label">结果URL:</span>
|
||||
<a :href="taskStatus.resultUrl" target="_blank" class="result-link">
|
||||
查看结果
|
||||
</a>
|
||||
</div>
|
||||
<div class="info-item" v-if="taskStatus?.errorMessage">
|
||||
<span class="label">错误信息:</span>
|
||||
<span class="value error">{{ taskStatus.errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons" v-if="showActions">
|
||||
<button
|
||||
v-if="canRetry"
|
||||
@click="retryTask"
|
||||
class="btn-retry"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { taskStatusApi } from '@/api/taskStatus'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
taskId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
autoRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 30000 // 30秒
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['statusChanged', 'taskCompleted', 'taskFailed'])
|
||||
|
||||
const taskStatus = ref(null)
|
||||
const loading = ref(false)
|
||||
const refreshTimer = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const statusClass = computed(() => {
|
||||
if (!taskStatus.value) return 'status-pending'
|
||||
|
||||
switch (taskStatus.value.status) {
|
||||
case 'PENDING':
|
||||
return 'status-pending'
|
||||
case 'PROCESSING':
|
||||
return 'status-processing'
|
||||
case 'COMPLETED':
|
||||
return 'status-completed'
|
||||
case 'FAILED':
|
||||
return 'status-failed'
|
||||
case 'CANCELLED':
|
||||
return 'status-cancelled'
|
||||
case 'TIMEOUT':
|
||||
return 'status-timeout'
|
||||
default:
|
||||
return 'status-pending'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!taskStatus.value) return '未知'
|
||||
return taskStatus.value.statusDescription || taskStatus.value.status
|
||||
})
|
||||
|
||||
const showActions = computed(() => {
|
||||
if (!taskStatus.value) return false
|
||||
return ['FAILED', 'TIMEOUT'].includes(taskStatus.value.status)
|
||||
})
|
||||
|
||||
const canRetry = computed(() => {
|
||||
if (!taskStatus.value) return false
|
||||
return ['FAILED', 'TIMEOUT'].includes(taskStatus.value.status)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const fetchTaskStatus = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await taskStatusApi.getTaskStatus(props.taskId)
|
||||
taskStatus.value = response.data
|
||||
|
||||
// 触发状态变化事件
|
||||
emit('statusChanged', taskStatus.value)
|
||||
|
||||
// 检查任务是否完成
|
||||
if (taskStatus.value.status === 'COMPLETED') {
|
||||
emit('taskCompleted', taskStatus.value)
|
||||
} else if (['FAILED', 'TIMEOUT', 'CANCELLED'].includes(taskStatus.value.status)) {
|
||||
emit('taskFailed', taskStatus.value)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取任务状态失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryTask = () => {
|
||||
// 重试逻辑,这里可以触发重新创建任务
|
||||
emit('retryTask', props.taskId)
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
if (props.autoRefresh && !refreshTimer.value) {
|
||||
refreshTimer.value = setInterval(fetchTaskStatus, props.refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer.value) {
|
||||
clearInterval(refreshTimer.value)
|
||||
refreshTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchTaskStatus()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-status-display {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-header h3 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #fbbf24;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
background: #3b82f6;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #10b981;
|
||||
color: #064e3b;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background: #ef4444;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background: #6b7280;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.status-timeout {
|
||||
background: #f59e0b;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #374151;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 动态进度条动画 */
|
||||
.progress-fill.animated {
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa, #3b82f6);
|
||||
background-size: 200% 100%;
|
||||
animation: progress-gradient 2s ease infinite, progress-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progress-fill.animated::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
animation: progress-shine 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes progress-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
@keyframes progress-shine {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* 不确定进度条(排队中) */
|
||||
.progress-bar.indeterminate {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill-indeterminate {
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, #3b82f6, #60a5fa, #3b82f6, transparent);
|
||||
border-radius: 4px;
|
||||
animation: indeterminate-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes indeterminate-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.result-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-retry:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
120
frontend/src/directives/lazyLoad.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 图片懒加载指令
|
||||
* 使用 Intersection Observer API 实现
|
||||
*/
|
||||
|
||||
// 默认占位图(1x1透明像素)
|
||||
const defaultPlaceholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||
|
||||
// 加载中占位图(可选,灰色背景)
|
||||
const loadingPlaceholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23333" width="100" height="100"/%3E%3C/svg%3E'
|
||||
|
||||
// 创建 Intersection Observer
|
||||
let observer = null
|
||||
|
||||
const getObserver = () => {
|
||||
if (observer) return observer
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const el = entry.target
|
||||
const src = el.dataset.src
|
||||
|
||||
if (src) {
|
||||
// 创建新图片预加载
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
el.src = src
|
||||
el.classList.add('lazy-loaded')
|
||||
el.classList.remove('lazy-loading')
|
||||
}
|
||||
img.onerror = () => {
|
||||
el.classList.add('lazy-error')
|
||||
el.classList.remove('lazy-loading')
|
||||
}
|
||||
img.src = src
|
||||
}
|
||||
|
||||
// 停止观察
|
||||
observer.unobserve(el)
|
||||
}
|
||||
})
|
||||
}, {
|
||||
rootMargin: '100px', // 提前100px开始加载
|
||||
threshold: 0.1
|
||||
})
|
||||
|
||||
return observer
|
||||
}
|
||||
|
||||
export const lazyLoad = {
|
||||
mounted(el, binding) {
|
||||
const src = binding.value
|
||||
|
||||
if (!src) return
|
||||
|
||||
// 保存真实src
|
||||
el.dataset.src = src
|
||||
|
||||
// 设置占位图
|
||||
el.src = binding.arg === 'loading' ? loadingPlaceholder : defaultPlaceholder
|
||||
el.classList.add('lazy-loading')
|
||||
|
||||
// 开始观察
|
||||
getObserver().observe(el)
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
// 如果src变化,重新加载
|
||||
if (binding.value !== binding.oldValue && binding.value) {
|
||||
el.dataset.src = binding.value
|
||||
el.classList.remove('lazy-loaded', 'lazy-error')
|
||||
el.classList.add('lazy-loading')
|
||||
getObserver().observe(el)
|
||||
}
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
if (observer) {
|
||||
observer.unobserve(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频懒加载指令
|
||||
export const lazyVideo = {
|
||||
mounted(el, binding) {
|
||||
const src = binding.value
|
||||
|
||||
if (!src) return
|
||||
|
||||
el.dataset.src = src
|
||||
el.preload = 'none' // 不预加载
|
||||
el.classList.add('lazy-loading')
|
||||
|
||||
const videoObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
el.src = el.dataset.src
|
||||
el.preload = 'metadata' // 只加载元数据
|
||||
el.classList.add('lazy-loaded')
|
||||
el.classList.remove('lazy-loading')
|
||||
videoObserver.unobserve(el)
|
||||
}
|
||||
})
|
||||
}, {
|
||||
rootMargin: '50px',
|
||||
threshold: 0.1
|
||||
})
|
||||
|
||||
videoObserver.observe(el)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
app.directive('lazy', lazyLoad)
|
||||
app.directive('lazy-video', lazyVideo)
|
||||
}
|
||||
}
|
||||
1236
frontend/src/locales/en.js
Normal file
23
frontend/src/locales/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zh from './zh'
|
||||
import en from './en'
|
||||
|
||||
// 从localStorage获取保存的语言设置,默认中文
|
||||
const savedLanguage = localStorage.getItem('language') || 'zh'
|
||||
|
||||
console.log('[i18n] 从 localStorage 读取的语言:', savedLanguage)
|
||||
console.log('[i18n] 可用的语言:', Object.keys({ zh, en }))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // 使用Composition API模式
|
||||
locale: savedLanguage, // 默认语言
|
||||
fallbackLocale: 'zh', // 回退语言
|
||||
messages: {
|
||||
zh,
|
||||
en
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[i18n] i18n 初始化完成,当前语言:', i18n.global.locale.value)
|
||||
|
||||
export default i18n
|
||||
1242
frontend/src/locales/zh.js
Normal file
26
frontend/src/main.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './locales'
|
||||
import { useUserStore } from './stores/user'
|
||||
import lazyLoadDirective from './directives/lazyLoad'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(ElementPlus)
|
||||
app.use(lazyLoadDirective)
|
||||
|
||||
console.log('[main.js] i18n 当前语言:', i18n.global.locale.value)
|
||||
|
||||
// 立即挂载应用
|
||||
app.mount('#app')
|
||||
|
||||
console.log('[main.js] 应用已挂载,当前语言:', i18n.global.locale.value)
|
||||
319
frontend/src/router/index.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 路由组件 - 使用懒加载优化性能
|
||||
const Login = () => import('@/views/Login.vue')
|
||||
const Register = () => import('@/views/Register.vue')
|
||||
const OrderDetail = () => import('@/views/OrderDetail.vue')
|
||||
const OrderCreate = () => import('@/views/OrderCreate.vue')
|
||||
const Payments = () => import('@/views/Payments.vue')
|
||||
const PaymentCreate = () => import('@/views/PaymentCreate.vue')
|
||||
const AdminOrders = () => import('@/views/AdminOrders.vue')
|
||||
const AdminDashboard = () => import('@/views/AdminDashboard.vue')
|
||||
const Welcome = () => import('@/views/Welcome.vue')
|
||||
const Profile = () => import('@/views/Profile.vue')
|
||||
const Subscription = () => import('@/views/Subscription.vue')
|
||||
const MyWorks = () => import('@/views/MyWorks.vue')
|
||||
const VideoDetail = () => import('@/views/VideoDetail.vue')
|
||||
const TextToVideo = () => import('@/views/TextToVideo.vue')
|
||||
const TextToVideoCreate = () => import('@/views/TextToVideoCreate.vue')
|
||||
const ImageToVideo = () => import('@/views/ImageToVideo.vue')
|
||||
const ImageToVideoCreate = () => import('@/views/ImageToVideoCreate.vue')
|
||||
const ImageToVideoDetail = () => import('@/views/ImageToVideoDetail.vue')
|
||||
const StoryboardVideo = () => import('@/views/StoryboardVideo.vue')
|
||||
const StoryboardVideoCreate = () => import('@/views/StoryboardVideoCreate.vue')
|
||||
const MemberManagement = () => import('@/views/MemberManagement.vue')
|
||||
const SystemSettings = () => import('@/views/SystemSettings.vue')
|
||||
const GenerateTaskRecord = () => import('@/views/GenerateTaskRecord.vue')
|
||||
const HelloWorld = () => import('@/views/HelloWorld.vue')
|
||||
const TaskStatusPage = () => import('@/views/TaskStatusPage.vue')
|
||||
const TermsOfService = () => import('@/views/TermsOfService.vue')
|
||||
const UserAgreement = () => import('@/views/UserAgreement.vue')
|
||||
const PrivacyPolicy = () => import('@/views/PrivacyPolicy.vue')
|
||||
const ChangePassword = () => import('@/views/ChangePassword.vue')
|
||||
const SetPassword = () => import('@/views/SetPassword.vue')
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/works',
|
||||
name: 'MyWorks',
|
||||
component: MyWorks,
|
||||
meta: { title: '我的作品', requiresAuth: true, keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: '/task-status',
|
||||
name: 'TaskStatus',
|
||||
component: TaskStatusPage,
|
||||
meta: { title: '任务状态', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/video/:id',
|
||||
name: 'VideoDetail',
|
||||
component: VideoDetail,
|
||||
meta: { title: '视频详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/text-to-video',
|
||||
name: 'TextToVideo',
|
||||
component: TextToVideo,
|
||||
meta: { title: '文生视频', keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: '/text-to-video/create',
|
||||
name: 'TextToVideoCreate',
|
||||
component: TextToVideoCreate,
|
||||
meta: { title: '文生视频创作' }
|
||||
},
|
||||
{
|
||||
path: '/image-to-video',
|
||||
name: 'ImageToVideo',
|
||||
component: ImageToVideo,
|
||||
meta: { title: '图生视频', keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: '/image-to-video/create',
|
||||
name: 'ImageToVideoCreate',
|
||||
component: ImageToVideoCreate,
|
||||
meta: { title: '图生视频创作' }
|
||||
},
|
||||
{
|
||||
path: '/image-to-video/detail/:taskId',
|
||||
name: 'ImageToVideoDetail',
|
||||
component: ImageToVideoDetail,
|
||||
meta: { title: '图生视频详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/storyboard-video',
|
||||
name: 'StoryboardVideo',
|
||||
component: StoryboardVideo,
|
||||
meta: { title: '分镜视频', keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: '/storyboard-video/create',
|
||||
name: 'StoryboardVideoCreate',
|
||||
component: StoryboardVideoCreate,
|
||||
meta: { title: '分镜视频创作' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Root',
|
||||
redirect: '/welcome' // 默认重定向到欢迎页面
|
||||
},
|
||||
{
|
||||
path: '/welcome',
|
||||
name: 'Welcome',
|
||||
component: Welcome,
|
||||
meta: { title: '欢迎', guest: true }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: Profile,
|
||||
meta: { title: '个人主页', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/subscription',
|
||||
name: 'Subscription',
|
||||
component: Subscription,
|
||||
meta: { title: '会员订阅', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { title: '登录', guest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: Register,
|
||||
meta: { title: '注册', guest: true }
|
||||
},
|
||||
{
|
||||
path: '/orders/:id',
|
||||
name: 'OrderDetail',
|
||||
component: OrderDetail,
|
||||
meta: { title: '订单详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/orders/create',
|
||||
name: 'OrderCreate',
|
||||
component: OrderCreate,
|
||||
meta: { title: '创建订单', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/payments',
|
||||
name: 'Payments',
|
||||
component: Payments,
|
||||
meta: { title: '支付记录', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/payments/create',
|
||||
name: 'PaymentCreate',
|
||||
component: PaymentCreate,
|
||||
meta: { title: '创建支付', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/orders',
|
||||
name: 'AdminOrders',
|
||||
component: AdminOrders,
|
||||
meta: { title: '订单管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: AdminDashboard,
|
||||
meta: { title: '后台管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/member-management',
|
||||
name: 'MemberManagement',
|
||||
component: MemberManagement,
|
||||
meta: { title: '会员管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/system-settings',
|
||||
name: 'SystemSettings',
|
||||
component: SystemSettings,
|
||||
meta: { title: '系统设置', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/generate-task-record',
|
||||
name: 'GenerateTaskRecord',
|
||||
component: GenerateTaskRecord,
|
||||
meta: { title: '生成任务记录', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/api-management',
|
||||
name: 'ApiManagement',
|
||||
component: () => import('@/views/ApiManagement.vue'),
|
||||
meta: { title: 'API管理', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/error-statistics',
|
||||
name: 'ErrorStatistics',
|
||||
component: () => import('@/views/ErrorStatistics.vue'),
|
||||
meta: { title: '错误统计', requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/hello',
|
||||
name: 'HelloWorld',
|
||||
component: HelloWorld,
|
||||
meta: { title: 'Hello World' }
|
||||
},
|
||||
{
|
||||
path: '/terms-of-service',
|
||||
name: 'TermsOfService',
|
||||
component: TermsOfService,
|
||||
meta: { title: 'Vionow 服务条款' }
|
||||
},
|
||||
{
|
||||
path: '/user-agreement',
|
||||
name: 'UserAgreement',
|
||||
component: UserAgreement,
|
||||
meta: { title: '用户协议' }
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
name: 'PrivacyPolicy',
|
||||
component: PrivacyPolicy,
|
||||
meta: { title: '隐私政策' }
|
||||
},
|
||||
{
|
||||
path: '/change-password',
|
||||
name: 'ChangePassword',
|
||||
component: ChangePassword,
|
||||
meta: { title: '修改密码', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/set-password',
|
||||
name: 'SetPassword',
|
||||
component: SetPassword,
|
||||
meta: { title: '设置密码', requiresAuth: true }
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
// 添加路由缓存配置
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 检查localStorage中的token是否被清除(例如JWT过期后被request.js清除)
|
||||
// 如果token被清除但store中仍有用户信息,则同步清除store
|
||||
const storedToken = localStorage.getItem('token')
|
||||
if (!storedToken && userStore.isAuthenticated) {
|
||||
userStore.clearUserData()
|
||||
}
|
||||
|
||||
// 优化:只在首次访问时初始化用户状态
|
||||
if (!userStore.initialized) {
|
||||
await userStore.init()
|
||||
}
|
||||
|
||||
// 处理根路径:如果已登录,重定向到个人主页;否则重定向到欢迎页面
|
||||
if (to.path === '/' || to.path === '/welcome') {
|
||||
if (userStore.isAuthenticated && to.path === '/') {
|
||||
next('/profile')
|
||||
return
|
||||
}
|
||||
// 未登录用户访问欢迎页面,允许访问
|
||||
if (!userStore.isAuthenticated && to.path === '/welcome') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!userStore.isAuthenticated) {
|
||||
// 未登录,跳转到登录页
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查管理员权限
|
||||
if (to.meta.requiresAdmin && !userStore.isAdmin) {
|
||||
// 权限不足,跳转到个人主页并显示警告
|
||||
ElMessage.warning('权限不足,只有管理员才能访问此页面')
|
||||
next('/profile')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户访问登录页,重定向到个人主页
|
||||
if (to.meta.guest && userStore.isAuthenticated) {
|
||||
next('/profile')
|
||||
return
|
||||
}
|
||||
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - AIGC Demo`
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('路由守卫错误:', error)
|
||||
// 发生错误时,允许访问但显示错误信息
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
238
frontend/src/stores/orders.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getOrders, getOrderById, createOrder, updateOrderStatus, cancelOrder, shipOrder, completeOrder } from '@/api/orders'
|
||||
|
||||
export const useOrderStore = defineStore('orders', () => {
|
||||
// 状态
|
||||
const orders = ref([])
|
||||
const currentOrder = ref(null)
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
page: 0,
|
||||
size: 10,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
// 获取订单列表
|
||||
const fetchOrders = async (params = {}) => {
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('OrderStore: 开始获取订单,参数:', params)
|
||||
|
||||
const response = await getOrders(params)
|
||||
console.log('OrderStore: API原始响应:', response)
|
||||
|
||||
if (response.success) {
|
||||
orders.value = response.data.content || response.data
|
||||
pagination.value = {
|
||||
page: response.data.number || 0,
|
||||
size: response.data.size || 10,
|
||||
total: response.data.totalElements || response.data.length,
|
||||
totalPages: response.data.totalPages || 1
|
||||
}
|
||||
console.log('OrderStore: 处理后的订单数据:', orders.value)
|
||||
console.log('OrderStore: 分页信息:', pagination.value)
|
||||
} else {
|
||||
console.error('OrderStore: API返回失败:', response.message)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('OrderStore: 获取订单异常:', error)
|
||||
return { success: false, message: '获取订单列表失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取订单详情
|
||||
const fetchOrderById = async (id) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getOrderById(id)
|
||||
|
||||
console.log('OrderStore: 获取订单详情响应:', response)
|
||||
|
||||
// axios会将响应包装在response.data中
|
||||
const responseData = response?.data || response || {}
|
||||
console.log('OrderStore: 解析后的响应数据:', responseData)
|
||||
|
||||
if (responseData.success && responseData.data) {
|
||||
currentOrder.value = responseData.data
|
||||
console.log('OrderStore: 设置后的订单详情:', currentOrder.value)
|
||||
return { success: true, data: responseData.data }
|
||||
} else if (responseData.success === false) {
|
||||
console.error('OrderStore: API返回失败:', responseData.message)
|
||||
return { success: false, message: responseData.message || '获取订单详情失败' }
|
||||
} else {
|
||||
// 如果没有success字段,尝试直接使用data
|
||||
if (responseData.id || responseData.orderNumber) {
|
||||
currentOrder.value = responseData
|
||||
return { success: true, data: responseData }
|
||||
} else {
|
||||
console.error('OrderStore: API返回数据格式错误:', responseData)
|
||||
return { success: false, message: 'API返回数据格式错误' }
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OrderStore: 获取订单详情异常:', error)
|
||||
return { success: false, message: error.response?.data?.message || error.message || '获取订单详情失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
const createNewOrder = async (orderData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await createOrder(orderData)
|
||||
|
||||
if (response.success) {
|
||||
// 刷新订单列表
|
||||
await fetchOrders()
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Create order error:', error)
|
||||
return { success: false, message: '创建订单失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
const updateOrder = async (id, status, notes) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await updateOrderStatus(id, status, notes)
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地订单状态
|
||||
const order = orders.value.find(o => o.id === id)
|
||||
if (order) {
|
||||
order.status = status
|
||||
order.updatedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
// 更新当前订单
|
||||
if (currentOrder.value && currentOrder.value.id === id) {
|
||||
currentOrder.value.status = status
|
||||
currentOrder.value.updatedAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Update order error:', error)
|
||||
return { success: false, message: '更新订单状态失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消订单
|
||||
const cancelOrderById = async (id, reason) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await cancelOrder(id, reason)
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地订单状态
|
||||
const order = orders.value.find(o => o.id === id)
|
||||
if (order) {
|
||||
order.status = 'CANCELLED'
|
||||
order.cancelledAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
// 更新当前订单
|
||||
if (currentOrder.value && currentOrder.value.id === id) {
|
||||
currentOrder.value.status = 'CANCELLED'
|
||||
currentOrder.value.cancelledAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Cancel order error:', error)
|
||||
return { success: false, message: '取消订单失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发货
|
||||
const shipOrderById = async (id, trackingNumber) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await shipOrder(id, trackingNumber)
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地订单状态
|
||||
const order = orders.value.find(o => o.id === id)
|
||||
if (order) {
|
||||
order.status = 'SHIPPED'
|
||||
order.shippedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
// 更新当前订单
|
||||
if (currentOrder.value && currentOrder.value.id === id) {
|
||||
currentOrder.value.status = 'SHIPPED'
|
||||
currentOrder.value.shippedAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Ship order error:', error)
|
||||
return { success: false, message: '发货失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 完成订单
|
||||
const completeOrderById = async (id) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await completeOrder(id)
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地订单状态
|
||||
const order = orders.value.find(o => o.id === id)
|
||||
if (order) {
|
||||
order.status = 'COMPLETED'
|
||||
order.deliveredAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
// 更新当前订单
|
||||
if (currentOrder.value && currentOrder.value.id === id) {
|
||||
currentOrder.value.status = 'COMPLETED'
|
||||
currentOrder.value.deliveredAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Complete order error:', error)
|
||||
return { success: false, message: '完成订单失败' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
orders,
|
||||
currentOrder,
|
||||
loading,
|
||||
pagination,
|
||||
// 方法
|
||||
fetchOrders,
|
||||
fetchOrderById,
|
||||
createNewOrder,
|
||||
updateOrder,
|
||||
cancelOrderById,
|
||||
shipOrderById,
|
||||
completeOrderById
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
182
frontend/src/stores/user.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login, register, logout, getCurrentUser } from '@/api/auth'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态 - 从 localStorage 尝试恢复用户信息
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
|
||||
try {
|
||||
const cachedUser = localStorage.getItem('user')
|
||||
const cachedToken = localStorage.getItem('token')
|
||||
if (cachedUser && cachedToken) {
|
||||
user.value = JSON.parse(cachedUser)
|
||||
token.value = cachedToken
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore localStorage parse errors
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'ROLE_ADMIN' || user.value?.role === 'ROLE_SUPER_ADMIN')
|
||||
const isSuperAdmin = computed(() => user.value?.role === 'ROLE_SUPER_ADMIN')
|
||||
const username = computed(() => user.value?.username || '')
|
||||
|
||||
// 可用积分(总积分 - 冻结积分)
|
||||
const availablePoints = computed(() => {
|
||||
if (!user.value) return 0
|
||||
const total = user.value.points || 0
|
||||
const frozen = user.value.frozenPoints || 0
|
||||
return Math.max(0, total - frozen)
|
||||
})
|
||||
|
||||
// 登录
|
||||
const loginUser = async (credentials) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await login(credentials)
|
||||
|
||||
if (response.success) {
|
||||
// 使用JWT认证,保存token和用户信息
|
||||
user.value = response.data.user
|
||||
token.value = response.data.token
|
||||
|
||||
// 保存到localStorage,关闭浏览器后仍保持登录
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, message: response.message }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return { success: false, message: '登录失败,请检查网络连接' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
const registerUser = async (userData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await register(userData)
|
||||
|
||||
if (response.success) {
|
||||
return { success: true, message: '注册成功,请登录' }
|
||||
} else {
|
||||
return { success: false, message: response.message }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Register error:', error)
|
||||
return { success: false, message: '注册失败,请检查网络连接' }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 登出
|
||||
const logoutUser = async () => {
|
||||
try {
|
||||
// JWT无状态,直接清除localStorage即可
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await getCurrentUser()
|
||||
// 统一使用 response.data 格式
|
||||
const data = response.data || response
|
||||
|
||||
if (data.success) {
|
||||
user.value = data.data
|
||||
localStorage.setItem('user', JSON.stringify(user.value))
|
||||
} else {
|
||||
console.warn('获取用户信息失败:', data.message)
|
||||
// 不要立即清除用户数据,保持当前登录状态
|
||||
// 只在明确的401/认证失败时才由axios拦截器处理登出
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch user error:', error)
|
||||
// 请求失败时不强制清除,保持现有本地态
|
||||
}
|
||||
}
|
||||
|
||||
// 清除用户数据
|
||||
const clearUserData = () => {
|
||||
token.value = null
|
||||
user.value = null
|
||||
// 清除 localStorage 中的用户数据
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const init = async () => {
|
||||
if (initialized.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复用户状态
|
||||
const savedToken = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
|
||||
console.log('Store init - savedToken:', savedToken ? savedToken.substring(0, 30) + '...' : 'null')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
token.value = savedToken
|
||||
user.value = JSON.parse(savedUser)
|
||||
|
||||
console.log('恢复用户状态:', user.value?.username)
|
||||
|
||||
// 刷新用户信息(确保角色等信息是最新的)
|
||||
await fetchCurrentUser()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to restore user state:', error)
|
||||
clearUserData()
|
||||
}
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
// 重置初始化状态(登录成功后调用)
|
||||
const resetInitialized = () => {
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
// 计算属性
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
username,
|
||||
availablePoints,
|
||||
// 方法
|
||||
loginUser,
|
||||
registerUser,
|
||||
logoutUser,
|
||||
fetchCurrentUser,
|
||||
clearUserData,
|
||||
init,
|
||||
initialized,
|
||||
resetInitialized
|
||||
}
|
||||
})
|
||||
41
frontend/src/utils/apiHelper.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* API 基础路径工具函数
|
||||
* 自动适配 ngrok 内网穿透和本地开发环境
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取 API 基础路径
|
||||
* @returns {string} API 基础路径
|
||||
*/
|
||||
export function getApiBaseURL() {
|
||||
// 检查是否在浏览器环境中
|
||||
if (typeof window !== 'undefined') {
|
||||
const hostname = window.location.hostname
|
||||
|
||||
// 如果当前域名包含 ngrok 或通过 Nginx 访问,使用相对路径
|
||||
if (hostname.includes('ngrok') ||
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname.startsWith('172.22.') ||
|
||||
window.location.port === '') { // 通过 Nginx 代理访问时没有端口号
|
||||
// 通过 Nginx 访问,使用相对路径(自动适配当前域名)
|
||||
return '/api'
|
||||
}
|
||||
}
|
||||
|
||||
// 默认开发环境,使用相对路径(通过 Vite 代理)
|
||||
return '/api'
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的 API URL
|
||||
* @param {string} path - API 路径(如 '/users' 或 'users')
|
||||
* @returns {string} 完整的 API URL
|
||||
*/
|
||||
export function buildApiURL(path) {
|
||||
const baseURL = getApiBaseURL()
|
||||
// 确保路径以 / 开头
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${baseURL}${cleanPath}`
|
||||
}
|
||||
|
||||
193
frontend/src/utils/download.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 跨浏览器兼容的文件下载工具
|
||||
* 特别针对 Safari/iOS 进行了优化
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检测是否为 Safari 浏览器
|
||||
*/
|
||||
export const isSafari = () => {
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
return ua.includes('safari') && !ua.includes('chrome') && !ua.includes('android')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 iOS 设备
|
||||
*/
|
||||
export const isIOS = () => {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文件下载函数
|
||||
* @param {string} url - 文件URL
|
||||
* @param {string} filename - 下载文件名
|
||||
* @param {string} mimeType - 文件MIME类型(可选)
|
||||
* @returns {Promise<boolean>} - 下载是否成功
|
||||
*/
|
||||
export const downloadFile = async (url, filename, mimeType = '') => {
|
||||
try {
|
||||
// Safari 和 iOS 特殊处理
|
||||
if (isSafari() || isIOS()) {
|
||||
return await downloadForSafari(url, filename, mimeType)
|
||||
}
|
||||
|
||||
// 其他浏览器使用标准方式
|
||||
return await downloadStandard(url, filename)
|
||||
} catch (error) {
|
||||
console.error('下载失败,尝试备用方案:', error)
|
||||
// 最终备用方案:新窗口打开
|
||||
window.open(url, '_blank')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safari/iOS 专用下载方法
|
||||
*/
|
||||
const downloadForSafari = async (url, filename, mimeType) => {
|
||||
try {
|
||||
// 方案1:尝试使用 fetch + blob
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'omit'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
|
||||
// 创建带正确 MIME 类型的 blob
|
||||
const finalMimeType = mimeType || blob.type || getMimeType(filename)
|
||||
const finalBlob = new Blob([blob], { type: finalMimeType })
|
||||
|
||||
// Safari 需要使用 FileReader 转换为 data URL
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result
|
||||
|
||||
// 创建临时链接
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
|
||||
// Safari 需要将链接添加到 DOM
|
||||
document.body.appendChild(link)
|
||||
|
||||
// 使用 setTimeout 确保 Safari 能正确处理
|
||||
setTimeout(() => {
|
||||
link.click()
|
||||
|
||||
// 延迟移除链接
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link)
|
||||
}, 100)
|
||||
|
||||
resolve(true)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
// FileReader 失败,尝试直接打开
|
||||
window.open(url, '_blank')
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(finalBlob)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Safari 下载失败:', error)
|
||||
|
||||
// 备用方案:直接打开新窗口
|
||||
// 对于视频文件,Safari 会显示播放器,用户可以长按保存
|
||||
window.open(url, '_blank')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准浏览器下载方法
|
||||
*/
|
||||
const downloadStandard = async (url, filename) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'omit'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// 清理
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 100)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('标准下载失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件名获取 MIME 类型
|
||||
*/
|
||||
const getMimeType = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const mimeTypes = {
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'avi': 'video/x-msvideo',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp'
|
||||
}
|
||||
return mimeTypes[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载视频文件
|
||||
*/
|
||||
export const downloadVideo = async (url, taskId) => {
|
||||
const filename = `video_${taskId || Date.now()}.mp4`
|
||||
return downloadFile(url, filename, 'video/mp4')
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片文件
|
||||
*/
|
||||
export const downloadImage = async (url, taskId, prefix = 'image') => {
|
||||
// 根据 URL 判断图片格式
|
||||
let ext = 'png'
|
||||
if (url.includes('.jpg') || url.includes('.jpeg')) {
|
||||
ext = 'jpg'
|
||||
} else if (url.includes('.webp')) {
|
||||
ext = 'webp'
|
||||
}
|
||||
|
||||
const filename = `${prefix}_${taskId || Date.now()}.${ext}`
|
||||
const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
|
||||
return downloadFile(url, filename, mimeType)
|
||||
}
|
||||
969
frontend/src/views/AdminDashboard.vue
Normal file
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item active">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>{{ $t('nav.dashboard') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMembers">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ $t('nav.members') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToOrders">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>{{ $t('nav.orders') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToAPI">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.apiManagement') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToTasks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>错误统计</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="online-users">
|
||||
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
|
||||
</div>
|
||||
<div class="system-uptime">
|
||||
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部操作栏 -->
|
||||
<header class="top-header">
|
||||
<div class="page-title">
|
||||
<h2>{{ $t('nav.dashboard') }}</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<LanguageSwitcher />
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="exitAdmin">
|
||||
{{ $t('admin.exitAdmin') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon users">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ $t('dashboard.totalUsers') }}</div>
|
||||
<div class="stat-number">{{ formatNumber(stats.totalUsers) }}</div>
|
||||
<div class="stat-change" :class="stats.totalUsersChange >= 0 ? 'positive' : 'negative'">
|
||||
{{ stats.totalUsersChange >= 0 ? '+' : '' }}{{ stats.totalUsersChange }}% {{ $t('dashboard.comparedToLastMonth') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon paid-users">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ $t('dashboard.paidUsers') }}</div>
|
||||
<div class="stat-number">{{ formatNumber(stats.paidUsers) }}</div>
|
||||
<div class="stat-change" :class="stats.paidUsersChange >= 0 ? 'positive' : 'negative'">
|
||||
{{ stats.paidUsersChange >= 0 ? '+' : '' }}{{ stats.paidUsersChange }}% {{ $t('dashboard.comparedToLastMonth') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon revenue">
|
||||
<el-icon><Money /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ $t('dashboard.todayRevenue') }}</div>
|
||||
<div class="stat-number">{{ formatCurrency(stats.todayRevenue) }}</div>
|
||||
<div class="stat-change" :class="stats.todayRevenueChange >= 0 ? 'positive' : 'negative'">
|
||||
{{ stats.todayRevenueChange >= 0 ? '+' : '' }}{{ stats.todayRevenueChange }}% {{ $t('dashboard.comparedToYesterday') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-section">
|
||||
<!-- 日活用户趋势 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3>{{ $t('dashboard.dailyActive') }}</h3>
|
||||
<div class="year-picker">
|
||||
<el-button :icon="ArrowLeft" circle size="small" @click="changeYear(-1)" :disabled="selectedYear <= 2025" />
|
||||
<span class="year-display">{{ selectedYear }}{{ $t('dashboard.yearSuffix') }}</span>
|
||||
<el-button :icon="ArrowRight" circle size="small" @click="changeYear(1)" :disabled="selectedYear >= 2099" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div ref="dailyActiveChart" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户转化率 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<h3>{{ $t('dashboard.conversionRate') }}</h3>
|
||||
<div class="year-picker">
|
||||
<el-button :icon="ArrowLeft" circle size="small" @click="changeYear2(-1)" :disabled="selectedYear2 <= 2025" />
|
||||
<span class="year-display">{{ selectedYear2 }}{{ $t('dashboard.yearSuffix') }}</span>
|
||||
<el-button :icon="ArrowRight" circle size="small" @click="changeYear2(1)" :disabled="selectedYear2 >= 2099" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div ref="conversionChart" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Grid,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Document,
|
||||
User as Briefcase,
|
||||
Setting,
|
||||
User as Search,
|
||||
User as Avatar,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Money,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend, getSystemStatus } from '@/api/dashboard'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 年份选择(默认当前年份)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const selectedYear = ref(currentYear)
|
||||
const selectedYear2 = ref(currentYear)
|
||||
|
||||
// 切换年份
|
||||
const changeYear = (delta) => {
|
||||
const newYear = selectedYear.value + delta
|
||||
if (newYear >= 2025 && newYear <= 2099) {
|
||||
selectedYear.value = newYear
|
||||
loadDailyActiveChart()
|
||||
}
|
||||
}
|
||||
|
||||
const changeYear2 = (delta) => {
|
||||
const newYear = selectedYear2.value + delta
|
||||
if (newYear >= 2025 && newYear <= 2099) {
|
||||
selectedYear2.value = newYear
|
||||
loadConversionChart()
|
||||
}
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
paidUsers: 0,
|
||||
todayRevenue: 0,
|
||||
totalUsersChange: 0,
|
||||
paidUsersChange: 0,
|
||||
todayRevenueChange: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 系统状态数据
|
||||
const onlineUsers = ref('0/500')
|
||||
const systemUptime = ref(t('nav.loading'))
|
||||
|
||||
// 图表相关
|
||||
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 goToMembers = () => {
|
||||
router.push('/member-management')
|
||||
}
|
||||
|
||||
const goToOrders = () => {
|
||||
router.push('/admin/orders')
|
||||
}
|
||||
|
||||
const goToAPI = () => {
|
||||
router.push('/api-management')
|
||||
}
|
||||
|
||||
const goToTasks = () => {
|
||||
router.push('/generate-task-record')
|
||||
}
|
||||
|
||||
const goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
// 处理用户头像下拉菜单
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'exitAdmin') {
|
||||
// 退出后台,返回个人首页
|
||||
router.push('/profile')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
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,
|
||||
totalUsersChange: data.totalUsersChange ?? 0,
|
||||
paidUsersChange: data.paidUsersChange ?? 0,
|
||||
todayRevenueChange: data.todayRevenueChange ?? 0
|
||||
}
|
||||
console.log('设置后的统计数据:', stats.value)
|
||||
} else {
|
||||
console.error('Get dashboard data failed:', data.error || data.message)
|
||||
ElMessage.error(t('dashboard.loadDataFailed') + ': ' + (data.message || t('dashboard.unknownError')))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Load dashboard data failed:', error)
|
||||
ElMessage.error(t('dashboard.loadDataFailed') + ': ' + (error.message || t('dashboard.unknownError')))
|
||||
} 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)
|
||||
|
||||
// 始终显示12个月
|
||||
const allMonths = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||
const monthlyData = data.monthlyData || []
|
||||
|
||||
// 创建月份到数据的映射
|
||||
const monthDataMap = {}
|
||||
monthlyData.forEach(item => {
|
||||
monthDataMap[item.month] = item
|
||||
})
|
||||
|
||||
// 填充12个月的数据,没有数据的月份为0
|
||||
const values = []
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const monthData = monthDataMap[i] || { avgDailyActive: 0, dailyActiveUsers: 0 }
|
||||
values.push(monthData.avgDailyActive || monthData.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: allMonths,
|
||||
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)
|
||||
|
||||
// 始终显示12个月
|
||||
const allMonths = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||
const monthlyData = data.monthlyData || []
|
||||
|
||||
// 创建月份到数据的映射
|
||||
const monthDataMap = {}
|
||||
monthlyData.forEach(item => {
|
||||
monthDataMap[item.month] = item
|
||||
})
|
||||
|
||||
// 填充12个月的数据,没有数据的月份为0
|
||||
const conversionRates = []
|
||||
const fullMonthlyData = []
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const monthData = monthDataMap[i] || { month: i, conversionRate: 0, totalUsers: 0, paidUsers: 0 }
|
||||
conversionRates.push(monthData.conversionRate || 0)
|
||||
fullMonthlyData.push(monthData)
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
},
|
||||
formatter: (params) => {
|
||||
const item = params[0]
|
||||
const monthData = fullMonthlyData[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: allMonths,
|
||||
axisLabel: {
|
||||
color: '#6b7280'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#6b7280',
|
||||
formatter: '{value}%'
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '转化率',
|
||||
type: 'bar',
|
||||
data: conversionRates,
|
||||
barWidth: '40%',
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: '#8b5cf6'
|
||||
}, {
|
||||
offset: 1,
|
||||
color: '#3b82f6'
|
||||
}]
|
||||
},
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
conversionChartInstance.setOption(option)
|
||||
|
||||
// 响应式调整
|
||||
window.addEventListener('resize', () => {
|
||||
if (conversionChartInstance) {
|
||||
conversionChartInstance.resize()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载用户转化率图失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时器
|
||||
let systemStatsTimer = null
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(async () => {
|
||||
console.log('后台管理页面加载完成')
|
||||
fetchSystemStats()
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
await loadDailyActiveChart()
|
||||
await loadConversionChart()
|
||||
|
||||
// 每30秒刷新一次系统状态
|
||||
systemStatsTimer = setInterval(() => {
|
||||
fetchSystemStats()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
// 获取系统统计数据(当天访问人数和系统运行时间)
|
||||
const fetchSystemStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/online-stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
onlineUsers.value = data.todayVisitors || 0
|
||||
systemUptime.value = data.uptime || t('systemSettings.unknown')
|
||||
} else {
|
||||
onlineUsers.value = '0'
|
||||
systemUptime.value = t('systemSettings.unknown')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get online stats failed:', error)
|
||||
onlineUsers.value = '0'
|
||||
systemUptime.value = t('systemSettings.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理图表和定时器
|
||||
onUnmounted(() => {
|
||||
if (dailyActiveChartInstance) {
|
||||
dailyActiveChartInstance.dispose()
|
||||
dailyActiveChartInstance = null
|
||||
}
|
||||
if (conversionChartInstance) {
|
||||
conversionChartInstance.dispose()
|
||||
conversionChartInstance = null
|
||||
}
|
||||
// 清理定时器
|
||||
if (systemStatsTimer) {
|
||||
clearInterval(systemStatsTimer)
|
||||
systemStatsTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
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;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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: #4b5563;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
margin-top: auto;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.sidebar-footer .highlight {
|
||||
color: #409EFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.online-count {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.system-uptime {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.top-header {
|
||||
background: #ffffff;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.page-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-icon.users {
|
||||
background: #fef3c7;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-icon.paid-users {
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-icon.revenue {
|
||||
background: #fce7f3;
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 图表区域 */
|
||||
.charts-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.year-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.year-display {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.year-picker .el-button {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: var(--el-border-color);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-light-9);
|
||||
--el-button-hover-border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.year-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding: 24px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed #d1d5db;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.charts-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-dashboard {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding: 16px;
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1256
frontend/src/views/AdminOrders.vue
Normal file
605
frontend/src/views/ApiManagement.vue
Normal file
@@ -0,0 +1,605 @@
|
||||
<template>
|
||||
<div class="api-management">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToDashboard">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>{{ $t('nav.dashboard') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMembers">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ $t('nav.members') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToOrders">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>{{ $t('nav.orders') }}</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.apiManagement') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToTasks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToErrorStats">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>{{ $t('nav.errorStats') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="online-users">
|
||||
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
|
||||
</div>
|
||||
<div class="system-uptime">
|
||||
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部操作栏 -->
|
||||
<header class="top-header">
|
||||
<div class="page-title">
|
||||
<h2>{{ $t('nav.apiManagement') }}</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<LanguageSwitcher />
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="exitAdmin">
|
||||
{{ $t('admin.exitAdmin') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- API密钥输入内容 -->
|
||||
<section class="api-content">
|
||||
<div class="content-header">
|
||||
<h2>{{ $t('apiManagement.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 当前配置展示 -->
|
||||
<div class="current-config">
|
||||
<h3>{{ $t('apiManagement.currentConfig') }}</h3>
|
||||
<div class="config-item">
|
||||
<span class="config-label">{{ $t('apiManagement.apiKey') }}:</span>
|
||||
<span class="config-value">{{ currentMaskedKey || $t('common.notConfigured') }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">{{ $t('apiManagement.apiEndpoint') }}:</span>
|
||||
<span class="config-value">{{ currentApiBaseUrl || $t('common.notConfigured') }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">{{ $t('apiManagement.tokenExpiration') }}:</span>
|
||||
<span class="config-value">{{ apiForm.tokenExpireHours ? apiForm.tokenExpireHours + ' ' + $t('apiManagement.hours') : $t('common.notConfigured') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-form-container">
|
||||
<h3 style="margin-bottom: 16px; color: #374151;">{{ $t('apiManagement.modifyConfig') }}</h3>
|
||||
<el-form :model="apiForm" label-width="120px" class="api-form">
|
||||
<el-form-item :label="$t('apiManagement.apiKey')">
|
||||
<el-input
|
||||
v-model="apiForm.apiKey"
|
||||
type="password"
|
||||
:placeholder="$t('apiManagement.apiKeyPlaceholder')"
|
||||
show-password
|
||||
style="width: 100%; max-width: 600px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('apiManagement.apiBaseUrl')">
|
||||
<el-input
|
||||
v-model="apiForm.apiBaseUrl"
|
||||
:placeholder="$t('apiManagement.apiBaseUrlPlaceholder')"
|
||||
style="width: 100%; max-width: 600px;"
|
||||
/>
|
||||
<div style="margin-top: 8px; color: #6b7280; font-size: 12px;">
|
||||
{{ $t('apiManagement.apiBaseUrlHint') }}: {{ currentApiBaseUrl || $t('common.notConfigured') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('apiManagement.tokenExpiration')">
|
||||
<div style="display: flex; align-items: center; gap: 12px; width: 100%; max-width: 600px;">
|
||||
<el-input
|
||||
v-model.number="apiForm.tokenExpireHours"
|
||||
type="number"
|
||||
:placeholder="$t('apiManagement.tokenPlaceholder')"
|
||||
style="flex: 1;"
|
||||
:min="1"
|
||||
:max="720"
|
||||
/>
|
||||
<span style="color: #6b7280; font-size: 14px;">{{ $t('apiManagement.hours') }}</span>
|
||||
</div>
|
||||
<div style="margin-top: 8px; color: #6b7280; font-size: 12px;">
|
||||
{{ $t('apiManagement.rangeHint') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveApiKey" :loading="saving">{{ $t('common.save') }}</el-button>
|
||||
<el-button @click="resetForm">{{ $t('common.reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Grid,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Document,
|
||||
Setting,
|
||||
Search,
|
||||
ArrowDown,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import api from '@/api/request'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// 系统状态数据
|
||||
const onlineUsers = ref('0/500')
|
||||
const systemUptime = ref(t('common.loading'))
|
||||
const apiForm = reactive({
|
||||
apiKey: '',
|
||||
apiBaseUrl: '',
|
||||
tokenExpireHours: null // 从数据库加载
|
||||
})
|
||||
const currentApiBaseUrl = ref('')
|
||||
const currentMaskedKey = ref('')
|
||||
|
||||
// 导航功能
|
||||
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 goToErrorStats = () => {
|
||||
router.push('/admin/error-statistics')
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push('/system-settings')
|
||||
}
|
||||
|
||||
// 处理用户头像下拉菜单
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'exitAdmin') {
|
||||
// 退出后台,返回个人首页
|
||||
router.push('/profile')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化JWT过期时间显示
|
||||
const formatJwtExpiration = (hours) => {
|
||||
if (!hours) return ''
|
||||
if (hours < 24) {
|
||||
return `${hours}${t('apiManagement.hours')}`
|
||||
} else if (hours < 720) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const remainingHours = hours % 24
|
||||
if (remainingHours === 0) {
|
||||
return `${days}${t('apiManagement.days')}`
|
||||
}
|
||||
return `${days}${t('apiManagement.days')}${remainingHours}${t('apiManagement.hours')}`
|
||||
} else {
|
||||
return `30${t('apiManagement.days')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 加载当前API配置
|
||||
const loadApiKey = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/api-key')
|
||||
if (response.data?.maskedKey) {
|
||||
currentMaskedKey.value = response.data.maskedKey
|
||||
console.log('当前API密钥已配置')
|
||||
}
|
||||
// 加载当前API基础URL
|
||||
if (response.data?.apiBaseUrl) {
|
||||
currentApiBaseUrl.value = response.data.apiBaseUrl
|
||||
}
|
||||
// 加载当前Token过期时间
|
||||
if (response.data?.tokenExpireHours) {
|
||||
apiForm.tokenExpireHours = response.data.tokenExpireHours
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存API配置到数据库
|
||||
const saveApiKey = async () => {
|
||||
// 检查是否有任何输入
|
||||
const hasApiKey = apiForm.apiKey && apiForm.apiKey.trim() !== ''
|
||||
const hasApiBaseUrl = apiForm.apiBaseUrl && apiForm.apiBaseUrl.trim() !== ''
|
||||
const hasTokenExpire = apiForm.tokenExpireHours && apiForm.tokenExpireHours >= 1 && apiForm.tokenExpireHours <= 720
|
||||
|
||||
// 验证输入:至少需要提供一个配置项
|
||||
if (!hasApiKey && !hasApiBaseUrl && !hasTokenExpire) {
|
||||
ElMessage.warning(t('apiManagement.atLeastOneRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const requestData = {}
|
||||
|
||||
// 如果提供了API密钥,添加到请求中
|
||||
if (hasApiKey) {
|
||||
requestData.apiKey = apiForm.apiKey.trim()
|
||||
}
|
||||
|
||||
// 如果提供了API基础URL,添加到请求中
|
||||
if (hasApiBaseUrl) {
|
||||
requestData.apiBaseUrl = apiForm.apiBaseUrl.trim()
|
||||
}
|
||||
|
||||
// 如果提供了Token过期时间,添加到请求中
|
||||
if (hasTokenExpire) {
|
||||
requestData.tokenExpireHours = apiForm.tokenExpireHours
|
||||
}
|
||||
|
||||
const response = await api.put('/api-key', requestData)
|
||||
|
||||
if (response.data?.success) {
|
||||
ElMessage.success(response.data.message || t('common.configSavedToDb'))
|
||||
// 清空输入框
|
||||
apiForm.apiKey = ''
|
||||
apiForm.apiBaseUrl = ''
|
||||
// 重新加载当前配置
|
||||
loadApiKey()
|
||||
} else {
|
||||
ElMessage.error(response.data?.error || t('common.saveFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
ElMessage.error(t('common.saveFailed') + ': ' + (error.response?.data?.message || error.message || ''))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
apiForm.apiKey = ''
|
||||
apiForm.apiBaseUrl = ''
|
||||
loadApiKey()
|
||||
}
|
||||
|
||||
// 页面加载时获取当前API密钥状态
|
||||
onMounted(() => {
|
||||
loadApiKey()
|
||||
fetchSystemStats()
|
||||
})
|
||||
|
||||
// 获取系统统计数据(当天访问人数和系统运行时间)
|
||||
const fetchSystemStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/online-stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
onlineUsers.value = data.todayVisitors || 0
|
||||
systemUptime.value = data.uptime || t('systemSettings.unknown')
|
||||
} else {
|
||||
onlineUsers.value = '0'
|
||||
systemUptime.value = t('systemSettings.unknown')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get online stats failed:', error)
|
||||
onlineUsers.value = '0'
|
||||
systemUptime.value = t('systemSettings.unknown')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.top-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.page-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.current-config {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.current-config h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(186, 230, 253, 0.5);
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.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>
|
||||
384
frontend/src/views/ChangePassword.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<!-- 修改密码卡片 -->
|
||||
<div class="login-card">
|
||||
<!-- 标题 -->
|
||||
<div class="page-title">{{ $t('changePassword.title') }}</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="password-form">
|
||||
<!-- 当前密码(可选) -->
|
||||
<div class="input-group">
|
||||
<el-input
|
||||
v-model="form.currentPassword"
|
||||
:placeholder="$t('changePassword.currentPasswordPlaceholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.currentPassword">{{ errors.currentPassword }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 新密码 -->
|
||||
<div class="input-group">
|
||||
<el-input
|
||||
v-model="form.newPassword"
|
||||
:placeholder="$t('changePassword.newPasswordPlaceholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.newPassword">{{ errors.newPassword }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认新密码 -->
|
||||
<div class="input-group">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
:placeholder="$t('changePassword.confirmPasswordPlaceholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 确定修改按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
class="submit-button"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? $t('changePassword.submitting') : $t('changePassword.confirm') }}
|
||||
</el-button>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<div class="back-button-wrapper">
|
||||
<el-button
|
||||
class="back-button"
|
||||
@click="handleBack"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 判断是否首次设置密码
|
||||
const isFirstTimeSetup = computed(() => {
|
||||
return localStorage.getItem('needSetPassword') === '1'
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 验证表单
|
||||
const validateForm = () => {
|
||||
let valid = true
|
||||
errors.currentPassword = ''
|
||||
errors.newPassword = ''
|
||||
errors.confirmPassword = ''
|
||||
|
||||
// 当前密码为可选,不强制必填
|
||||
|
||||
// 新密码必填,且必须包含英文字母和数字,不少于8位
|
||||
if (!form.newPassword) {
|
||||
errors.newPassword = t('changePassword.enterNewPassword')
|
||||
valid = false
|
||||
} else if (form.newPassword.length < 8) {
|
||||
errors.newPassword = t('changePassword.passwordMinLength')
|
||||
valid = false
|
||||
} else if (!/[a-zA-Z]/.test(form.newPassword)) {
|
||||
errors.newPassword = t('changePassword.passwordNeedLetter')
|
||||
valid = false
|
||||
} else if (!/[0-9]/.test(form.newPassword)) {
|
||||
errors.newPassword = t('changePassword.passwordNeedNumber')
|
||||
valid = false
|
||||
}
|
||||
|
||||
// 确认密码必填且必须与新密码一致
|
||||
if (!form.confirmPassword) {
|
||||
errors.confirmPassword = t('changePassword.confirmPasswordRequired')
|
||||
valid = false
|
||||
} else if (form.newPassword !== form.confirmPassword) {
|
||||
errors.confirmPassword = t('changePassword.passwordMismatch')
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
// 提交修改
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/auth/change-password',
|
||||
method: 'post',
|
||||
data: {
|
||||
oldPassword: form.currentPassword || null,
|
||||
newPassword: form.newPassword
|
||||
}
|
||||
})
|
||||
|
||||
console.log('修改密码响应:', response)
|
||||
|
||||
// response.data 是后端返回的数据
|
||||
const result = response.data
|
||||
if (result && result.success) {
|
||||
ElMessage.success(t('common.passwordSetSuccess'))
|
||||
|
||||
// 清除首次设置标记
|
||||
localStorage.removeItem('needSetPassword')
|
||||
|
||||
// 跳转到首页或之前的页面
|
||||
const redirect = route.query.redirect || '/profile'
|
||||
router.replace(redirect)
|
||||
} else {
|
||||
ElMessage.error(result?.message || t('common.updateFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
const errorMsg = error.response?.data?.message || error.message || t('common.updateFailed')
|
||||
ElMessage.error(errorMsg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const handleBack = () => {
|
||||
if (isFirstTimeSetup.value) {
|
||||
// 首次设置时返回到首页
|
||||
router.replace('/')
|
||||
} else {
|
||||
// 非首次设置时返回上一页
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查用户是否已登录
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #0a0e1a url('/images/backgrounds/login_bg.png') center/cover no-repeat;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 左上角Logo */
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.login-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 550px;
|
||||
max-width: 90vw;
|
||||
background: rgba(121, 121, 121, 0.1);
|
||||
backdrop-filter: blur(50px);
|
||||
-webkit-backdrop-filter: blur(50px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 60px 80px;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title {
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 输入组 */
|
||||
.input-group {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper) {
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
height: 60px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(217, 217, 217, 0.25);
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__inner) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: #ff7875;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 确定修改按钮 */
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: #0DC0FF;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-top: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background: #4DD4FF;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 返回按钮 */
|
||||
.back-button {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
}
|
||||
|
||||
.back-button-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-button-wrapper .back-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
width: 90%;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
756
frontend/src/views/ErrorStatistics.vue
Normal file
@@ -0,0 +1,756 @@
|
||||
<template>
|
||||
<div class="error-statistics-page">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToDashboard">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>{{ $t('nav.dashboard') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMembers">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ $t('nav.members') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToOrders">
|
||||
<el-icon><ShoppingCart /></el-icon>
|
||||
<span>{{ $t('nav.orders') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToAPI">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.apiManagement') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToTasks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ $t('nav.tasks') }}</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>{{ $t('nav.errorStats') }}</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ $t('nav.systemSettings') }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="online-users">
|
||||
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
|
||||
</div>
|
||||
<div class="system-uptime">
|
||||
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部搜索栏 -->
|
||||
<header class="top-header">
|
||||
<div class="page-title">
|
||||
<h2>{{ $t('errorStats.title') }}</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<LanguageSwitcher />
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('errorStats.userAvatar')" />
|
||||
<el-icon class="arrow-down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="exitAdmin">
|
||||
{{ $t('admin.exitAdmin') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容包装器 -->
|
||||
<div class="content-wrapper">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon error">
|
||||
<el-icon><Warning /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ $t('errorStats.totalErrors') }}</div>
|
||||
<div class="stat-number">{{ statistics.totalErrors || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon today">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ $t('errorStats.todayErrors') }}</div>
|
||||
<div class="stat-number">{{ statistics.todayErrors || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon week">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ $t('errorStats.weekErrors') }}</div>
|
||||
<div class="stat-number">{{ statistics.weekErrors || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 错误类型分布 -->
|
||||
<div class="charts-section">
|
||||
<div class="chart-card full-width">
|
||||
<div class="chart-header">
|
||||
<h3>{{ $t('errorStats.errorTypeDistribution') }}</h3>
|
||||
<el-select v-model="selectedDays" @change="loadStatistics" class="days-select">
|
||||
<el-option :label="$t('errorStats.last7Days')" :value="7"></el-option>
|
||||
<el-option :label="$t('errorStats.last30Days')" :value="30"></el-option>
|
||||
<el-option :label="$t('errorStats.last90Days')" :value="90"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="error-type-list">
|
||||
<div
|
||||
v-for="(item, index) in errorTypeStats"
|
||||
:key="item.type"
|
||||
class="error-type-item"
|
||||
>
|
||||
<div class="type-info">
|
||||
<span class="type-name">{{ item.description || item.type }}</span>
|
||||
<span class="type-count">{{ item.count }} {{ $t('errorStats.times') }}</span>
|
||||
</div>
|
||||
<div class="type-bar">
|
||||
<div
|
||||
class="type-bar-fill"
|
||||
:style="{ width: getBarWidth(item.count) + '%', backgroundColor: getBarColor(index) }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="type-percentage">{{ getPercentage(item.count) }}%</div>
|
||||
</div>
|
||||
<el-empty v-if="errorTypeStats.length === 0" :description="$t('errorStats.noErrorData')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近错误列表 -->
|
||||
<div class="recent-errors-section">
|
||||
<div class="section-header">
|
||||
<h3>{{ $t('errorStats.recentErrors') }}</h3>
|
||||
<el-button type="primary" size="small" @click="loadRecentErrors">{{ $t('errorStats.refresh') }}</el-button>
|
||||
</div>
|
||||
<el-table :data="recentErrors" v-loading="tableLoading" stripe>
|
||||
<el-table-column prop="createdAt" :label="$t('errorStats.time')" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorType" :label="$t('errorStats.errorType')" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTagType(row.errorType)">{{ row.errorType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" :label="$t('errorStats.user')" width="120" />
|
||||
<el-table-column prop="taskId" :label="$t('errorStats.taskId')" width="200" />
|
||||
<el-table-column prop="errorMessage" :label="$t('errorStats.errorMessage')" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="totalErrors"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadErrorLogs"
|
||||
@current-change="loadErrorLogs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Grid,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Document,
|
||||
Setting,
|
||||
ArrowDown,
|
||||
Warning,
|
||||
Clock,
|
||||
Calendar
|
||||
} from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const tableLoading = ref(false)
|
||||
const selectedDays = ref(7)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalErrors = ref(0)
|
||||
const onlineUsers = ref('0')
|
||||
const systemUptime = ref('--')
|
||||
|
||||
// 数据
|
||||
const statistics = ref({})
|
||||
const errorTypeStats = ref([])
|
||||
const recentErrors = ref([])
|
||||
const errorTypes = ref({})
|
||||
|
||||
// 计算总数
|
||||
const totalCount = computed(() => {
|
||||
return errorTypeStats.value.reduce((sum, item) => sum + item.count, 0)
|
||||
})
|
||||
|
||||
// 加载统计数据
|
||||
const loadStatistics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs/statistics', {
|
||||
params: { days: selectedDays.value }
|
||||
})
|
||||
if (res.data.success) {
|
||||
const data = res.data.data || {}
|
||||
statistics.value = {
|
||||
totalErrors: data.totalErrors || 0,
|
||||
todayErrors: data.todayErrors || 0,
|
||||
weekErrors: data.weekErrors || 0
|
||||
}
|
||||
// 处理错误类型统计 - 后端返回的是 errorsByType
|
||||
if (data.errorsByType) {
|
||||
errorTypeStats.value = Object.entries(data.errorsByType).map(([type, count]) => ({
|
||||
type,
|
||||
description: errorTypes.value[type] || type,
|
||||
count
|
||||
})).sort((a, b) => b.count - a.count)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
ElMessage.error(t('common.loadStatsFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载错误类型定义
|
||||
const loadErrorTypes = async () => {
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs/types')
|
||||
if (res.data.success) {
|
||||
errorTypes.value = res.data.data || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载错误类型失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载错误日志列表
|
||||
const loadErrorLogs = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs', {
|
||||
params: {
|
||||
page: currentPage.value - 1,
|
||||
size: pageSize.value
|
||||
}
|
||||
})
|
||||
if (res.data.success) {
|
||||
recentErrors.value = res.data.data || []
|
||||
totalErrors.value = res.data.totalElements || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载错误日志失败:', error)
|
||||
ElMessage.error(t('common.loadErrorLogsFailed'))
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近错误
|
||||
const loadRecentErrors = async () => {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/error-logs/recent', {
|
||||
params: { limit: 20 }
|
||||
})
|
||||
if (res.data.success) {
|
||||
recentErrors.value = res.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载最近错误失败:', error)
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取进度条宽度
|
||||
const getBarWidth = (count) => {
|
||||
if (totalCount.value === 0) return 0
|
||||
return Math.min((count / totalCount.value) * 100, 100)
|
||||
}
|
||||
|
||||
// 获取百分比
|
||||
const getPercentage = (count) => {
|
||||
if (totalCount.value === 0) return 0
|
||||
return ((count / totalCount.value) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
const getBarColor = (index) => {
|
||||
const colors = ['#f56c6c', '#e6a23c', '#409eff', '#67c23a', '#909399', '#b88230', '#8e44ad', '#16a085']
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取标签类型
|
||||
const getTagType = (errorType) => {
|
||||
const typeMap = {
|
||||
'API_ERROR': 'danger',
|
||||
'TASK_FAILED': 'warning',
|
||||
'PAYMENT_ERROR': 'danger',
|
||||
'AUTH_ERROR': 'info',
|
||||
'SYSTEM_ERROR': 'danger'
|
||||
}
|
||||
return typeMap[errorType] || 'info'
|
||||
}
|
||||
|
||||
// 导航函数
|
||||
const goToDashboard = () => router.push('/admin/dashboard')
|
||||
const goToMembers = () => router.push('/member-management')
|
||||
const goToOrders = () => router.push('/admin/orders')
|
||||
const goToAPI = () => router.push('/api-management')
|
||||
const goToTasks = () => router.push('/generate-task-record')
|
||||
const goToSettings = () => router.push('/system-settings')
|
||||
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'exitAdmin') {
|
||||
router.push('/profile')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统统计数据
|
||||
const fetchSystemStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/online-stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
onlineUsers.value = data.todayVisitors || 0
|
||||
systemUptime.value = data.uptime || '--'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchSystemStats()
|
||||
await loadErrorTypes()
|
||||
await loadStatistics()
|
||||
await loadErrorLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-statistics-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
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;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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: #4b5563;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
margin-top: auto;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.sidebar-footer .highlight {
|
||||
color: #409EFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.online-users,
|
||||
.system-uptime {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏 */
|
||||
.top-header {
|
||||
background: #ffffff;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.page-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 内容包装器 */
|
||||
.content-wrapper {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-icon.error {
|
||||
background: rgba(245, 108, 108, 0.2);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.stat-icon.today {
|
||||
background: rgba(230, 162, 60, 0.2);
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.stat-icon.week {
|
||||
background: rgba(64, 158, 255, 0.2);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stat-icon.users {
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 图表区域 */
|
||||
.charts-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-card.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.days-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
/* 错误类型列表 */
|
||||
.error-type-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.type-info {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.type-count {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.type-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.type-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.type-percentage {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 最近错误区域 */
|
||||
.recent-errors-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
:deep(.el-table) {
|
||||
background: transparent;
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: #f5f5f5;
|
||||
--el-table-row-hover-bg-color: #f5f5f5;
|
||||
--el-table-border-color: #e5e7eb;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f5f5f5 !important;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
:deep(.el-select) {
|
||||
--el-select-input-focus-border-color: #3b82f6;
|
||||
}
|
||||
|
||||
:deep(.el-pagination) {
|
||||
--el-pagination-bg-color: transparent;
|
||||
--el-pagination-text-color: #9ca3af;
|
||||
--el-pagination-button-disabled-bg-color: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1307
frontend/src/views/GenerateTaskRecord.vue
Normal file
10
frontend/src/views/HelloWorld.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Hello World!</h1>
|
||||
<p>Vue is working!</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
console.log('Vue component loaded!')
|
||||
</script>
|
||||
895
frontend/src/views/ImageToVideo.vue
Normal file
@@ -0,0 +1,895 @@
|
||||
<template>
|
||||
<div class="image-to-video-page">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToProfile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人主页</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSubscription">
|
||||
<el-icon><Compass /></el-icon>
|
||||
<span>会员订阅</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMyWorks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的作品</span>
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-item" @click="goToTextToVideo">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>文生视频</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图生视频</span>
|
||||
</div>
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>分镜视频</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部用户信息卡片 -->
|
||||
<div class="user-info-card">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="avatar-image" />
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username">mingzi_FBx7foZYDS7inLQb</div>
|
||||
<div class="profile-prompt">还没有设置个人简介,点击填写</div>
|
||||
<div class="user-id">ID 2994509784706419</div>
|
||||
</div>
|
||||
<div class="edit-profile-btn">
|
||||
<el-button type="primary">编辑资料</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已发布作品区域 -->
|
||||
<div class="published-works">
|
||||
<div class="works-tabs">
|
||||
<div class="tab active">已发布</div>
|
||||
</div>
|
||||
|
||||
<div class="works-grid">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.taskId || work.id" @click="openDetail(work)">
|
||||
<div class="work-thumbnail">
|
||||
<!-- 优先使用首帧作为封面,如果没有则使用视频 -->
|
||||
<img
|
||||
v-if="work.firstFrameUrl"
|
||||
v-lazy:loading="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="hover-create-btn" @click.stop="goToCreate(work)">
|
||||
<el-button type="primary" size="small" round>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
{{ $t('works.createSimilar') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-info">
|
||||
<div class="work-title">{{ work.prompt || work.title || $t('common.imageToVideoCategory') }}</div>
|
||||
<div class="work-meta">{{ work.date || $t('common.unknownDate') }} · {{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
|
||||
</div>
|
||||
<div class="work-actions" v-if="index === 0">
|
||||
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">{{ $t('works.createSimilar') }}</el-button>
|
||||
</div>
|
||||
<div class="work-director" v-else>
|
||||
<span>DIRECTED BY VANNOCENT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 作品详情模态框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
:title="selectedItem?.title"
|
||||
width="60%"
|
||||
class="detail-dialog"
|
||||
:modal="true"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="detail-content">
|
||||
<div class="detail-left">
|
||||
<div class="video-player">
|
||||
<img :src="selectedItem?.cover" :alt="selectedItem?.title" class="video-thumbnail" />
|
||||
<div class="play-overlay">
|
||||
<div class="play-button">▶</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-right">
|
||||
<div class="metadata-section">
|
||||
<div class="metadata-item">
|
||||
<span class="label">作品 ID</span>
|
||||
<span class="value">{{ selectedItem?.id }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">文件大小</span>
|
||||
<span class="value">{{ selectedItem?.size }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ selectedItem?.createTime }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">分类</span>
|
||||
<span class="value">{{ selectedItem?.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">描述</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-section">
|
||||
<button class="create-similar-btn" @click="createSimilar">
|
||||
做同款
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// 模态框状态
|
||||
const detailDialogVisible = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
|
||||
// 已发布作品数据
|
||||
const publishedWorks = ref([])
|
||||
|
||||
// 导航函数
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
const goToSubscription = () => {
|
||||
router.push('/subscription')
|
||||
}
|
||||
|
||||
const goToMyWorks = () => {
|
||||
router.push('/works')
|
||||
}
|
||||
|
||||
const goToTextToVideo = () => {
|
||||
router.push('/text-to-video/create')
|
||||
}
|
||||
|
||||
const goToStoryboardVideo = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
const goToCreate = (work) => {
|
||||
// 跳转到图生视频创作页面
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
// 模态框相关函数
|
||||
const openDetail = (work) => {
|
||||
selectedItem.value = work
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
detailDialogVisible.value = false
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
const getDescription = (item) => {
|
||||
if (!item) return ''
|
||||
return `这是一个${item.category}作品,展现了"What Does it Mean To You"的主题。作品通过AI技术生成,具有独特的视觉风格和创意表达。`
|
||||
}
|
||||
|
||||
const createSimilar = () => {
|
||||
// 关闭模态框并跳转到创作页面
|
||||
handleClose()
|
||||
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 || t('common.imageToVideoCategory'),
|
||||
text: task.prompt || t('common.imageToVideoCategory'),
|
||||
category: t('common.imageToVideoCategory'),
|
||||
createTime: task.createdAt ? new Date(task.createdAt).toLocaleString('zh-CN') : '',
|
||||
date: task.createdAt ? new Date(task.createdAt).toLocaleDateString('zh-CN') : t('common.unknownDate')
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
ElMessage.error(t('common.loadTaskListFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面初始化时加载任务列表
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 图片懒加载样式 */
|
||||
.lazy-loading {
|
||||
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: lazy-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.lazy-loaded {
|
||||
animation: lazy-fade-in 0.3s ease-in;
|
||||
}
|
||||
|
||||
.lazy-error {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
@keyframes lazy-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes lazy-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.image-to-video-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 280px !important;
|
||||
background: #000000 !important;
|
||||
padding: 24px 0 !important;
|
||||
border-right: 1px solid #1a1a1a !important;
|
||||
flex-shrink: 0 !important;
|
||||
z-index: 100 !important;
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 0 24px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.sora-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 分镜视频特殊样式 */
|
||||
.storyboard-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.storyboard-item .sora-tag {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2) !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 11px !important;
|
||||
padding: 2px 8px !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3) !important;
|
||||
animation: pulse-glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0% {
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 用户信息卡片 */
|
||||
.user-info-card {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-prompt {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edit-profile-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 已发布作品区域 */
|
||||
.published-works {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.works-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 0;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-item {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.work-item:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.work-thumbnail {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-thumbnail img,
|
||||
.work-thumbnail video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.work-image-thumbnail {
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.work-video-thumbnail {
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.work-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示的做同款按钮 */
|
||||
.hover-create-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.work-thumbnail:hover .hover-create-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.hover-create-btn .el-button {
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
border: none;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.hover-create-btn .el-button:hover {
|
||||
background: rgba(64, 158, 255, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* work-overlay / overlay-text 样式已移除(不再使用) */
|
||||
|
||||
.work-info {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.work-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-meta {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-actions {
|
||||
padding: 0 12px 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.work-item:hover .work-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.work-director {
|
||||
padding: 0 12px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-director span {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-to-video-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
:deep(.detail-dialog .el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333 !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__title) {
|
||||
color: #fff !important;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__headerbtn) {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
/* 全局覆盖Element Plus默认样式 */
|
||||
:deep(.el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border: 1px solid #333 !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
height: 50vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.video-player:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.detail-right {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.metadata-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
color: #d1d5db;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.create-similar-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
</style>
|
||||
3182
frontend/src/views/ImageToVideoCreate.vue
Normal file
637
frontend/src/views/ImageToVideoDetail.vue
Normal file
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<div class="video-detail-page">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="top-bar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.png" alt="Logo" />
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<el-icon class="action-icon"><User /></el-icon>
|
||||
<el-icon class="action-icon"><Setting /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="nav-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>文件</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图片</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>视频</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 视频播放器区域 -->
|
||||
<div class="video-section">
|
||||
<div class="video-player">
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="videoData.videoUrl"
|
||||
@click="togglePlay"
|
||||
@timeupdate="updateTime"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
|
||||
<!-- 视频控制栏 -->
|
||||
<div class="video-controls" v-show="showControls">
|
||||
<div class="controls-left">
|
||||
<el-button circle size="small" @click="togglePlay">
|
||||
<el-icon><VideoPlay v-if="!isPlaying" /><VideoPause v-else /></el-icon>
|
||||
</el-button>
|
||||
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<el-button circle size="small" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频操作按钮 -->
|
||||
<div class="video-actions">
|
||||
<el-tooltip :content="$t('video.download')" placement="bottom">
|
||||
<el-button circle size="small" @click="downloadVideo">
|
||||
<el-icon><Download /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="$t('common.delete')" placement="bottom">
|
||||
<el-button circle size="small" @click="deleteVideo">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情区域 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-header">
|
||||
<h3>图片详情</h3>
|
||||
<p class="subtitle">{{ $t('video.imageToVideo.referenceImage') || '参考生图' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<div class="input-section">
|
||||
<el-input
|
||||
v-model="detailInput"
|
||||
:placeholder="$t('imageToVideoDetail.detailInputPlaceholder')"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="thumbnails">
|
||||
<div class="thumbnail" v-for="(thumb, index) in thumbnails" :key="index">
|
||||
<img :src="thumb" :alt="`${$t('works.image')}${index + 1}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description">
|
||||
<h4>{{ $t('works.description') || '描述' }}</h4>
|
||||
<p>{{ videoData.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="metadata">
|
||||
<div class="meta-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ videoData.createTime }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">视频 ID</span>
|
||||
<span class="value">{{ videoData.id }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">时长</span>
|
||||
<span class="value">{{ videoData.duration }}s</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">清晰度</span>
|
||||
<span class="value">{{ videoData.resolution }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">宽高比</span>
|
||||
<span class="value">{{ videoData.aspectRatio }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-button">
|
||||
<el-button type="primary" size="large" @click="makeSimilar">
|
||||
做同款
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 滚动指示器 -->
|
||||
<div class="scroll-indicators">
|
||||
<el-icon class="scroll-arrow up"><ArrowUp /></el-icon>
|
||||
<el-icon class="scroll-arrow down"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
User, Setting, Document, User as Picture, User as VideoPlay, User as VideoPause,
|
||||
User as FullScreen, User as Download, User as Delete, User as ArrowUp, User as ArrowDown
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const videoRef = ref(null)
|
||||
|
||||
// 视频播放状态
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const showControls = ref(true)
|
||||
const loading = ref(true)
|
||||
|
||||
// 详情数据
|
||||
const detailInput = ref('')
|
||||
const videoData = ref({
|
||||
id: '',
|
||||
videoUrl: '',
|
||||
description: '',
|
||||
createTime: '',
|
||||
duration: 5,
|
||||
resolution: '1080p',
|
||||
aspectRatio: '16:9',
|
||||
status: 'PROCESSING',
|
||||
progress: 0
|
||||
})
|
||||
|
||||
const thumbnails = ref([
|
||||
'/images/backgrounds/welcome.jpg',
|
||||
'/images/backgrounds/welcome.jpg'
|
||||
])
|
||||
|
||||
// 视频控制方法
|
||||
const togglePlay = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoRef.value.pause()
|
||||
} else {
|
||||
videoRef.value.play()
|
||||
}
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
const updateTime = () => {
|
||||
if (videoRef.value) {
|
||||
currentTime.value = videoRef.value.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (videoRef.value) {
|
||||
duration.value = videoRef.value.duration
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
videoRef.value.requestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮方法
|
||||
const downloadVideo = () => {
|
||||
ElMessage.success(t('common.downloadStarted'))
|
||||
}
|
||||
|
||||
const deleteVideo = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('common.confirm'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel')
|
||||
})
|
||||
ElMessage.success(t('common.videoDeleted'))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const makeSimilar = () => {
|
||||
ElMessage.info(t('common.retryFeatureInDev'))
|
||||
}
|
||||
|
||||
// 自动隐藏控制栏
|
||||
let controlsTimer = null
|
||||
const resetControlsTimer = () => {
|
||||
clearTimeout(controlsTimer)
|
||||
showControls.value = true
|
||||
controlsTimer = setTimeout(() => {
|
||||
showControls.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 加载任务详情
|
||||
const loadTaskDetail = async () => {
|
||||
const taskId = route.params.taskId
|
||||
if (!taskId) {
|
||||
ElMessage.error(t('common.missingVideoId'))
|
||||
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 || t('common.loadWorkDetailFailed'))
|
||||
router.push('/image-to-video')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务详情失败:', error)
|
||||
ElMessage.error(t('common.loadWorkDetailFailed'))
|
||||
router.push('/image-to-video')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载任务详情
|
||||
loadTaskDetail()
|
||||
|
||||
// 监听鼠标移动来显示/隐藏控制栏
|
||||
document.addEventListener('mousemove', resetControlsTimer)
|
||||
resetControlsTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(controlsTimer)
|
||||
document.removeEventListener('mousemove', resetControlsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-detail-page {
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 顶部导航栏 */
|
||||
.top-bar {
|
||||
height: 60px;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 30px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 20px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.action-icon:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 60px;
|
||||
width: 200px;
|
||||
height: calc(100vh - 60px);
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid #333;
|
||||
padding: 20px 0;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #2a2a2a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
margin-top: 60px;
|
||||
height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 视频播放器区域 */
|
||||
.video-section {
|
||||
flex: 2;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-actions .el-button {
|
||||
background: rgba(0,0,0,0.6);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.video-actions .el-button:hover {
|
||||
background: rgba(0,0,0,0.8);
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
/* 右侧详情区域 */
|
||||
.detail-section {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border-left: 1px solid #333;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.thumbnails {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.description h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.description p {
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.action-button .el-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroll-indicators {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scroll-arrow {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.scroll-arrow:hover {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 160px;
|
||||
}
|
||||
|
||||
.video-section {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-section {
|
||||
flex: none;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
flex: none;
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
739
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,739 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<div class="login-card">
|
||||
<!-- 欢迎标题 -->
|
||||
<div class="welcome-title">
|
||||
<span class="welcome-text">欢迎来到</span>
|
||||
<span class="brand-name">Vionow</span>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="login-tabs">
|
||||
<!-- 邮箱登录盒子 -->
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: loginType === 'email' }"
|
||||
@click="loginType = 'email'"
|
||||
>
|
||||
<svg width="105" height="30" viewBox="0 0 105 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.598 6.112V25.638H11.076V24.13H4.316V25.638H1.768V6.112H6.344V2.55H8.996V6.112H13.598ZM4.316 21.712H6.344V16.122H4.316V21.712ZM8.996 21.712H11.076V16.122H8.996V21.712ZM4.316 13.73H6.344V8.556H4.316V13.73ZM8.996 8.556V13.73H11.076V8.556H8.996ZM15.34 3.772H24.232V5.748C23.452 8.4 22.62 10.818 21.736 13.054C23.556 15.654 24.466 17.76 24.492 19.398C24.492 20.958 24.154 22.024 23.504 22.596C22.802 23.22 21.398 23.532 19.318 23.532L18.512 20.802C19.474 20.906 20.28 20.984 20.956 20.984C21.372 20.958 21.658 20.828 21.814 20.62C21.918 20.464 21.97 20.048 21.996 19.398C21.97 17.786 20.982 15.68 19.032 13.054C19.864 11.156 20.67 8.868 21.45 6.164H17.914V26.73H15.34V3.772ZM27.716 12.326H31.616V9.83H34.346V12.326H37.57V14.926H34.346V15.446C35.542 16.538 36.79 17.734 38.038 19.06L36.53 21.348C35.698 20.048 34.97 18.93 34.346 18.046V26.756H31.616V18.202C30.654 20.23 29.432 22.102 27.924 23.818L26.754 20.776C28.756 19.06 30.238 17.11 31.226 14.926H27.716V12.326ZM49.4 10.48V26.73H46.774V25.82H41.106V26.73H38.48V10.48H49.4ZM41.106 23.428H46.774V21.244H41.106V23.428ZM41.106 18.956H46.774V17.058H41.106V18.956ZM41.106 14.77H46.774V12.872H41.106V14.77ZM31.538 6.762C30.966 7.75 30.316 8.634 29.614 9.466L27.248 7.958C28.782 6.294 29.874 4.5 30.498 2.576L33.124 3.148C32.968 3.564 32.838 3.98 32.682 4.37H38.974V6.762H35.568C36.088 7.49 36.504 8.192 36.842 8.842L34.346 9.778C33.878 8.738 33.306 7.724 32.682 6.762H31.538ZM42.64 6.762C42.12 7.828 41.574 8.816 40.95 9.726L38.636 8.244C39.962 6.424 40.898 4.474 41.444 2.446L44.018 3.018C43.862 3.486 43.732 3.928 43.602 4.37H50.83V6.762H46.67C47.19 7.49 47.632 8.192 47.97 8.842L45.578 9.752C45.11 8.712 44.538 7.724 43.862 6.762H42.64ZM57.538 13.522H72.566V20.282H57.538V13.522ZM69.836 17.89V15.888H60.268V17.89H69.836ZM60.45 20.438C61.282 21.296 62.036 22.31 62.712 23.454H67.626C68.354 22.466 68.978 21.426 69.524 20.36L72.046 21.27C71.578 22.05 71.084 22.778 70.564 23.454H76.232V26.028H53.716V23.454H59.826C59.28 22.726 58.63 22.05 57.902 21.4L60.45 20.438ZM56.368 6.112C57.564 6.996 58.63 7.854 59.514 8.686C60.424 7.802 61.152 6.866 61.724 5.878H55.822V3.382H64.792V5.41C64.194 6.918 63.388 8.27 62.374 9.466H68.822C67.262 7.75 66.014 5.904 65.104 3.876L67.366 2.628C67.782 3.59 68.276 4.5 68.848 5.358C69.758 4.63 70.538 3.85 71.188 3.018L73.086 4.708C72.306 5.644 71.37 6.528 70.304 7.308C70.72 7.828 71.188 8.296 71.708 8.764C72.8 7.932 73.71 6.996 74.49 5.982L76.388 7.646C75.608 8.634 74.672 9.544 73.632 10.35C74.776 11.182 76.024 11.962 77.428 12.664L75.634 14.744C73.606 13.6 71.838 12.352 70.33 10.974V11.936H60.788V11.104C59.124 12.56 57.07 13.782 54.626 14.796L52.962 12.664C54.782 11.962 56.316 11.156 57.616 10.246C56.758 9.466 55.744 8.66 54.548 7.828L56.368 6.112ZM82.368 3.408H97.864V11.806H102.258V14.276H98.228L100.282 15.966C98.93 17.63 97.396 18.956 95.68 19.892C97.604 21.296 99.84 22.518 102.388 23.61L101.01 26.002C97.37 24.286 94.432 22.232 92.196 19.814V23.974C92.196 25.794 91.39 26.73 89.804 26.73H86.762L86.164 24.182C87.1 24.286 88.01 24.364 88.894 24.364C89.284 24.364 89.492 24 89.492 23.324V19.918C86.944 22.154 83.928 24.156 80.47 25.95L79.378 23.428C83.278 21.66 86.632 19.58 89.492 17.136V14.276H79.768V11.806H95.108V10.012H83.252V7.672H95.108V5.852H82.368V3.408ZM83.018 14.666C84.526 15.706 85.8 16.746 86.84 17.786L85.072 19.554C84.162 18.566 82.888 17.526 81.224 16.382L83.018 14.666ZM98.176 14.276H92.196V16.824C92.69 17.37 93.236 17.89 93.834 18.41C95.498 17.422 96.954 16.044 98.176 14.276Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="tab-divider"></div>
|
||||
|
||||
<!-- 账号登录盒子 -->
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: loginType === 'password' }"
|
||||
@click="loginType = 'password'"
|
||||
>
|
||||
<svg width="105" height="30" viewBox="0 0 105 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.362 3.46V20.126H7.996V5.956H3.212V20.126H0.768V3.46H10.362ZM4.434 7.49H6.722V16.018C6.644 18.93 6.202 21.27 5.37 23.012C4.564 24.65 3.264 25.898 1.47 26.782L0.014 24.494C1.704 23.636 2.848 22.57 3.472 21.322C4.044 19.944 4.356 18.176 4.434 16.018V7.49ZM7.684 20.724C8.984 22.05 10.076 23.35 10.986 24.624L9.036 26.574C8.308 25.274 7.294 23.896 5.942 22.414L7.684 20.724ZM17.044 15.732H15.328V23.298C16.342 22.882 17.33 22.362 18.318 21.738L18.786 24.156C17.122 25.196 15.224 26.028 13.092 26.704L11.974 24.286C12.442 24.052 12.676 23.688 12.676 23.22V15.732H11.064V13.132H12.676V2.68H15.328V13.132H23.622V15.732H19.384C20.528 19.45 22.14 22.336 24.168 24.364L22.322 26.47C20.008 24.026 18.24 20.438 17.044 15.732ZM21.23 3.746L23.31 5.41C21.516 8.322 19.436 10.506 17.044 11.91L15.588 9.83C17.772 8.478 19.67 6.45 21.23 3.746ZM29.966 3.356H46.034V11.026H29.966V3.356ZM43.278 8.608V5.8H32.722V8.608H43.278ZM31.76 15.16H26.118V12.534H49.856V15.16H34.516L33.814 17.422H46.372C46.164 21.894 45.748 24.546 45.072 25.378C44.396 26.184 43.122 26.6 41.198 26.6C39.924 26.6 38.78 26.522 37.766 26.392L36.882 23.922C38.286 24.052 39.508 24.13 40.6 24.13C41.874 24.13 42.654 23.87 42.966 23.402C43.252 22.908 43.46 21.738 43.59 19.866H30.564L31.76 15.16ZM56.538 13.522H71.566V20.282H56.538V13.522ZM68.836 17.89V15.888H59.268V17.89H68.836ZM59.45 20.438C60.282 21.296 61.036 22.31 61.712 23.454H66.626C67.354 22.466 67.978 21.426 68.524 20.36L71.046 21.27C70.578 22.05 70.084 22.778 69.564 23.454H75.232V26.028H52.716V23.454H58.826C58.28 22.726 57.63 22.05 56.902 21.4L59.45 20.438ZM55.368 6.112C56.564 6.996 57.63 7.854 58.514 8.686C59.424 7.802 60.152 6.866 60.724 5.878H54.822V3.382H63.792V5.41C63.194 6.918 62.388 8.27 61.374 9.466H67.822C66.262 7.75 65.014 5.904 64.104 3.876L66.366 2.628C66.782 3.59 67.276 4.5 67.848 5.358C68.758 4.63 69.538 3.85 70.188 3.018L72.086 4.708C71.306 5.644 70.37 6.528 69.304 7.308C69.72 7.828 70.188 8.296 70.708 8.764C71.8 7.932 72.71 6.996 73.49 5.982L75.388 7.646C74.608 8.634 73.672 9.544 72.632 10.35C73.776 11.182 75.024 11.962 76.428 12.664L74.634 14.744C72.606 13.6 70.838 12.352 69.33 10.974V11.936H59.788V11.104C58.124 12.56 56.07 13.782 53.626 14.796L51.962 12.664C53.782 11.962 55.316 11.156 56.616 10.246C55.758 9.466 54.744 8.66 53.548 7.828L55.368 6.112ZM81.368 3.408H96.864V11.806H101.258V14.276H97.228L99.282 15.966C97.93 17.63 96.396 18.956 94.68 19.892C96.604 21.296 98.84 22.518 101.388 23.61L100.01 26.002C96.37 24.286 93.432 22.232 91.196 19.814V23.974C91.196 25.794 90.39 26.73 88.804 26.73H85.762L85.164 24.182C86.1 24.286 87.01 24.364 87.894 24.364C88.284 24.364 88.492 24 88.492 23.324V19.918C85.944 22.154 82.928 24.156 79.47 25.95L78.378 23.428C82.278 21.66 85.632 19.58 88.492 17.136V14.276H78.768V11.806H94.108V10.012H82.252V7.672H94.108V5.852H81.368V3.408ZM82.018 14.666C83.526 15.706 84.8 16.746 85.84 17.786L84.072 19.554C83.162 18.566 81.888 17.526 80.224 16.382L82.018 14.666ZM97.176 14.276H91.196V16.824C91.69 17.37 92.236 17.89 92.834 18.41C94.498 17.422 95.954 16.044 97.176 14.276Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form">
|
||||
<!-- 邮箱登录 / 密码登录 表单 -->
|
||||
<div class="email-login">
|
||||
<!-- 邮箱输入 -->
|
||||
<div class="email-input-group">
|
||||
<el-input
|
||||
ref="emailInput"
|
||||
v-model="loginForm.email"
|
||||
:placeholder="$t('login.emailPlaceholder')"
|
||||
class="email-input"
|
||||
type="email"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.email">{{ errors.email }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入(仅验证码登录显示) -->
|
||||
<div class="code-input-wrapper" v-if="loginType === 'email'">
|
||||
<el-input
|
||||
ref="codeInput"
|
||||
v-model="loginForm.code"
|
||||
:placeholder="$t('login.codePlaceholder')"
|
||||
class="code-input"
|
||||
@keyup.enter="handleLogin"
|
||||
@input="filterCodeSpaces"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="get-code-text"
|
||||
:class="{ disabled: countdown > 0 || !isEmailValid }"
|
||||
@click="getEmailCode"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : $t('login.getCode') }}
|
||||
</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="input-error" v-if="errors.code">{{ errors.code }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入(仅密码登录显示) -->
|
||||
<div v-if="loginType === 'password'" class="password-input-group">
|
||||
<el-input
|
||||
ref="passwordInput"
|
||||
v-model="loginForm.password"
|
||||
:placeholder="$t('login.passwordPlaceholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.password">{{ errors.password }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="userStore.loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ userStore.loading ? $t('login.loggingIn') : $t('login.loginOrRegister') }}
|
||||
</el-button>
|
||||
|
||||
<!-- 协议文字 -->
|
||||
<p class="agreement-text">
|
||||
{{ $t('login.agreementPrefix') }}<router-link to="/terms-of-service" class="agreement-link">{{ $t('login.termsOfService') }}</router-link>{{ $t('login.and') }}<router-link to="/privacy-policy" class="agreement-link">{{ $t('login.privacyPolicy') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { loginWithEmail, login, sendEmailCode, setDevEmailCode, getCurrentUser } from '@/api/auth'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const countdown = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
const loginType = ref('email') // 'email' or 'password'
|
||||
|
||||
const loginForm = reactive({
|
||||
email: '',
|
||||
code: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
// inline errors for fields and server
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
code: '',
|
||||
password: '',
|
||||
server: ''
|
||||
})
|
||||
|
||||
// input refs for focusing
|
||||
const emailInput = ref(null)
|
||||
const codeInput = ref(null)
|
||||
const passwordInput = ref(null)
|
||||
|
||||
const isEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(loginForm.email))
|
||||
const isCodeValid = computed(() => /^\d{6}$/.test(loginForm.code))
|
||||
const isPasswordValid = computed(() => loginForm.password && loginForm.password.length >= 6)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (loginType.value === 'email') {
|
||||
return isEmailValid.value && isCodeValid.value
|
||||
}
|
||||
return isEmailValid.value && isPasswordValid.value
|
||||
})
|
||||
|
||||
// 清空表单
|
||||
const clearForm = async () => {
|
||||
loginForm.email = ''
|
||||
loginForm.code = ''
|
||||
loginForm.password = ''
|
||||
errors.email = errors.code = errors.password = errors.server = ''
|
||||
// 重置倒计时
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
countdown.value = 0
|
||||
// focus email input after clearing
|
||||
await nextTick()
|
||||
emailInput.value && emailInput.value.focus && emailInput.value.focus()
|
||||
}
|
||||
|
||||
// 过滤验证码中的空格
|
||||
const filterCodeSpaces = () => {
|
||||
loginForm.code = loginForm.code.replace(/\s/g, '')
|
||||
}
|
||||
|
||||
// 组件挂载时从URL参数读取邮箱
|
||||
onMounted(() => {
|
||||
// 从URL参数中读取邮箱
|
||||
if (route.query.email) {
|
||||
loginForm.email = route.query.email
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 获取邮箱验证码
|
||||
const getEmailCode = async () => {
|
||||
errors.email = ''
|
||||
errors.code = ''
|
||||
errors.password = ''
|
||||
errors.server = ''
|
||||
|
||||
if (!loginForm.email) {
|
||||
errors.email = '请输入邮箱地址'
|
||||
emailInput.value && emailInput.value.focus && emailInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmailValid.value) {
|
||||
errors.email = '请输入正确的邮箱地址'
|
||||
emailInput.value && emailInput.value.focus && emailInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端API发送邮箱验证码
|
||||
const response = await sendEmailCode(loginForm.email)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
ElMessage.success(t('common.codeSentToEmail'))
|
||||
// 开始倒计时
|
||||
startCountdown()
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || t('common.updateFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送验证码失败:', error)
|
||||
// 开发环境:显示真实验证码
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// 生成6位随机验证码(与后端逻辑一致)
|
||||
const randomCode = Array.from({length: 6}, () => Math.floor(Math.random() * 10)).join('')
|
||||
|
||||
// 开发模式:将验证码同步到后端
|
||||
try {
|
||||
await setDevEmailCode(loginForm.email, randomCode)
|
||||
} catch (syncError) {
|
||||
console.warn('同步验证码到后端失败:', syncError)
|
||||
}
|
||||
|
||||
console.log(`📨 验证码已发送到: ${loginForm.email}`)
|
||||
ElMessage.success(t('common.codeSentToEmail'))
|
||||
startCountdown()
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.message || t('common.updateFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
countdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
// 基本邮箱校验
|
||||
if (!loginForm.email) {
|
||||
ElMessage.warning(t('common.pleaseEnterEmail'))
|
||||
return
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(loginForm.email)) {
|
||||
ElMessage.warning(t('common.pleaseEnterValidEmail'))
|
||||
return
|
||||
}
|
||||
|
||||
// 登录前清除旧的token,避免过期token影响新的登录请求
|
||||
const oldToken = localStorage.getItem('token')
|
||||
console.log('登录前旧token:', oldToken ? oldToken.substring(0, 50) + '...' : 'null')
|
||||
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
userStore.token = null
|
||||
userStore.user = null
|
||||
|
||||
try {
|
||||
console.log('开始登录... 登录方式:', loginType)
|
||||
|
||||
let response = null
|
||||
|
||||
if (loginType.value === 'email') {
|
||||
// 验证码登录
|
||||
if (!loginForm.code) {
|
||||
errors.code = '请输入验证码'
|
||||
codeInput.value && codeInput.value.focus && codeInput.value.focus()
|
||||
return
|
||||
}
|
||||
if (!isCodeValid.value) {
|
||||
errors.code = '验证码格式不正确,请输入6位数字'
|
||||
codeInput.value && codeInput.value.focus && codeInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
response = await loginWithEmail({ email: loginForm.email, code: loginForm.code })
|
||||
} else {
|
||||
// 密码登录
|
||||
if (!loginForm.password) {
|
||||
errors.password = '请输入密码'
|
||||
passwordInput.value && passwordInput.value.focus && passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
if (!isPasswordValid.value) {
|
||||
errors.password = '密码至少 6 位'
|
||||
passwordInput.value && passwordInput.value.focus && passwordInput.value.focus()
|
||||
return
|
||||
}
|
||||
|
||||
response = await login({ email: loginForm.email, password: loginForm.password })
|
||||
}
|
||||
|
||||
if (response && response.data && response.data.success) {
|
||||
// 保存用户信息和token
|
||||
const loginUser = response.data.data.user
|
||||
const loginToken = response.data.data.token
|
||||
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
|
||||
|
||||
console.log('登录成功,新token:', loginToken ? loginToken.substring(0, 50) + '...' : 'null')
|
||||
console.log('新旧token是否相同:', oldToken === loginToken)
|
||||
|
||||
localStorage.setItem('token', loginToken)
|
||||
localStorage.setItem('user', JSON.stringify(loginUser))
|
||||
userStore.user = loginUser
|
||||
userStore.token = loginToken
|
||||
|
||||
// 重置初始化状态,确保路由守卫使用新 token
|
||||
userStore.resetInitialized()
|
||||
|
||||
// 验证保存是否成功
|
||||
const savedToken = localStorage.getItem('token')
|
||||
console.log('验证localStorage中的token:', savedToken ? savedToken.substring(0, 50) + '...' : 'null')
|
||||
console.log('token保存成功:', savedToken === loginToken)
|
||||
|
||||
// 根据后端返回的标记设置是否需要修改密码
|
||||
if (needsPasswordChange) {
|
||||
localStorage.setItem('needSetPassword', '1')
|
||||
console.log('新用户首次登录,需要设置密码')
|
||||
} else {
|
||||
localStorage.removeItem('needSetPassword')
|
||||
}
|
||||
|
||||
console.log('登录成功,用户信息:', userStore.user, '需要设置密码:', needsPasswordChange)
|
||||
ElMessage.success(t('common.loginSuccess'))
|
||||
|
||||
// 等待一下确保状态更新
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
// 如果需要设置密码,跳转到设置密码页面
|
||||
const needSetPassword = localStorage.getItem('needSetPassword') === '1'
|
||||
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
|
||||
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
|
||||
|
||||
// 强制刷新页面,确保所有组件使用新token
|
||||
window.location.href = redirectPath
|
||||
return
|
||||
|
||||
console.log('路由跳转完成')
|
||||
} else {
|
||||
const msg = response?.data?.message || t('common.loginFailed')
|
||||
errors.server = msg
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
const msg = error.response?.data?.message || t('common.loginFailedRetry')
|
||||
errors.server = msg
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #0a0e1a url('/images/backgrounds/login_bg.png') center/cover no-repeat;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 左上角Logo */
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 145px;
|
||||
transform: translateY(-50%);
|
||||
width: 773px;
|
||||
height: 796px;
|
||||
max-width: 90vw;
|
||||
background: rgba(121, 121, 121, 0.1);
|
||||
backdrop-filter: blur(50px);
|
||||
-webkit-backdrop-filter: blur(50px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 80px 82px;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 欢迎标题 */
|
||||
.welcome-title {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
font-size: 36px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: #00D4FF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 登录方式切换 */
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 50px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
user-select: none;
|
||||
color: #9EA9B6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tab-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #9EA9B6;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
/* 邮箱输入组 */
|
||||
.email-input-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.email-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 快捷输入标签 */
|
||||
.quick-email-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.email-tag {
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
border: 1px solid rgba(64, 158, 255, 0.25);
|
||||
color: #66B1FF;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.email-tag:hover {
|
||||
background: rgba(64, 158, 255, 0.2);
|
||||
border-color: rgba(64, 158, 255, 0.4);
|
||||
color: #409EFF;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.email-input :deep(.el-input__wrapper) {
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
height: 80px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.email-input :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(217, 217, 217, 0.25);
|
||||
}
|
||||
|
||||
.email-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.email-input :deep(.el-input__inner) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.email-input :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 验证码输入组 */
|
||||
.code-input-wrapper {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.code-input :deep(.el-input__wrapper) {
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
height: 80px;
|
||||
transition: all 0.3s ease;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.code-input :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(217, 217, 217, 0.25);
|
||||
}
|
||||
|
||||
.code-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code-input :deep(.el-input__inner) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.code-input :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.get-code-text {
|
||||
color: #00D4FF;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.get-code-text:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.get-code-text.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper) {
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
height: 80px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(217, 217, 217, 0.25);
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__inner) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: #ff7875;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: #0DC0FF;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-top: 30px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: #4DD4FF;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 协议文字 */
|
||||
.agreement-text {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 18px;
|
||||
margin: 20px 0 0 0;
|
||||
line-height: 30px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: #00D4FF;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.agreement-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.login-card {
|
||||
right: 5%;
|
||||
width: 450px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
transform: none;
|
||||
margin: 50px auto;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 40px 25px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.code-input-group {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
1281
frontend/src/views/MemberManagement.vue
Normal file
2950
frontend/src/views/MyWorks.vue
Normal file
383
frontend/src/views/OrderCreate.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="order-create">
|
||||
<el-page-header @back="$router.go(-1)" :content="$t('orderCreate.title')">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">
|
||||
<el-icon><Check /></el-icon>
|
||||
{{ $t('orderCreate.createButton') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card class="form-card">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item :label="$t('orderCreate.orderTypeLabel')" prop="orderType">
|
||||
<el-select v-model="form.orderType" :placeholder="$t('orderCreate.orderTypePlaceholder')">
|
||||
<el-option :label="$t('orderCreate.orderTypeService')" value="SERVICE" />
|
||||
<el-option :label="$t('orderCreate.orderTypeSubscription')" value="SUBSCRIPTION" />
|
||||
<el-option :label="$t('orders.digitalProduct')" value="DIGITAL" />
|
||||
<el-option :label="$t('orderCreate.orderTypeProduct')" value="VIRTUAL" />
|
||||
</el-select>
|
||||
<div class="field-description">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ $t('orderCreate.orderTypeHint') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('orderCreate.currencyLabel')" prop="currency">
|
||||
<el-select v-model="form.currency" :placeholder="$t('orderCreate.currencyPlaceholder')">
|
||||
<el-option :label="$t('orderCreate.currencyCNY')" value="CNY" />
|
||||
<el-option :label="$t('orderCreate.currencyUSD')" value="USD" />
|
||||
<el-option label="EUR (Euro)" value="EUR" />
|
||||
</el-select>
|
||||
<div class="field-description">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ $t('orderCreate.currencyHint') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('orderCreate.descriptionLabel')" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('orderCreate.descriptionPlaceholder')"
|
||||
/>
|
||||
<div class="field-description">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ $t('orderCreate.descriptionHint') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('orderCreate.contactEmailLabel')" prop="contactEmail">
|
||||
<el-input
|
||||
v-model="form.contactEmail"
|
||||
type="email"
|
||||
:placeholder="$t('orderCreate.contactEmailPlaceholder')"
|
||||
/>
|
||||
<div class="field-description">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ $t('orderCreate.contactEmailHint') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('orderCreate.contactPhoneLabel')" prop="contactPhone">
|
||||
<el-input
|
||||
v-model="form.contactPhone"
|
||||
:placeholder="$t('orderCreate.contactPhonePlaceholder')"
|
||||
/>
|
||||
<div class="field-description">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ $t('orderCreate.contactPhoneHint') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 虚拟商品不需要收货地址 -->
|
||||
<template v-if="isPhysicalOrder">
|
||||
<el-form-item :label="$t('orderCreate.shippingAddressLabel')" prop="shippingAddress">
|
||||
<el-input
|
||||
v-model="form.shippingAddress"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('orderCreate.shippingAddressPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('orderCreate.billingAddressLabel')" prop="billingAddress">
|
||||
<el-input
|
||||
v-model="form.billingAddress"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('orderCreate.billingAddressPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 订单项 -->
|
||||
<el-form-item :label="$t('orderCreate.virtualProductsLabel')">
|
||||
<div class="field-description">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ $t('orderCreate.virtualProductsHint') }}</span>
|
||||
</div>
|
||||
<div class="order-items">
|
||||
<div
|
||||
v-for="(item, index) in form.orderItems"
|
||||
:key="index"
|
||||
class="order-item"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-input
|
||||
v-model="item.productName"
|
||||
:placeholder="$t('orderCreate.productNamePlaceholder')"
|
||||
@input="calculateSubtotal(index)"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-input-number
|
||||
v-model="item.unitPrice"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
:placeholder="$t('orderCreate.unitPricePlaceholder')"
|
||||
@change="calculateSubtotal(index)"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-input-number
|
||||
v-model="item.quantity"
|
||||
:min="1"
|
||||
:placeholder="$t('orderCreate.quantityPlaceholder')"
|
||||
@change="calculateSubtotal(index)"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-input
|
||||
v-model="item.subtotal"
|
||||
readonly
|
||||
:placeholder="$t('orderCreate.subtotalPlaceholder')"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
circle
|
||||
@click="removeItem(index)"
|
||||
v-if="form.orderItems.length > 1"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="addItem"
|
||||
class="add-item-btn"
|
||||
>
|
||||
添加虚拟商品
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="total-amount">
|
||||
<span class="total-label">订单总计:</span>
|
||||
<span class="total-value">{{ form.currency }} {{ totalAmount }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useOrderStore } from '@/stores/orders'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Delete, Check, User } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
orderType: 'SERVICE',
|
||||
currency: 'CNY',
|
||||
description: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
shippingAddress: '',
|
||||
billingAddress: '',
|
||||
orderItems: [
|
||||
{
|
||||
productName: '',
|
||||
unitPrice: 0,
|
||||
quantity: 1,
|
||||
subtotal: 0
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const rules = {
|
||||
orderType: [
|
||||
{ required: true, message: '请选择订单类型', trigger: 'change' }
|
||||
],
|
||||
currency: [
|
||||
{ required: true, message: '请选择货币', trigger: 'change' }
|
||||
],
|
||||
contactEmail: [
|
||||
{ required: true, message: '请输入联系邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 检查是否为实体商品订单
|
||||
const isPhysicalOrder = computed(() => {
|
||||
return form.orderType === 'PHYSICAL'
|
||||
})
|
||||
|
||||
// 计算总金额
|
||||
const totalAmount = computed(() => {
|
||||
return form.orderItems.reduce((total, item) => {
|
||||
return total + parseFloat(item.subtotal || 0)
|
||||
}, 0).toFixed(2)
|
||||
})
|
||||
|
||||
// 计算小计
|
||||
const calculateSubtotal = (index) => {
|
||||
const item = form.orderItems[index]
|
||||
if (item.unitPrice && item.quantity) {
|
||||
item.subtotal = parseFloat((item.unitPrice * item.quantity).toFixed(2))
|
||||
} else {
|
||||
item.subtotal = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 添加商品项
|
||||
const addItem = () => {
|
||||
form.orderItems.push({
|
||||
productName: '',
|
||||
unitPrice: 0,
|
||||
quantity: 1,
|
||||
subtotal: 0
|
||||
})
|
||||
}
|
||||
|
||||
// 删除商品项
|
||||
const removeItem = (index) => {
|
||||
if (form.orderItems.length > 1) {
|
||||
form.orderItems.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 验证订单项
|
||||
const validItems = form.orderItems.filter(item =>
|
||||
item.productName && item.unitPrice > 0 && item.quantity > 0
|
||||
)
|
||||
|
||||
if (validItems.length === 0) {
|
||||
ElMessage.error(t('common.addValidProduct'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 准备提交数据
|
||||
const orderData = {
|
||||
orderType: form.orderType,
|
||||
currency: form.currency,
|
||||
description: form.description,
|
||||
contactEmail: form.contactEmail,
|
||||
contactPhone: form.contactPhone,
|
||||
shippingAddress: form.shippingAddress,
|
||||
billingAddress: form.billingAddress,
|
||||
totalAmount: parseFloat(totalAmount.value),
|
||||
status: 'PENDING', // 新创建的订单状态为待支付
|
||||
orderItems: validItems.map(item => ({
|
||||
productName: item.productName,
|
||||
unitPrice: parseFloat(item.unitPrice),
|
||||
quantity: parseInt(item.quantity),
|
||||
subtotal: parseFloat(item.subtotal)
|
||||
}))
|
||||
}
|
||||
|
||||
const response = await orderStore.createNewOrder(orderData)
|
||||
|
||||
if (response.success) {
|
||||
ElMessage.success(t('common.orderCreateSuccess'))
|
||||
router.push('/admin/orders')
|
||||
} else {
|
||||
ElMessage.error(response.message || t('common.orderCreateFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create order error:', error)
|
||||
ElMessage.error(t('common.orderCreateFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-create {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.order-items {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
text-align: right;
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: #f0f9ff;
|
||||
border: 1px solid #b3d8ff;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field-description .el-icon {
|
||||
margin-right: 6px;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-description span {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
222
frontend/src/views/OrderDetail.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="order-detail">
|
||||
<el-page-header @back="$router.go(-1)" :content="$t('orders.orderDetail')">
|
||||
<template #extra>
|
||||
<el-button-group>
|
||||
<el-button v-if="order?.canPay()" type="success" @click="handlePayment">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
{{ $t('subscription.subscribe') }}
|
||||
</el-button>
|
||||
<el-button v-if="order?.canCancel()" type="danger" @click="handleCancel">
|
||||
<el-icon><Close /></el-icon>
|
||||
{{ $t('common.cancel') }}
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card v-if="order" class="order-card">
|
||||
<template #header>
|
||||
<div class="order-header">
|
||||
<h3>{{ $t('orders.basicInfo') }}</h3>
|
||||
<el-tag :type="getStatusType(order.status)">
|
||||
{{ getStatusText(order.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="$t('orders.orderNumber')">{{ order.orderNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.orderType')">{{ getOrderTypeText(order.orderType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.amount')">
|
||||
<span class="amount">{{ order.currency }} {{ order.totalAmount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.createTime')">{{ formatDate(order.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.email')" v-if="order.contactEmail">{{ order.contactEmail }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.phone')" v-if="order.contactPhone">{{ order.contactPhone }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="order.description" class="order-description">
|
||||
<h4>{{ $t('orders.description') }}</h4>
|
||||
<p>{{ order.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="order.orderItems && order.orderItems.length > 0" class="order-items">
|
||||
<h4>{{ $t('orderCreate.virtualProductsLabel') }}</h4>
|
||||
<el-table :data="order.orderItems" border>
|
||||
<el-table-column prop="productName" :label="$t('orderCreate.productNamePlaceholder').split('(')[0]" />
|
||||
<el-table-column prop="unitPrice" :label="$t('orderCreate.unitPricePlaceholder')" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ order.currency }} {{ row.unitPrice }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="$t('orderCreate.quantityPlaceholder')" width="80" />
|
||||
<el-table-column prop="subtotal" :label="$t('orderCreate.subtotalPlaceholder')" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ order.currency }} {{ row.subtotal }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-empty v-else :description="$t('orders.orderNotFound')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useOrderStore } from '@/stores/orders'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
User as ArrowLeft,
|
||||
User as ArrowRight,
|
||||
Check,
|
||||
Close,
|
||||
User as Warning,
|
||||
User as Info,
|
||||
Money,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
User as Truck,
|
||||
User as Package,
|
||||
User,
|
||||
User as Calendar,
|
||||
User as Clock
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const orderStore = useOrderStore()
|
||||
|
||||
const order = ref(null)
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status) => {
|
||||
const statusMap = {
|
||||
'PENDING': 'warning',
|
||||
'CONFIRMED': 'info',
|
||||
'PAID': 'primary',
|
||||
'PROCESSING': '',
|
||||
'SHIPPED': 'success',
|
||||
'DELIVERED': 'success',
|
||||
'COMPLETED': 'success',
|
||||
'CANCELLED': 'danger',
|
||||
'REFUNDED': 'info'
|
||||
}
|
||||
return statusMap[status] || ''
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'PENDING': '待支付',
|
||||
'CONFIRMED': '已确认',
|
||||
'PAID': '已支付',
|
||||
'PROCESSING': '处理中',
|
||||
'SHIPPED': '已发货',
|
||||
'DELIVERED': '已送达',
|
||||
'COMPLETED': '已完成',
|
||||
'CANCELLED': '已取消',
|
||||
'REFUNDED': '已退款'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取订单类型文本
|
||||
const getOrderTypeText = (orderType) => {
|
||||
const typeMap = {
|
||||
'PRODUCT': '商品订单',
|
||||
'SERVICE': '服务订单',
|
||||
'SUBSCRIPTION': '订阅订单',
|
||||
'DIGITAL': '数字商品',
|
||||
'PHYSICAL': '实体商品'
|
||||
}
|
||||
return typeMap[orderType] || orderType
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
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 handlePayment = () => {
|
||||
ElMessage.info(t('common.paymentFeatureInDev'))
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
ElMessage.info(t('common.cancelOrderFeatureInDev'))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const orderId = route.params.id
|
||||
if (orderId) {
|
||||
const response = await orderStore.fetchOrderById(orderId)
|
||||
if (response.success) {
|
||||
order.value = orderStore.currentOrder
|
||||
} else {
|
||||
ElMessage.error(response.message || t('common.updateFailed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.order-detail {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 600;
|
||||
color: #E6A23C;
|
||||
}
|
||||
|
||||
.order-description,
|
||||
.order-items {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.order-description h4,
|
||||
.order-items h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
235
frontend/src/views/PaymentCreate.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="payment-create">
|
||||
<el-page-header @back="$router.go(-1)" :content="$t('paymentCreate.title')">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="loading">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
{{ $t('paymentCreate.createButton') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card class="form-card">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item :label="$t('paymentCreate.orderIdLabel')" prop="orderId">
|
||||
<el-input
|
||||
v-model="form.orderId"
|
||||
:placeholder="$t('paymentCreate.orderIdPlaceholder')"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('paymentCreate.amountLabel')" prop="amount">
|
||||
<el-input-number
|
||||
v-model="form.amount"
|
||||
:precision="2"
|
||||
:min="0.01"
|
||||
:placeholder="$t('paymentCreate.amountPlaceholder')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('paymentCreate.currencyLabel')" prop="currency">
|
||||
<el-select v-model="form.currency" :placeholder="$t('paymentCreate.currencyPlaceholder')">
|
||||
<el-option :label="$t('paymentCreate.currencyCNY')" value="CNY" />
|
||||
<el-option :label="$t('paymentCreate.currencyUSD')" value="USD" />
|
||||
<el-option label="EUR (Euro)" value="EUR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('paymentCreate.paymentMethodLabel')" prop="paymentMethod">
|
||||
<el-radio-group v-model="form.paymentMethod">
|
||||
<el-radio value="ALIPAY">
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
{{ $t('orders.alipay') }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('paymentCreate.descriptionLabel')" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('paymentCreate.descriptionPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('paymentCreate.callbackUrlLabel')" prop="callbackUrl">
|
||||
<el-input
|
||||
v-model="form.callbackUrl"
|
||||
:placeholder="$t('paymentCreate.callbackUrlPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('paymentCreate.returnUrlLabel')" prop="returnUrl">
|
||||
<el-input
|
||||
v-model="form.returnUrl"
|
||||
:placeholder="$t('paymentCreate.returnUrlPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 支付方式说明 -->
|
||||
<el-card class="info-card">
|
||||
<template #header>
|
||||
<h4>{{ $t('paymentCreate.paymentMethodLabel') }}</h4>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="payment-method-info">
|
||||
<el-icon size="32" color="#1677FF"><CreditCard /></el-icon>
|
||||
<h5>{{ $t('orders.alipay') }}</h5>
|
||||
<p>{{ $t('paymentCreate.alipayDesc') }}</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Money,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
User as Plus,
|
||||
Check,
|
||||
Close,
|
||||
User as ArrowLeft,
|
||||
User as ArrowRight,
|
||||
User as Upload,
|
||||
User as Download
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
orderId: '',
|
||||
amount: 0,
|
||||
currency: 'CNY',
|
||||
paymentMethod: 'ALIPAY',
|
||||
description: '',
|
||||
callbackUrl: '',
|
||||
returnUrl: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
orderId: [
|
||||
{ required: true, message: '请输入订单号', trigger: 'blur' }
|
||||
],
|
||||
amount: [
|
||||
{ required: true, message: '请输入支付金额', trigger: 'blur' },
|
||||
{ type: 'number', min: 0.01, message: '支付金额必须大于0', trigger: 'blur' }
|
||||
],
|
||||
currency: [
|
||||
{ required: true, message: '请选择货币', trigger: 'change' }
|
||||
],
|
||||
paymentMethod: [
|
||||
{ required: true, message: '请选择支付方式', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 调用真实支付API
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
ElMessage.success(t('common.paymentCreateSuccess'))
|
||||
router.push('/payments')
|
||||
} catch (error) {
|
||||
console.error('Create payment error:', error)
|
||||
ElMessage.error(t('common.paymentCreateFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-create {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.info-card h4 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.payment-method-info {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.payment-method-info:hover {
|
||||
background-color: #f5f7fa;
|
||||
border-color: #409EFF;
|
||||
}
|
||||
|
||||
.payment-method-info h5 {
|
||||
margin: 12px 0 8px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.payment-method-info p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.payment-method-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
749
frontend/src/views/Payments.vue
Normal file
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<div class="payments">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2>
|
||||
<el-icon><CreditCard /></el-icon>
|
||||
{{ $t('payments.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<el-card class="filter-card">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
:placeholder="$t('payments.statusPlaceholder')"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<el-option :label="$t('payments.allStatus')" value="" />
|
||||
<el-option :label="$t('payments.pending')" value="PENDING" />
|
||||
<el-option :label="$t('payments.paid')" value="SUCCESS" />
|
||||
<el-option :label="$t('payments.failed')" value="FAILED" />
|
||||
<el-option :label="$t('payments.cancelled')" value="CANCELLED" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8">
|
||||
<el-input
|
||||
v-model="filters.search"
|
||||
:placeholder="$t('payments.searchPlaceholder')"
|
||||
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">{{ $t('common.reset') }}</el-button>
|
||||
<el-button type="success" @click="showSubscriptionDialog('standard')">{{ $t('subscription.standard') }}</el-button>
|
||||
<el-button type="warning" @click="showSubscriptionDialog('professional')">{{ $t('subscription.professional') }}</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 支付记录列表 -->
|
||||
<el-card class="payments-card">
|
||||
<el-table
|
||||
:data="payments"
|
||||
v-loading="loading"
|
||||
:empty-text="$t('subscription.noPointsHistory')"
|
||||
>
|
||||
<el-table-column prop="orderId" :label="$t('orders.orderNumber')" width="150">
|
||||
<template #default="{ row }">
|
||||
<router-link :to="`/orders/${row.orderId}`" class="order-link">
|
||||
{{ row.orderId }}
|
||||
</router-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="amount" :label="$t('orders.amount')" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="amount">{{ row.currency }} {{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="paymentMethod" :label="$t('orders.paymentMethod')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPaymentMethodType(row.paymentMethod)">
|
||||
{{ getPaymentMethodText(row.paymentMethod) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" :label="$t('orders.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" :label="$t('orders.description')" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="description">{{ row.description }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="createdAt" :label="$t('orders.createTime')" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="paidAt" :label="$t('orders.paidTime')" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.paidAt ? formatDate(row.paidAt) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="$t('orders.operation')" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
@click="viewPaymentDetail(row)"
|
||||
>
|
||||
{{ $t('common.view') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'PENDING'"
|
||||
size="small"
|
||||
type="success"
|
||||
@click="testPaymentComplete(row)"
|
||||
>
|
||||
{{ $t('common.confirmTest') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeletePayment(row)"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</el-button>
|
||||
</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="detailDialogVisible"
|
||||
:title="$t('payments.paymentDetail')"
|
||||
width="600px"
|
||||
>
|
||||
<div v-if="currentPayment">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="$t('orders.orderNumber')">{{ currentPayment.orderId }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.paymentMethod')">
|
||||
<el-tag :type="getPaymentMethodType(currentPayment.paymentMethod)">
|
||||
{{ getPaymentMethodText(currentPayment.paymentMethod) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.amount')">
|
||||
<span class="amount">{{ currentPayment.currency }} {{ currentPayment.amount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.status')">
|
||||
<el-tag :type="getStatusType(currentPayment.status)">
|
||||
{{ getStatusText(currentPayment.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('payments.externalTransactionId')" v-if="currentPayment.externalTransactionId">
|
||||
{{ currentPayment.externalTransactionId }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.createTime')">{{ formatDate(currentPayment.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('orders.paidTime')" v-if="currentPayment.paidAt">
|
||||
{{ formatDate(currentPayment.paidAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('payments.updateTime')">{{ formatDate(currentPayment.updatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="currentPayment.description" class="payment-description">
|
||||
<h4>{{ $t('orders.description') }}</h4>
|
||||
<p>{{ currentPayment.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 订阅对话框 -->
|
||||
<el-dialog
|
||||
v-model="subscriptionDialogVisible"
|
||||
:title="subscriptionDialogTitle"
|
||||
width="500px"
|
||||
>
|
||||
<div class="subscription-info">
|
||||
<h3>{{ subscriptionInfo.title }}</h3>
|
||||
<p class="price">${{ subscriptionInfo.price }}</p>
|
||||
<p class="description">{{ subscriptionInfo.description }}</p>
|
||||
<div class="benefits">
|
||||
<h4>{{ $t('subscription.features') }}:</h4>
|
||||
<ul>
|
||||
<li v-for="benefit in subscriptionInfo.benefits" :key="benefit">
|
||||
{{ benefit }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="points-info">
|
||||
<el-tag type="success">支付完成后可获得 {{ subscriptionInfo.points }} 积分</el-tag>
|
||||
</div>
|
||||
<div class="payment-method">
|
||||
<h4>选择支付方式:</h4>
|
||||
<el-radio-group v-model="selectedPaymentMethod" @change="updatePrice">
|
||||
<el-radio label="ALIPAY">支付宝</el-radio>
|
||||
<el-radio label="PAYPAL">PayPal</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="converted-price" v-if="convertedPrice">
|
||||
<p>支付金额:<span class="price-display">{{ convertedPrice }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="subscriptionDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="createSubscription" :loading="subscriptionLoading">
|
||||
立即订阅
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Money,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
User as Search,
|
||||
User as Filter,
|
||||
User as Plus,
|
||||
User as View,
|
||||
User as Refresh,
|
||||
User as Download,
|
||||
User as Upload,
|
||||
Setting,
|
||||
Check,
|
||||
Close,
|
||||
User as Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getPayments, testPaymentComplete as testPaymentCompleteApi, createTestPayment, deletePayment } from '@/api/payments'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const payments = ref([])
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
// 分页信息
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 支付详情对话框
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentPayment = ref(null)
|
||||
|
||||
|
||||
// 订阅对话框
|
||||
const subscriptionDialogVisible = ref(false)
|
||||
const subscriptionLoading = ref(false)
|
||||
const subscriptionType = ref('')
|
||||
const selectedPaymentMethod = ref('ALIPAY')
|
||||
const convertedPrice = ref('')
|
||||
const exchangeRate = ref(7.2) // 美元对人民币汇率,可以根据实际情况调整
|
||||
const subscriptionInfo = reactive({
|
||||
title: '',
|
||||
price: 0,
|
||||
description: '',
|
||||
benefits: [],
|
||||
points: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const subscriptionDialogTitle = computed(() => {
|
||||
return subscriptionType.value === 'standard' ? '标准版订阅' : '专业版订阅'
|
||||
})
|
||||
|
||||
// 获取支付方式类型
|
||||
const getPaymentMethodType = (method) => {
|
||||
const methodMap = {
|
||||
'ALIPAY': 'primary',
|
||||
'PAYPAL': 'success',
|
||||
'WECHAT': 'success',
|
||||
'UNIONPAY': 'warning'
|
||||
}
|
||||
return methodMap[method] || ''
|
||||
}
|
||||
|
||||
// 获取支付方式文本
|
||||
const getPaymentMethodText = (method) => {
|
||||
const methodMap = {
|
||||
'ALIPAY': '支付宝',
|
||||
'PAYPAL': 'PayPal',
|
||||
'WECHAT': '微信支付',
|
||||
'UNIONPAY': '银联支付'
|
||||
}
|
||||
return methodMap[method] || method
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status) => {
|
||||
const statusMap = {
|
||||
'PENDING': 'warning',
|
||||
'SUCCESS': 'success',
|
||||
'FAILED': 'danger',
|
||||
'CANCELLED': 'info'
|
||||
}
|
||||
return statusMap[status] || ''
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'PENDING': '待支付',
|
||||
'SUCCESS': '支付成功',
|
||||
'FAILED': '支付失败',
|
||||
'CANCELLED': '已取消'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
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 fetchPayments = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await getPayments({
|
||||
page: pagination.page - 1,
|
||||
size: pagination.size,
|
||||
status: filters.status,
|
||||
search: filters.search
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
payments.value = response.data
|
||||
pagination.total = response.total || response.data.length
|
||||
} else {
|
||||
ElMessage.error(response.message || t('common.fetchPaymentsFailed'))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch payments error:', error)
|
||||
ElMessage.error(t('common.fetchPaymentsFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
pagination.page = 1
|
||||
fetchPayments()
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchPayments()
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
filters.status = ''
|
||||
filters.search = ''
|
||||
pagination.page = 1
|
||||
fetchPayments()
|
||||
}
|
||||
|
||||
// 分页大小变化
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.size = size
|
||||
pagination.page = 1
|
||||
fetchPayments()
|
||||
}
|
||||
|
||||
// 当前页变化
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
fetchPayments()
|
||||
}
|
||||
|
||||
// 查看支付详情
|
||||
const viewPaymentDetail = (payment) => {
|
||||
currentPayment.value = payment
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 更新价格显示
|
||||
const updatePrice = () => {
|
||||
if (selectedPaymentMethod.value === 'ALIPAY') {
|
||||
// 支付宝使用人民币
|
||||
const cnyPrice = (subscriptionInfo.price * exchangeRate.value).toFixed(2)
|
||||
convertedPrice.value = `¥${cnyPrice}`
|
||||
} else if (selectedPaymentMethod.value === 'PAYPAL') {
|
||||
// PayPal使用美元
|
||||
convertedPrice.value = `$${subscriptionInfo.price}`
|
||||
}
|
||||
}
|
||||
|
||||
// 显示订阅对话框
|
||||
const showSubscriptionDialog = (type) => {
|
||||
if (!userStore.isAuthenticated) {
|
||||
ElMessage.warning(t('common.pleaseLoginFirst'))
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionType.value = type
|
||||
|
||||
if (type === 'standard') {
|
||||
subscriptionInfo.title = '标准版订阅'
|
||||
subscriptionInfo.price = 59
|
||||
subscriptionInfo.description = '适合个人用户的基础功能订阅'
|
||||
subscriptionInfo.benefits = [
|
||||
'基础AI功能使用',
|
||||
'每月100次API调用',
|
||||
'邮件技术支持',
|
||||
'基础模板库访问'
|
||||
]
|
||||
subscriptionInfo.points = 200
|
||||
} else if (type === 'professional') {
|
||||
subscriptionInfo.title = '专业版订阅'
|
||||
subscriptionInfo.price = 259
|
||||
subscriptionInfo.description = '适合企业用户的高级功能订阅'
|
||||
subscriptionInfo.benefits = [
|
||||
'高级AI功能使用',
|
||||
'每月1000次API调用',
|
||||
'优先技术支持',
|
||||
'完整模板库访问',
|
||||
'API接口集成',
|
||||
'数据分析报告'
|
||||
]
|
||||
subscriptionInfo.points = 1000
|
||||
}
|
||||
|
||||
subscriptionDialogVisible.value = true
|
||||
// 初始化价格显示
|
||||
updatePrice()
|
||||
}
|
||||
|
||||
// 创建订阅支付
|
||||
const createSubscription = async () => {
|
||||
try {
|
||||
subscriptionLoading.value = true
|
||||
|
||||
// 根据支付方式确定实际支付金额
|
||||
let actualAmount
|
||||
if (selectedPaymentMethod.value === 'ALIPAY') {
|
||||
// 支付宝使用人民币
|
||||
actualAmount = (subscriptionInfo.price * exchangeRate.value).toFixed(2)
|
||||
} else {
|
||||
// PayPal使用美元
|
||||
actualAmount = subscriptionInfo.price.toString()
|
||||
}
|
||||
|
||||
const response = await createTestPayment({
|
||||
amount: actualAmount,
|
||||
method: selectedPaymentMethod.value
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
ElMessage.success(t('common.paymentRecordCreated', { title: subscriptionInfo.title }))
|
||||
|
||||
// 根据支付方式调用相应的支付接口
|
||||
if (selectedPaymentMethod.value === 'ALIPAY') {
|
||||
try {
|
||||
const alipayResponse = await createAlipayPayment({
|
||||
paymentId: response.data.id
|
||||
})
|
||||
|
||||
if (alipayResponse.success) {
|
||||
// 跳转到支付宝支付页面
|
||||
window.open(alipayResponse.data.paymentUrl, '_blank')
|
||||
ElMessage.success(t('common.redirectingToAlipay'))
|
||||
} else {
|
||||
ElMessage.error(alipayResponse.message || t('common.createAlipayFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建支付宝支付失败:', error)
|
||||
ElMessage.error(t('common.createAlipayFailed'))
|
||||
}
|
||||
} else if (selectedPaymentMethod.value === 'PAYPAL') {
|
||||
try {
|
||||
const paypalResponse = await createPayPalPayment({
|
||||
paymentId: response.data.id
|
||||
})
|
||||
|
||||
if (paypalResponse.success) {
|
||||
// 跳转到PayPal支付页面
|
||||
window.open(paypalResponse.data.paymentUrl, '_blank')
|
||||
ElMessage.success(t('common.redirectingToPaypal'))
|
||||
} else {
|
||||
ElMessage.error(paypalResponse.message || t('common.createPaypalFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建PayPal支付失败:', error)
|
||||
ElMessage.error(t('common.createPaypalFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
subscriptionDialogVisible.value = false
|
||||
// 刷新支付记录列表
|
||||
fetchPayments()
|
||||
} else {
|
||||
ElMessage.error(response.message || t('common.createSubscriptionFailed'))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create subscription error:', error)
|
||||
ElMessage.error(t('common.createSubscriptionFailed'))
|
||||
} finally {
|
||||
subscriptionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 测试支付完成
|
||||
const testPaymentComplete = async (payment) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('common.confirmTestPayment', { orderId: payment.orderId }),
|
||||
t('common.confirmTest'),
|
||||
{
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const response = await testPaymentCompleteApi(payment.id)
|
||||
|
||||
if (response.success) {
|
||||
ElMessage.success(t('common.testPaymentSuccess'))
|
||||
// 刷新支付记录列表
|
||||
fetchPayments()
|
||||
} else {
|
||||
ElMessage.error(response.message || t('common.testPaymentFailed'))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('Test payment complete error:', error)
|
||||
ElMessage.error(t('common.testPaymentFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除支付记录
|
||||
const handleDeletePayment = async (payment) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除支付记录 ${payment.orderId} 吗?`,
|
||||
t('common.confirm'),
|
||||
{
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const response = await deletePayment(payment.id)
|
||||
|
||||
if (response.data?.success) {
|
||||
ElMessage.success(t('common.deleteSuccess'))
|
||||
// 刷新支付记录列表
|
||||
fetchPayments()
|
||||
} else {
|
||||
ElMessage.error(response.data?.message || t('common.deleteFailed'))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('Delete payment error:', error)
|
||||
ElMessage.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPayments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payments {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.payments-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.order-link {
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 600;
|
||||
color: #E6A23C;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.payment-description {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.payment-description h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.payment-description p {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subscription-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subscription-info h3 {
|
||||
color: #409eff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subscription-info .price {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #f56c6c;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.subscription-info .description {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subscription-info .benefits {
|
||||
text-align: left;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.subscription-info .benefits h4 {
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subscription-info .benefits ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.subscription-info .benefits li {
|
||||
padding: 0.25rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.subscription-info .benefits li:before {
|
||||
content: "✓ ";
|
||||
color: #67c23a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subscription-info .points-info {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.subscription-info .payment-method {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.subscription-info .payment-method h4 {
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subscription-info .converted-price {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b3d8ff;
|
||||
}
|
||||
|
||||
.subscription-info .price-display {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
578
frontend/src/views/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="privacy-page">
|
||||
<div class="privacy-container">
|
||||
<!-- 返回按钮 -->
|
||||
<div class="back-button" @click="goBack">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>返回</span>
|
||||
</div>
|
||||
|
||||
<!-- 中文版本 -->
|
||||
<div class="privacy-content">
|
||||
<h1>隐私政策</h1>
|
||||
|
||||
<p class="update-date">最后更新:2025年11月1日</p>
|
||||
|
||||
<p class="intro">
|
||||
本隐私政策阐述了当您(下称"您"或"用户")通过网站 (https://vionow.com/) 访问或使用我们的服务时,Vionow(下称"公司"、"我们"或"我们的")关于收集、使用和披露个人信息的政策和程序。本政策还解释了您的隐私权以及适用法律如何保护您。
|
||||
</p>
|
||||
|
||||
<p class="intro">
|
||||
使用本服务即表示您同意我们根据本隐私政策收集和使用信息。本文件的编写已考虑到最佳实践和相关的法律标准。
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>解释与定义</h2>
|
||||
|
||||
<h3>解释</h3>
|
||||
<p>首字母大写的词语具有下文定义的含义,无论其以单数还是复数形式出现,其含义均相同。</p>
|
||||
|
||||
<h3>定义</h3>
|
||||
<p>为本隐私政策之目的:</p>
|
||||
<ul>
|
||||
<li><strong>账户:</strong>指您为访问我们的服务而创建的唯一个人资料。</li>
|
||||
<li><strong>关联公司:</strong>指控制我们、被我们控制或与我们共同受控的任何实体。</li>
|
||||
<li><strong>Cookies:</strong>指网站放置在您设备上的小型数据文件。</li>
|
||||
<li><strong>公司:</strong>指在香港适用法律下运营的 Vionow。</li>
|
||||
<li><strong>国家:</strong>指中国香港。</li>
|
||||
<li><strong>设备:</strong>指能够访问本服务的任何技术设备。</li>
|
||||
<li><strong>个人数据:</strong>指与已识别或可识别的个人相关的任何信息。</li>
|
||||
<li><strong>服务:</strong>指公司提供的平台及相关服务。</li>
|
||||
<li><strong>服务提供商:</strong>指与我们签约以处理数据或提供服务的第三方。</li>
|
||||
<li><strong>使用数据:</strong>指在使用服务过程中自动收集的数据。</li>
|
||||
<li><strong>网站:</strong>指位于 https://vionow.com/ 的在线平台。</li>
|
||||
<li><strong>您:</strong>指访问或使用本服务的任何个人或实体。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>数据收集</h2>
|
||||
|
||||
<h3>收集的数据类型</h3>
|
||||
|
||||
<h4>个人数据</h4>
|
||||
<p>我们可能会要求您提供个人数据,包括但不限于:</p>
|
||||
<ul>
|
||||
<li>电子邮箱地址</li>
|
||||
<li>姓名</li>
|
||||
<li>电话号码</li>
|
||||
<li>您自愿提供的其他身份标识信息</li>
|
||||
</ul>
|
||||
|
||||
<h4>使用数据</h4>
|
||||
<p>自动收集的数据可能包括:</p>
|
||||
<ul>
|
||||
<li>IP 地址</li>
|
||||
<li>浏览器和设备信息</li>
|
||||
<li>访问时间与访问页面</li>
|
||||
<li>诊断和性能数据</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>追踪技术</h2>
|
||||
<p>我们使用Cookies 及类似工具来增强功能和进行分析:</p>
|
||||
<ul>
|
||||
<li><strong>必要性 Cookies:</strong>用于启用核心功能和保障安全。</li>
|
||||
<li><strong>偏好性 Cookies:</strong>用于存储用户设置。</li>
|
||||
<li><strong>分析性 Cookies:</strong>用于衡量性能和使用情况。</li>
|
||||
</ul>
|
||||
<p>您可以通过浏览器修改Cookie 设置。禁用 Cookies 可能会影响某些功能的正常使用。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>数据的使用</h2>
|
||||
<p>我们可能将您的信息用于以下目的:</p>
|
||||
<ul>
|
||||
<li>提供、改进和维护服务</li>
|
||||
<li>管理您的账户</li>
|
||||
<li>履行合同和法律义务</li>
|
||||
<li>就服务更新、支持或营销事宜与您沟通(取决于您的偏好)</li>
|
||||
<li>分析使用模式以改善用户体验</li>
|
||||
<li>用于内部研发</li>
|
||||
<li>遵守法规和法律要求</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>用户生成内容与上传的图片</h2>
|
||||
<p>
|
||||
当您向本服务上传内容(例如图片、渲染图)时,您保留该内容的完全所有权。但是,通过我们的平台提交此类内容,即表示您授予公司一项非独占、全球性、免版税、不可撤销且永久的许可,授权我们仅为推广、营销或展示本服务功能之有限目的,使用、复制、修改和公开展示该等内容,包括但不限于在网站、营销材料或社交媒体上使用,前提是该内容不包含个人数据、可识别的个人或第三方的机密或专有信息。
|
||||
</p>
|
||||
<p>
|
||||
如果您希望选择退出此许可,您可以随时通过<a href="mailto:contact@vionow.com">contact@vionow.com</a> 与我们联系。收到有效请求后,我们将停止所有相关的推广使用,并尽合理努力从未来的材料中移除相关内容。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>数据保留</h2>
|
||||
<p>
|
||||
我们仅在为实现本政策所述目的或遵守适用法律所必需的时间内保留个人数据。使用数据可能为分析、安全或法律合规目的而保留。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>数据传输</h2>
|
||||
<p>
|
||||
您的数据可能会在您所在司法管辖区之外进行处理。我们将采取合理的保障措施,确保根据适用标准提供适当的保护。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>您的权利</h2>
|
||||
<p>您有权:</p>
|
||||
<ul>
|
||||
<li>访问、更正或删除您的个人数据</li>
|
||||
<li>反对或限制某些处理活动</li>
|
||||
<li>撤回同意(如适用)</li>
|
||||
<li>向监管机构投诉</li>
|
||||
</ul>
|
||||
<p>您可以通过<a href="mailto:contact@vionow.com">contact@vionow.com</a> 联系我们提交请求。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>数据披露</h2>
|
||||
<p>我们可能在以下情况下披露您的个人数据:</p>
|
||||
<ul>
|
||||
<li>向根据合同义务行事的服务提供商披露</li>
|
||||
<li>与公司重组或出售相关的披露</li>
|
||||
<li>为遵守法律义务或捍卫我们的合法权利</li>
|
||||
<li>为保护用户或公众的安全或权利</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>儿童隐私</h2>
|
||||
<p>
|
||||
我们的服务不面向13岁以下的个人。我们不会有意收集未成年人的个人数据。如果您认为有未成年人提交了个人数据,请联系我们以请求删除。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>第三方网站</h2>
|
||||
<p>
|
||||
我们的网站可能包含指向外部网站的链接。我们对其内容或隐私惯例不承担任何责任。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>本政策的变更</h2>
|
||||
<p>
|
||||
我们保留随时修订本隐私政策的权利。重大变更将通过电子邮件或网站上的醒目通知进行传达。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>联系我们</h2>
|
||||
<p>
|
||||
如果您对本隐私政策有任何疑问或希望行使您的权利,请通过以下方式联系我们:<br>
|
||||
电子邮箱:<a href="mailto:contact@vionow.com">contact@vionow.com</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- English Version -->
|
||||
<h1 class="english-title">Privacy Policy</h1>
|
||||
|
||||
<p class="update-date">Last updated: June 12, 2025</p>
|
||||
|
||||
<p class="intro">
|
||||
This Privacy Policy outlines the policies and procedures of Vionow ("the Company", "We", "Us", or "Our") regarding the collection, use, and disclosure of personal information when You ("You" or "User") access or use Our services via the Website (https://vionow.com/). It also explains Your privacy rights and how applicable laws protect You.
|
||||
</p>
|
||||
|
||||
<p class="intro">
|
||||
By using the Service, You consent to the collection and use of information in accordance with this Privacy Policy. This document has been prepared with consideration for best practices and relevant legal standards.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Interpretation and Definitions</h2>
|
||||
|
||||
<h3>Interpretation</h3>
|
||||
<p>Capitalized words have meanings defined below, which apply equally whether singular or plural.</p>
|
||||
|
||||
<h3>Definitions</h3>
|
||||
<p>For purposes of this Privacy Policy:</p>
|
||||
<ul>
|
||||
<li><strong>Account:</strong> A unique profile created by You to access Our Service.</li>
|
||||
<li><strong>Affiliate:</strong> Any entity that controls, is controlled by, or is under common control with Us.</li>
|
||||
<li><strong>Cookies:</strong> Small data files placed on Your device by the Website.</li>
|
||||
<li><strong>Company:</strong> Vionow, operating under applicable laws in Hong Kong.</li>
|
||||
<li><strong>Country:</strong> Hong Kong.</li>
|
||||
<li><strong>Device:</strong> Any technology capable of accessing the Service.</li>
|
||||
<li><strong>Personal Data:</strong> Information identifying or reasonably identifiable to an individual.</li>
|
||||
<li><strong>Service:</strong> The platform and related services provided by the Company.</li>
|
||||
<li><strong>Service Provider:</strong> Third parties contracted to process data or deliver services.</li>
|
||||
<li><strong>Usage Data:</strong> Automatically collected data about Service usage.</li>
|
||||
<li><strong>Website:</strong> The online platform located at https://vionow.com/.</li>
|
||||
<li><strong>You:</strong> Any individual or entity accessing or using the Service.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Collection of Data</h2>
|
||||
|
||||
<h3>Types of Data Collected</h3>
|
||||
|
||||
<h4>Personal Data</h4>
|
||||
<p>We may request Personal Data including but not limited to:</p>
|
||||
<ul>
|
||||
<li>Email address</li>
|
||||
<li>First and last name</li>
|
||||
<li>Phone number</li>
|
||||
<li>Other voluntarily provided identifiers</li>
|
||||
</ul>
|
||||
|
||||
<h4>Usage Data</h4>
|
||||
<p>Collected automatically and may include:</p>
|
||||
<ul>
|
||||
<li>IP address</li>
|
||||
<li>Browser and device information</li>
|
||||
<li>Access times and visited pages</li>
|
||||
<li>Diagnostic and performance data</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tracking Technologies</h2>
|
||||
<p>We utilize Cookies and similar tools to enhance functionality and analytics:</p>
|
||||
<ul>
|
||||
<li><strong>Essential Cookies:</strong> Enable core features and security.</li>
|
||||
<li><strong>Preference Cookies:</strong> Store user settings.</li>
|
||||
<li><strong>Analytics Cookies:</strong> Measure performance and usage.</li>
|
||||
</ul>
|
||||
<p>You may modify cookie settings via Your browser. Declining cookies may impair certain functionalities.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Use of Data</h2>
|
||||
<p>We may use Your information for the following purposes:</p>
|
||||
<ul>
|
||||
<li>To deliver, improve, and maintain the Service</li>
|
||||
<li>To administer Your Account</li>
|
||||
<li>To fulfill contractual and legal obligations</li>
|
||||
<li>To communicate with You regarding service updates, support, or marketing (subject to Your preferences)</li>
|
||||
<li>To analyze usage patterns and improve the user experience</li>
|
||||
<li>For internal research and development</li>
|
||||
<li>To comply with regulatory and legal requirements</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>User-Generated Content and Uploaded Images</h2>
|
||||
<p>
|
||||
When You upload content (e.g., images, renders) to the Service, You retain full ownership of that content. However, by submitting such content through Our platform, You hereby grant the Company a non-exclusive, worldwide, royalty-free, irrevocable, and perpetual license to use, reproduce, modify, and publicly display such content strictly for the limited purpose of promoting, marketing, or demonstrating the functionality of the Service, including but not limited to use on the Website, in marketing materials, or on social media, provided that such content does not contain personal data, identifiable individuals, or confidential or proprietary information of third parties.
|
||||
</p>
|
||||
<p>
|
||||
If You wish to opt out of this license, You may do so at any time by contacting Us at <a href="mailto:contact@vionow.com">contact@vionow.com</a>. Upon receipt of a valid request, We will cease all promotional use and make reasonable efforts to remove the relevant content from future materials.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Retention of Data</h2>
|
||||
<p>
|
||||
We retain Personal Data only as long as necessary to achieve the purposes described herein or as required by applicable law. Usage Data may be retained for analytics, security, or legal compliance.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Data Transfers</h2>
|
||||
<p>
|
||||
Your data may be processed outside of Your jurisdiction. We implement reasonable safeguards to ensure appropriate protection in accordance with applicable standards.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Your Rights</h2>
|
||||
<p>You have the right to:</p>
|
||||
<ul>
|
||||
<li>Access, rectify, or delete Your Personal Data</li>
|
||||
<li>Object to or restrict certain processing activities</li>
|
||||
<li>Withdraw consent (where applicable)</li>
|
||||
<li>File a complaint with a supervisory authority</li>
|
||||
</ul>
|
||||
<p>Requests can be submitted by contacting Us at <a href="mailto:contact@vionow.com">contact@vionow.com</a>.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Disclosure of Data</h2>
|
||||
<p>We may disclose Your Personal Data:</p>
|
||||
<ul>
|
||||
<li>To service providers acting under contractual obligations</li>
|
||||
<li>In connection with corporate restructuring or sale</li>
|
||||
<li>To comply with legal obligations or defend Our legal rights</li>
|
||||
<li>To protect the safety or rights of Users or the public</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Children's Privacy</h2>
|
||||
<p>
|
||||
Our Service is not directed to individuals under the age of 13. We do not knowingly collect Personal Data from minors. If You believe that a minor has submitted Personal Data, please contact Us to request removal.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Third-Party Websites</h2>
|
||||
<p>
|
||||
Our Website may contain links to external websites. We are not responsible for their content or privacy practices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Changes to This Policy</h2>
|
||||
<p>
|
||||
We reserve the right to amend this Privacy Policy at any time. Material changes will be communicated via email or a prominent notice on the Website.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Contact Us</h2>
|
||||
<p>
|
||||
If You have any questions about this Privacy Policy or wish to exercise Your rights, contact us at:<br>
|
||||
Email: <a href="mailto:contact@vionow.com">contact@vionow.com</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.privacy-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.privacy-container {
|
||||
max-width: 900px;
|
||||
max-height: calc(100vh - 80px);
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 60px 80px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.privacy-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.privacy-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.privacy-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.privacy-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
.privacy-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 212, 255, 0.3) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #00D4FF;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 30px;
|
||||
transition: all 0.3s ease;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.privacy-content {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #00D4FF;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.english-title {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.update-date {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
margin-bottom: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-top: 40px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
ul li {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
ul li::before {
|
||||
content: "·";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: #00D4FF;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00D4FF;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
margin: 60px 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.privacy-container {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p, ul li {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.privacy-page {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.privacy-container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1947
frontend/src/views/Profile.vue
Normal file
513
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="register">
|
||||
<el-row justify="center" align="middle" class="register-container">
|
||||
<el-col :xs="22" :sm="16" :md="12" :lg="8" :xl="6">
|
||||
<el-card class="register-card">
|
||||
<template #header>
|
||||
<div class="register-header">
|
||||
<el-icon size="32" color="#67C23A"><User /></el-icon>
|
||||
<h2>{{ $t('register.title') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="registerFormRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
label-width="80px"
|
||||
@submit.prevent="handleRegister"
|
||||
>
|
||||
<el-form-item :label="$t('profile.username')" prop="username">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
:placeholder="$t('register.usernamePlaceholder')"
|
||||
prefix-icon="User"
|
||||
clearable
|
||||
@blur="checkUsername"
|
||||
/>
|
||||
<div v-if="usernameChecking" class="checking-text">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<div v-if="usernameExists" class="error-text">
|
||||
<el-icon><CircleCloseFilled /></el-icon>
|
||||
{{ $t('register.usernameExists') }}
|
||||
</div>
|
||||
<div v-if="usernameAvailable" class="success-text">
|
||||
<el-icon><CircleCheckFilled /></el-icon>
|
||||
{{ $t('register.usernameAvailable') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('profile.email')" prop="email">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
:placeholder="$t('register.emailPlaceholder')"
|
||||
prefix-icon="Message"
|
||||
clearable
|
||||
@blur="checkEmail"
|
||||
/>
|
||||
<div v-if="emailChecking" class="checking-text">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<div v-if="emailExists" class="error-text">
|
||||
<el-icon><CircleCloseFilled /></el-icon>
|
||||
{{ $t('register.emailExists') }}
|
||||
</div>
|
||||
<div v-if="emailAvailable" class="success-text">
|
||||
<el-icon><CircleCheckFilled /></el-icon>
|
||||
{{ $t('register.emailAvailable') }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('login.passwordPlaceholder').replace('请输入', '')" prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
:placeholder="$t('register.passwordPlaceholder')"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
@input="checkPasswordStrength"
|
||||
/>
|
||||
<div v-if="passwordStrength" class="password-strength">
|
||||
<div class="strength-bar">
|
||||
<div
|
||||
class="strength-fill"
|
||||
:class="strengthClass"
|
||||
:style="{ width: strengthWidth }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="strength-text">{{ strengthText }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('common.confirm')" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
:placeholder="$t('register.confirmPasswordPlaceholder')"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="agreeTerms">
|
||||
{{ $t('register.agreement') }}
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
:loading="userStore.loading"
|
||||
:disabled="!canRegister"
|
||||
@click="handleRegister"
|
||||
class="register-button"
|
||||
>
|
||||
{{ userStore.loading ? $t('register.registering') : $t('register.registerButton') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="register-footer">
|
||||
<p>{{ $t('register.haveAccount') }}<router-link to="/login" class="login-link">{{ $t('register.loginNow') }}</router-link></p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { checkUsernameExists, checkEmailExists } from '@/api/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
User,
|
||||
Lock,
|
||||
Message,
|
||||
Phone,
|
||||
Calendar,
|
||||
Location,
|
||||
Check,
|
||||
Close,
|
||||
ArrowLeft,
|
||||
ArrowRight
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const registerFormRef = ref()
|
||||
const agreeTerms = ref(false)
|
||||
|
||||
// 用户名检查状态
|
||||
const usernameChecking = ref(false)
|
||||
const usernameExists = ref(false)
|
||||
const usernameAvailable = ref(false)
|
||||
|
||||
// 邮箱检查状态
|
||||
const emailChecking = ref(false)
|
||||
const emailExists = ref(false)
|
||||
const emailAvailable = ref(false)
|
||||
|
||||
// 密码强度
|
||||
const passwordStrength = ref(false)
|
||||
const strengthLevel = ref(0)
|
||||
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', 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' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 检查用户名是否存在
|
||||
const checkUsername = async () => {
|
||||
if (!registerForm.username || registerForm.username.length < 3) {
|
||||
resetUsernameCheck()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
usernameChecking.value = true
|
||||
usernameExists.value = false
|
||||
usernameAvailable.value = false
|
||||
|
||||
const response = await checkUsernameExists(registerForm.username)
|
||||
|
||||
if (response.success) {
|
||||
usernameExists.value = response.data.exists
|
||||
usernameAvailable.value = !response.data.exists
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check username error:', error)
|
||||
} finally {
|
||||
usernameChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查邮箱是否存在
|
||||
const checkEmail = async () => {
|
||||
if (!registerForm.email || !isValidEmail(registerForm.email)) {
|
||||
resetEmailCheck()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
emailChecking.value = true
|
||||
emailExists.value = false
|
||||
emailAvailable.value = false
|
||||
|
||||
const response = await checkEmailExists(registerForm.email)
|
||||
|
||||
if (response.success) {
|
||||
emailExists.value = response.data.exists
|
||||
emailAvailable.value = !response.data.exists
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check email error:', error)
|
||||
} finally {
|
||||
emailChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查密码强度
|
||||
const checkPasswordStrength = () => {
|
||||
const password = registerForm.password
|
||||
if (!password) {
|
||||
passwordStrength.value = false
|
||||
return
|
||||
}
|
||||
|
||||
passwordStrength.value = true
|
||||
|
||||
let score = 0
|
||||
if (password.length >= 6) score++
|
||||
if (password.length >= 8) score++
|
||||
if (/[a-z]/.test(password)) score++
|
||||
if (/[A-Z]/.test(password)) score++
|
||||
if (/[0-9]/.test(password)) score++
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++
|
||||
|
||||
strengthLevel.value = Math.min(score, 4)
|
||||
}
|
||||
|
||||
// 重置用户名检查状态
|
||||
const resetUsernameCheck = () => {
|
||||
usernameChecking.value = false
|
||||
usernameExists.value = false
|
||||
usernameAvailable.value = false
|
||||
}
|
||||
|
||||
// 重置邮箱检查状态
|
||||
const resetEmailCheck = () => {
|
||||
emailChecking.value = false
|
||||
emailExists.value = false
|
||||
emailAvailable.value = false
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const isValidEmail = (email) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const strengthClass = computed(() => {
|
||||
const classes = ['weak', 'fair', 'good', 'strong']
|
||||
return classes[strengthLevel.value - 1] || 'weak'
|
||||
})
|
||||
|
||||
const strengthWidth = computed(() => {
|
||||
return `${(strengthLevel.value / 4) * 100}%`
|
||||
})
|
||||
|
||||
const strengthText = computed(() => {
|
||||
const texts = [t('common.weak'), t('common.fair'), t('common.good'), t('common.strong')]
|
||||
return texts[strengthLevel.value - 1] || t('common.weak')
|
||||
})
|
||||
|
||||
const canRegister = computed(() => {
|
||||
return agreeTerms.value &&
|
||||
usernameAvailable.value &&
|
||||
emailAvailable.value &&
|
||||
registerForm.password &&
|
||||
registerForm.confirmPassword &&
|
||||
registerForm.password === registerForm.confirmPassword
|
||||
})
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await registerFormRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
if (!agreeTerms.value) {
|
||||
ElMessage.warning(t('common.pleaseAgreeTerms'))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await userStore.registerUser(registerForm)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message || t('common.success'))
|
||||
router.push('/login')
|
||||
} else {
|
||||
ElMessage.error(result.message || t('common.updateFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Register error:', error)
|
||||
ElMessage.error(t('common.registerFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register {
|
||||
min-height: calc(100vh - 120px);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 0;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 页面特殊效果 */
|
||||
.register::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.05) 0%, transparent 50%);
|
||||
animation: registerFloat 4s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes registerFloat {
|
||||
0% { transform: translateY(0px) rotate(0deg); }
|
||||
100% { transform: translateY(-10px) rotate(1deg); }
|
||||
}
|
||||
|
||||
/* 内容层级 */
|
||||
.register > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.register-header h2 {
|
||||
margin: 12px 0 0 0;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.register-footer p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #67C23A;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.terms-link {
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.terms-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.checking-text, .error-text, .success-text {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.checking-text {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #67C23A;
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
background-color: #EBEEF5;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.strength-fill.weak {
|
||||
background-color: #F56C6C;
|
||||
}
|
||||
|
||||
.strength-fill.fair {
|
||||
background-color: #E6A23C;
|
||||
}
|
||||
|
||||
.strength-fill.good {
|
||||
background-color: #409EFF;
|
||||
}
|
||||
|
||||
.strength-fill.strong {
|
||||
background-color: #67C23A;
|
||||
}
|
||||
|
||||
.strength-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.register {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
min-height: calc(100vh - 160px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
369
frontend/src/views/SetPassword.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<!-- 设置密码卡片 -->
|
||||
<div class="login-card">
|
||||
<!-- 标题 -->
|
||||
<div class="page-title">{{ $t('setPassword.title') }}</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="password-form">
|
||||
<!-- 新密码 -->
|
||||
<div class="input-group">
|
||||
<el-input
|
||||
v-model="form.newPassword"
|
||||
:placeholder="$t('setPassword.newPasswordPlaceholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.newPassword">{{ errors.newPassword }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<div class="input-group">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
:placeholder="$t('setPassword.confirmPasswordPlaceholder')"
|
||||
class="password-input"
|
||||
show-password
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<div class="input-error" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 确定按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
class="submit-button"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ loading ? $t('setPassword.submitting') : $t('setPassword.confirm') }}
|
||||
</el-button>
|
||||
|
||||
<!-- 跳过按钮 -->
|
||||
<div class="skip-button-wrapper">
|
||||
<el-button
|
||||
class="skip-button"
|
||||
@click="handleSkip"
|
||||
>
|
||||
{{ $t('setPassword.skipForNow') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 验证表单
|
||||
const validateForm = () => {
|
||||
let valid = true
|
||||
errors.newPassword = ''
|
||||
errors.confirmPassword = ''
|
||||
|
||||
// 密码必填,且必须包含英文字母和数字,不少于8位
|
||||
if (!form.newPassword) {
|
||||
errors.newPassword = t('setPassword.enterPassword')
|
||||
valid = false
|
||||
} else if (form.newPassword.length < 8) {
|
||||
errors.newPassword = t('setPassword.passwordMinLength')
|
||||
valid = false
|
||||
} else if (!/[a-zA-Z]/.test(form.newPassword)) {
|
||||
errors.newPassword = t('setPassword.passwordNeedLetter')
|
||||
valid = false
|
||||
} else if (!/[0-9]/.test(form.newPassword)) {
|
||||
errors.newPassword = t('setPassword.passwordNeedNumber')
|
||||
valid = false
|
||||
}
|
||||
|
||||
// 确认密码必填且必须与密码一致
|
||||
if (!form.confirmPassword) {
|
||||
errors.confirmPassword = t('setPassword.confirmPasswordRequired')
|
||||
valid = false
|
||||
} else if (form.newPassword !== form.confirmPassword) {
|
||||
errors.confirmPassword = t('setPassword.passwordMismatch')
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
// 提交设置
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
url: '/auth/change-password',
|
||||
method: 'post',
|
||||
data: {
|
||||
oldPassword: null,
|
||||
newPassword: form.newPassword,
|
||||
isFirstTimeSetup: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log('设置密码响应:', response)
|
||||
|
||||
const result = response.data
|
||||
if (result && result.success) {
|
||||
ElMessage.success(t('common.passwordSetSuccess'))
|
||||
|
||||
// 清除首次设置标记
|
||||
localStorage.removeItem('needSetPassword')
|
||||
|
||||
// 跳转到首页或之前的页面
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.replace(redirect)
|
||||
} else {
|
||||
ElMessage.error(result?.message || t('common.setFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('设置密码失败:', error)
|
||||
const errorMsg = error.response?.data?.message || error.message || t('common.setFailed')
|
||||
ElMessage.error(errorMsg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过
|
||||
const handleSkip = () => {
|
||||
// 清除首次设置标记
|
||||
localStorage.removeItem('needSetPassword')
|
||||
|
||||
// 跳转到首页
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.replace(redirect)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 检查用户是否已登录
|
||||
if (!userStore.isAuthenticated) {
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #0a0e1a url('/images/backgrounds/login_bg.png') center/cover no-repeat;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 左上角Logo */
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.login-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 550px;
|
||||
max-width: 90vw;
|
||||
background: rgba(121, 121, 121, 0.1);
|
||||
backdrop-filter: blur(50px);
|
||||
-webkit-backdrop-filter: blur(50px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 60px 80px;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title {
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 输入组 */
|
||||
.input-group {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper) {
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: none;
|
||||
height: 60px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper:hover) {
|
||||
background: rgba(217, 217, 217, 0.25);
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__inner) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.password-input :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: #ff7875;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 确定按钮 */
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: #0DC0FF;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-top: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background: #4DD4FF;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 跳过按钮 */
|
||||
.skip-button {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
background: rgba(217, 217, 217, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.skip-button:hover {
|
||||
background: rgba(217, 217, 217, 0.3);
|
||||
}
|
||||
|
||||
.skip-button-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skip-button-wrapper .skip-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
width: 90%;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
845
frontend/src/views/StoryboardVideo.vue
Normal file
@@ -0,0 +1,845 @@
|
||||
<template>
|
||||
<div class="storyboard-video-page">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToProfile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人主页</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSubscription">
|
||||
<el-icon><Compass /></el-icon>
|
||||
<span>会员订阅</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMyWorks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的作品</span>
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-item" @click="goToTextToVideo">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>文生视频</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToImageToVideo">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图生视频</span>
|
||||
<span class="badge-pro">Pro</span>
|
||||
</div>
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>分镜视频</span>
|
||||
<span class="badge-max">Max</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部用户信息卡片 -->
|
||||
<div class="user-info-card">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="avatar-image" />
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username">mingzi_FBx7foZYDS7inLQb</div>
|
||||
<div class="profile-prompt">还没有设置个人简介,点击填写</div>
|
||||
<div class="user-id">ID 2994509784706419</div>
|
||||
</div>
|
||||
<div class="edit-profile-btn">
|
||||
<el-button type="primary">编辑资料</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已发布作品区域 -->
|
||||
<div class="published-works">
|
||||
<div class="works-tabs">
|
||||
<div class="tab active">已发布</div>
|
||||
</div>
|
||||
|
||||
<div class="works-grid">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.id" @click="openDetail(work)">
|
||||
<div class="work-thumbnail">
|
||||
<img v-lazy:loading="work.cover" :alt="work.title" />
|
||||
<!-- 鼠标悬停时显示的做同款按钮 -->
|
||||
<div class="hover-create-btn" @click.stop="goToCreate(work)">
|
||||
<el-button type="primary" size="small" round>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
{{ $t('works.createSimilar') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-info">
|
||||
<div class="work-title">{{ work.title }}</div>
|
||||
<div class="work-meta">{{ work.date || $t('common.unknownDate') }} · {{ work.id }} · {{ work.size }}</div>
|
||||
</div>
|
||||
<div class="work-actions" v-if="index === 0">
|
||||
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">{{ $t('works.createSimilar') }}</el-button>
|
||||
</div>
|
||||
<div class="work-director" v-else>
|
||||
<span>DIRECTED BY VANNOCENT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 作品详情模态框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
:title="selectedItem?.title"
|
||||
width="60%"
|
||||
class="detail-dialog"
|
||||
:modal="true"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="detail-content">
|
||||
<div class="detail-left">
|
||||
<div class="video-player">
|
||||
<img :src="selectedItem?.cover" :alt="selectedItem?.title" class="video-thumbnail" />
|
||||
<div class="play-overlay">
|
||||
<div class="play-button">▶</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-right">
|
||||
<div class="metadata-section">
|
||||
<div class="metadata-item">
|
||||
<span class="label">作品 ID</span>
|
||||
<span class="value">{{ selectedItem?.id }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">文件大小</span>
|
||||
<span class="value">{{ selectedItem?.size }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ selectedItem?.createTime }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">分类</span>
|
||||
<span class="value">{{ selectedItem?.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">描述</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-section">
|
||||
<button class="create-similar-btn" @click="createSimilar">
|
||||
做同款
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 模态框状态
|
||||
const detailDialogVisible = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
|
||||
// 已发布作品数据
|
||||
const publishedWorks = ref([
|
||||
{
|
||||
id: '2995000000001',
|
||||
title: '分镜视频作品 #1',
|
||||
cover: '/images/backgrounds/welcome.jpg',
|
||||
text: 'What Does it Mean To You',
|
||||
size: '9 MB',
|
||||
category: '分镜视频',
|
||||
createTime: '2025/01/15 14:30',
|
||||
date: '2025/1/15'
|
||||
},
|
||||
{
|
||||
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',
|
||||
date: '2025/1/14'
|
||||
},
|
||||
{
|
||||
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',
|
||||
date: '2025/1/13'
|
||||
}
|
||||
])
|
||||
|
||||
// 导航函数
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
const goToSubscription = () => {
|
||||
router.push('/subscription')
|
||||
}
|
||||
|
||||
const goToMyWorks = () => {
|
||||
router.push('/works')
|
||||
}
|
||||
|
||||
const goToTextToVideo = () => {
|
||||
router.push('/text-to-video/create')
|
||||
}
|
||||
|
||||
const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
const goToStoryboardVideoCreate = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
const goToCreate = (work) => {
|
||||
// 跳转到分镜视频创作页面
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
// 模态框相关函数
|
||||
const openDetail = (work) => {
|
||||
selectedItem.value = work
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
detailDialogVisible.value = false
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
const getDescription = (item) => {
|
||||
if (!item) return ''
|
||||
return `这是一个${item.category}作品,展现了"What Does it Mean To You"的主题。作品通过AI技术生成,具有独特的视觉风格和创意表达。`
|
||||
}
|
||||
|
||||
const createSimilar = () => {
|
||||
// 关闭模态框并跳转到创作页面
|
||||
handleClose()
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面初始化
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 图片懒加载样式 */
|
||||
.lazy-loading {
|
||||
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: lazy-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.lazy-loaded {
|
||||
animation: lazy-fade-in 0.3s ease-in;
|
||||
}
|
||||
|
||||
.lazy-error {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
@keyframes lazy-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes lazy-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.storyboard-video-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 280px !important;
|
||||
background: #000000 !important;
|
||||
padding: 24px 0 !important;
|
||||
border-right: 1px solid #1a1a1a !important;
|
||||
flex-shrink: 0 !important;
|
||||
z-index: 100 !important;
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 0 24px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.sora-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 分镜视频特殊样式 */
|
||||
.storyboard-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.storyboard-item .sora-tag {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2) !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 11px !important;
|
||||
padding: 2px 8px !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3) !important;
|
||||
animation: pulse-glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0% {
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 用户信息卡片 */
|
||||
.user-info-card {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-prompt {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edit-profile-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 已发布作品区域 */
|
||||
.published-works {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.works-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 0;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-item {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.work-item:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.work-thumbnail {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示的做同款按钮 */
|
||||
.hover-create-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.work-thumbnail:hover .hover-create-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.hover-create-btn .el-button {
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
border: none;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.hover-create-btn .el-button:hover {
|
||||
background: rgba(64, 158, 255, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* work-overlay / overlay-text 样式已移除(不再使用) */
|
||||
|
||||
.work-info {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.work-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-meta {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-actions {
|
||||
padding: 0 12px 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.work-item:hover .work-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.work-director {
|
||||
padding: 0 12px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-director span {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.storyboard-video-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
:deep(.detail-dialog .el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333 !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__title) {
|
||||
color: #fff !important;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__headerbtn) {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
/* 全局覆盖Element Plus默认样式 */
|
||||
:deep(.el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border: 1px solid #333 !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
height: 50vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.video-player:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.detail-right {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.metadata-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
color: #d1d5db;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.create-similar-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Sora2.0 SVG 风格标签 */
|
||||
.badge-pro, .badge-max {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
background: rgba(62, 163, 255, 0.2);
|
||||
color: #5AE0FF;
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.badge-max {
|
||||
background: rgba(255, 100, 150, 0.2);
|
||||
color: #FF7EB3;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
5789
frontend/src/views/StoryboardVideoCreate.vue
Normal file
1877
frontend/src/views/Subscription.vue
Normal file
1582
frontend/src/views/SystemSettings.vue
Normal file
578
frontend/src/views/TaskStatusPage.vue
Normal file
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="task-status-page">
|
||||
<div class="page-header">
|
||||
<h1>任务状态监控</h1>
|
||||
<div class="header-actions">
|
||||
<button @click="refreshAll" class="btn-refresh" :disabled="loading">
|
||||
{{ loading ? '刷新中...' : '刷新全部' }}
|
||||
</button>
|
||||
<button @click="triggerPolling" class="btn-poll" v-if="isAdmin">
|
||||
手动轮询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon pending">⏳</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待处理</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon processing">🔄</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.processing }}</div>
|
||||
<div class="stat-label">处理中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon completed">✅</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.completed }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon failed">❌</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.failed }}</div>
|
||||
<div class="stat-label">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<div class="list-header">
|
||||
<h2>任务列表</h2>
|
||||
<div class="filter-controls">
|
||||
<select v-model="statusFilter" @change="filterTasks">
|
||||
<option value="">全部状态</option>
|
||||
<option value="PENDING">待处理</option>
|
||||
<option value="PROCESSING">处理中</option>
|
||||
<option value="COMPLETED">已完成</option>
|
||||
<option value="FAILED">失败</option>
|
||||
<option value="CANCELLED">已取消</option>
|
||||
<option value="TIMEOUT">超时</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-items">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.taskId"
|
||||
class="task-item"
|
||||
:class="getTaskItemClass(task.status)"
|
||||
>
|
||||
<div class="task-main">
|
||||
<div class="task-info">
|
||||
<div class="task-id">{{ task.taskId }}</div>
|
||||
<div class="task-type">{{ task.taskType?.description || task.taskType }}</div>
|
||||
<div class="task-time">{{ formatDate(task.createdAt) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="task-status">
|
||||
<div class="status-badge" :class="getStatusClass(task.status)">
|
||||
{{ task.statusDescription || task.status }}
|
||||
</div>
|
||||
<div class="progress-info" v-if="task.status === 'PROCESSING'">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: task.progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ task.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-actions">
|
||||
<button
|
||||
v-if="task.resultUrl"
|
||||
@click="viewResult(task.resultUrl)"
|
||||
class="btn-view"
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
<button
|
||||
v-if="['FAILED', 'TIMEOUT'].includes(task.status)"
|
||||
@click="retryTask(task.taskId)"
|
||||
class="btn-retry"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTasks.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<div class="empty-text">暂无任务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { taskStatusApi } from '@/api/taskStatus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const tasks = ref([])
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const refreshTimer = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
|
||||
const stats = computed(() => {
|
||||
const stats = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0
|
||||
}
|
||||
|
||||
tasks.value.forEach(task => {
|
||||
switch (task.status) {
|
||||
case 'PENDING':
|
||||
stats.pending++
|
||||
break
|
||||
case 'PROCESSING':
|
||||
stats.processing++
|
||||
break
|
||||
case 'COMPLETED':
|
||||
stats.completed++
|
||||
break
|
||||
case 'FAILED':
|
||||
case 'CANCELLED':
|
||||
case 'TIMEOUT':
|
||||
stats.failed++
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return stats
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (!statusFilter.value) {
|
||||
return tasks.value
|
||||
}
|
||||
return tasks.value.filter(task => task.status === statusFilter.value)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await taskStatusApi.getUserTaskStatuses(userStore.user?.username)
|
||||
tasks.value = response.data
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error)
|
||||
ElMessage.error(t('common.loadTaskListFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAll = async () => {
|
||||
await fetchTasks()
|
||||
ElMessage.success(t('common.taskListRefreshed'))
|
||||
}
|
||||
|
||||
const filterTasks = () => {
|
||||
// 过滤逻辑在计算属性中处理
|
||||
}
|
||||
|
||||
const viewResult = (resultUrl) => {
|
||||
window.open(resultUrl, '_blank')
|
||||
}
|
||||
|
||||
const retryTask = (taskId) => {
|
||||
// 重试逻辑,可以导航到相应的创建页面
|
||||
ElMessage.info(t('common.retryFeatureInDev'))
|
||||
}
|
||||
|
||||
const triggerPolling = async () => {
|
||||
try {
|
||||
const response = await taskStatusApi.triggerPolling()
|
||||
if (response.data.success) {
|
||||
ElMessage.success(t('common.pollingTriggered'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('触发轮询失败:', error)
|
||||
ElMessage.error(t('common.triggerPollingFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getTaskItemClass = (status) => {
|
||||
return `task-item-${status.toLowerCase()}`
|
||||
}
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
return `status-${status.toLowerCase()}`
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
refreshTimer.value = setInterval(fetchTasks, 30000) // 30秒刷新一次
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer.value) {
|
||||
clearInterval(refreshTimer.value)
|
||||
refreshTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-status-page {
|
||||
padding: 24px;
|
||||
background: #0a0a0a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-refresh,
|
||||
.btn-poll {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-poll {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-poll:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-icon.pending {
|
||||
background: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-icon.processing {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-icon.completed {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.stat-icon.failed {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.list-header h2 {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-controls select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: #0a0a0a;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid #374151;
|
||||
}
|
||||
|
||||
.task-item-pending {
|
||||
border-left-color: #fbbf24;
|
||||
}
|
||||
|
||||
.task-item-processing {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.task-item-completed {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.task-item-failed,
|
||||
.task-item-cancelled,
|
||||
.task-item-timeout {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.task-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-type {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #fbbf24;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
background: #3b82f6;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #10b981;
|
||||
color: #064e3b;
|
||||
}
|
||||
|
||||
.status-failed,
|
||||
.status-cancelled,
|
||||
.status-timeout {
|
||||
background: #ef4444;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: #374151;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-view,
|
||||
.btn-retry {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-retry:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
440
frontend/src/views/TermsOfService.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div class="terms-of-service">
|
||||
<el-page-header @back="$router.go(-1)" content="Vionow 服务条款">
|
||||
</el-page-header>
|
||||
|
||||
<el-card class="terms-card">
|
||||
<div class="terms-content">
|
||||
<!-- 中文版本 -->
|
||||
<div class="language-section">
|
||||
<h1>Vionow 服务条款</h1>
|
||||
<p class="intro">
|
||||
欢迎来到 Vionow。本服务条款(下称"条款")适用于对网站 <a href="https://vionow.com" target="_blank">https://vionow.com</a> 及AI视频创作软件(下称"服务"或"平台")的访问和使用。通过访问、注册或使用 Vionow,用户(下称"用户")同意本条款并承诺完全遵守。
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>法律信息和所有权</h2>
|
||||
<p>根据适用法规,向用户告知有关服务提供商的以下详细信息:</p>
|
||||
<ul>
|
||||
<li><strong>所有者:</strong>Vionow Team</li>
|
||||
<li><strong>联系邮箱:</strong><a href="mailto:contact@vionow.com">contact@vionow.com</a></li>
|
||||
<li><strong>网站:</strong><a href="https://vionow.com" target="_blank">https://vionow.com</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>服务说明</h2>
|
||||
<p>Vionow 是一个基于人工智能的视频创作平台。访问该服务需要注册,并采用基于代币的模式运行:</p>
|
||||
<ul>
|
||||
<li>用户必须注册才能访问平台。</li>
|
||||
<li>代币通过付费订阅计划获得。</li>
|
||||
<li>代币不可累积;它们在每个计费周期结束时到期。</li>
|
||||
<li>服务按"原样"提供,不保证适用于任何特定目的。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>用户注册和账户</h2>
|
||||
<p>用户必须提供有效的电子邮件地址进行注册。注册即表示用户同意:</p>
|
||||
<ul>
|
||||
<li>提供准确、最新和完整的信息。</li>
|
||||
<li>对登录凭据保密,不与第三方共享。</li>
|
||||
<li>避免任何欺诈或滥用本服务的行为。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>通讯</h2>
|
||||
<p>注册即表示用户同意接收有关服务的通讯,包括更新、教程和宣传材料。用户可以随时通过邮件中的退订链接选择退订。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>订阅计划和自动续订</h2>
|
||||
<ul>
|
||||
<li>除非提前取消,否则订阅将自动续订。</li>
|
||||
<li>付款按周期性处理。</li>
|
||||
<li>代币每月更新,不会结转。</li>
|
||||
<li>用户可随时取消;取消将在当前计费周期结束时生效。未使用的代币不予退款。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>退款政策和有限保证</h2>
|
||||
<p>我们提供7天保证,前提是用户渲染的视频项目不超过一个。如果渲染的视频项目超过一个,则用户将丧失申请退款的权利。对于因不满意结果、平台期望或未使用服务而提出的退款请求,将不予受理。用户承担使用本服务的所有风险。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>退款保证</h2>
|
||||
<p>除一般退款政策外,Vionow 还为首次购买客户提供一次性退款保证,但需满足以下条件:</p>
|
||||
<ul>
|
||||
<li>用户使用的积分不得超过所购套餐中包含积分的30%。例如,对于尝试套餐(500积分),申请退款的资格要求是消耗的积分不超过150个。</li>
|
||||
<li>退款请求必须通过填写一份强制性反馈表提交,并详细说明不满意的原因。该表格必须在购买之日起15天内提交。</li>
|
||||
<li>在以下情况下将不予退款:
|
||||
<ul>
|
||||
<li>使用的积分超过30%。</li>
|
||||
<li>表格不完整或在15天窗口期后提交。</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>退款将通过原始支付方式进行,处理时间最多可能需要10个工作日。</li>
|
||||
</ul>
|
||||
<p>使用本服务即表示用户承认并同意这些条件。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>服务的正当使用</h2>
|
||||
<p>用户同意根据适用法律和道德标准使用本平台。禁止的活动包括但不限于:</p>
|
||||
<ul>
|
||||
<li>上传非法、冒犯、色情、诽谤或未经适当授权的第三方版权材料。</li>
|
||||
<li>试图访问源代码或对软件进行逆向工程。</li>
|
||||
<li>利用漏洞或从事系统滥用行为。</li>
|
||||
</ul>
|
||||
<p>公司保留暂停或终止任何违反这些规定的账户的权利。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>数据保护和隐私</h2>
|
||||
<p>Vionow 遵守相关数据保护法规。个人数据将根据我们的隐私政策进行处理,该政策概述了:</p>
|
||||
<ul>
|
||||
<li>数据收集的目的</li>
|
||||
<li>处理的法律依据</li>
|
||||
<li>用户在访问、纠正、删除、反对和可移植性方面的权利</li>
|
||||
</ul>
|
||||
<p>有关数据相关的请求,请联系:<a href="mailto:contact@vionow.com">contact@vionow.com</a>。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>上传内容的所有权和许可授予</h2>
|
||||
<p>用户保留上传至本服务内容(如视频、素材)的完全所有权。使用本服务即表示用户授予公司非排他性、免版税、全球性、永久性的许可,以仅为推广、营销或演示目的(例如在网站、社交媒体或新闻通讯中)使用、复制、修改和公开展示该等内容,前提是该内容不包含可识别的个人或机密信息。用户可随时通过联系 <a href="mailto:contact@vionow.com">contact@vionow.com</a> 撤销此许可。收到请求后,公司将停止使用并从营销渠道中删除该等内容。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>责任免除</h2>
|
||||
<p>本服务不提供任何形式的保证。Vionow 不保证:</p>
|
||||
<ul>
|
||||
<li>服务将不间断、安全或无错误。</li>
|
||||
<li>输出结果将满足用户的主观期望。</li>
|
||||
<li>与第三方格式、软件或平台的兼容性。</li>
|
||||
</ul>
|
||||
<p>用户承认并接受与使用本服务相关的所有风险。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>条款和条件的修改</h2>
|
||||
<p>我们保留随时修改本条款的权利。重大变更将通过电子邮件或网站公告进行通知。继续使用本服务即表示接受更新后的条款。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>适用法律和司法管辖权</h2>
|
||||
<p>本条款受香港法律管辖。因解释或执行本条款而产生的任何争议,除非适用法律另有规定,否则应提交至香港法院。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>联系方式</h2>
|
||||
<p>如有法律或运营方面的疑问,请联系:<a href="mailto:contact@vionow.com">contact@vionow.com</a></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Cookies 政策</h2>
|
||||
<p>我们的网站使用 cookies 以改善用户体验。Cookies 是存储在您设备上的小型文本文件,允许我们收集有关浏览行为和网站使用情况的信息。</p>
|
||||
<h3>使用的 Cookies 类型</h3>
|
||||
<p>我们仅使用根据适用法律免于征求同意的基本和分析性 cookies:</p>
|
||||
<ul>
|
||||
<li><strong>purecookieDismiss:</strong>记录用户是否已关闭 cookie 横幅。为确保正常运行所必需。</li>
|
||||
<li><strong>__gsas, _ga_GMVSXDG494, _ga:</strong>用于汇总统计洞察的 Google Analytics 4 cookies。这些 cookies 不记录或存储 IP 地址。</li>
|
||||
</ul>
|
||||
<p>Cookies 不用于广告或用户画像目的。用户可以配置其浏览器设置来管理或删除 cookies。</p>
|
||||
</section>
|
||||
|
||||
<section class="conclusion">
|
||||
<p>本文件构成用户与 Vionow 就使用本服务达成的完整协议,并取代任何先前的协议或通讯。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 英文版本 -->
|
||||
<div class="language-section">
|
||||
<h1>Vionow Terms of Service</h1>
|
||||
<p class="intro">
|
||||
Welcome to Vionow. These Terms of Service ("Terms") govern access to and use of the website <a href="https://vionow.com" target="_blank">https://vionow.com</a> and the AI-powered video creation software (hereinafter, the "Service" or "Platform"). By accessing, registering, or using Vionow, the user (hereinafter, the "User") agrees to these Terms and commits to complying with them in full.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Legal Information and Ownership</h2>
|
||||
<p>In accordance with applicable regulations, Users are informed of the following details regarding the Service provider:</p>
|
||||
<ul>
|
||||
<li><strong>Owner:</strong> Vionow Team</li>
|
||||
<li><strong>Contact email:</strong> <a href="mailto:contact@vionow.com">contact@vionow.com</a></li>
|
||||
<li><strong>Website:</strong> <a href="https://vionow.com" target="_blank">https://vionow.com</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Description of the Service</h2>
|
||||
<p>Vionow is an AI-based platform for video creation. Access to the Service requires registration and operates on a token-based model:</p>
|
||||
<ul>
|
||||
<li>Users must register to access the Platform.</li>
|
||||
<li>Tokens are acquired through paid subscription plans.</li>
|
||||
<li>Tokens are non-cumulative; they expire at the end of each billing cycle.</li>
|
||||
<li>The Service is provided "as is" without guarantees of suitability for any specific purpose.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>User Registration and Account</h2>
|
||||
<p>Users must provide a valid email address to register. By registering, Users agree to:</p>
|
||||
<ul>
|
||||
<li>Provide accurate, current, and complete information.</li>
|
||||
<li>Keep login credentials confidential and not share them with third parties.</li>
|
||||
<li>Avoid any fraudulent or abusive use of the Service.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Communications</h2>
|
||||
<p>By registering, the User consents to receiving communications regarding the Service, including updates, tutorials, and promotional materials. Users may opt out at any time by following the unsubscribe link in such emails.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Subscription Plans and Automatic Renewal</h2>
|
||||
<ul>
|
||||
<li>Subscriptions renew automatically unless canceled in advance.</li>
|
||||
<li>Payments are processed on a recurring basis.</li>
|
||||
<li>Tokens are renewed monthly and do not carry over.</li>
|
||||
<li>Users may cancel at any time; cancellations take effect at the end of the current billing cycle. Refunds are not issued for unused tokens.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Refund Policy and Limited Guarantees</h2>
|
||||
<p>A 7-day guarantee is offered, provided the User has not rendered more than one video project. If more than one video project has been rendered, the right to request a refund is forfeited. No refunds will be given for dissatisfaction with results, platform expectations, or lack of usage. The User assumes all risk for using the Service.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Money-Back Guarantee</h2>
|
||||
<p>In addition to the general refund policy, Vionow offers a one-time money-back guarantee for first-time customers under the following conditions:</p>
|
||||
<ul>
|
||||
<li>The User must not have used more than 30% of the credits included in the purchased plan. For example, for the Try Plan (500 credits), eligibility for a refund requires that no more than 150 credits have been consumed.</li>
|
||||
<li>The refund request must be submitted by completing a mandatory feedback form, providing detailed reasons for dissatisfaction. This form must be submitted within 15 days of the purchase date.</li>
|
||||
<li>Refunds will not be granted:
|
||||
<ul>
|
||||
<li>If more than 30% of the credits have been used.</li>
|
||||
<li>If the form is incomplete or submitted after the 15-day window.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Refunds will be issued using the original payment method and may take up to 10 business days to be processed.</li>
|
||||
</ul>
|
||||
<p>By using the Service, the User acknowledges and agrees to these conditions.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Proper Use of the Service</h2>
|
||||
<p>Users agree to use the Platform in accordance with applicable law and ethical standards. Prohibited activities include, but are not limited to:</p>
|
||||
<ul>
|
||||
<li>Uploading illegal, offensive, pornographic, defamatory, or third-party copyrighted material without proper authorization.</li>
|
||||
<li>Attempting to access the source code or reverse engineer the software.</li>
|
||||
<li>Exploiting vulnerabilities or engaging in system abuse.</li>
|
||||
</ul>
|
||||
<p>The Company reserves the right to suspend or terminate any account that violates these provisions.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Data Protection and Privacy</h2>
|
||||
<p>Vionow complies with relevant data protection regulations. Personal data is processed according to our Privacy Policy, which outlines:</p>
|
||||
<ul>
|
||||
<li>Purpose of data collection</li>
|
||||
<li>Legal basis for processing</li>
|
||||
<li>User rights regarding access, rectification, erasure, objection, and portability</li>
|
||||
</ul>
|
||||
<p>Contact: <a href="mailto:contact@vionow.com">contact@vionow.com</a> for data-related requests.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Ownership of Uploaded Content and License Grant</h2>
|
||||
<p>Users retain full ownership of content (e.g., videos, assets) uploaded to the Service. By using the Service, the User grants the Company a non-exclusive, royalty-free, worldwide, perpetual license to use, reproduce, modify, and publicly display such content solely for promotional, marketing, or demonstration purposes (e.g., on the Website, social media, or newsletters), provided such content does not contain identifiable individuals or confidential information. Users may withdraw this license at any time by contacting <a href="mailto:contact@vionow.com">contact@vionow.com</a>. Upon request, the Company will cease using and remove such content from marketing channels.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability Disclaimer</h2>
|
||||
<p>The Service is provided without warranties of any kind. Vionow does not guarantee:</p>
|
||||
<ul>
|
||||
<li>That the Service will be uninterrupted, secure, or error-free.</li>
|
||||
<li>That the output will meet the User's subjective expectations.</li>
|
||||
<li>Compatibility with third-party formats, software, or platforms.</li>
|
||||
</ul>
|
||||
<p>The User acknowledges and accepts all risks related to the use of the Service.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Modification of Terms and Conditions</h2>
|
||||
<p>We reserve the right to modify these Terms at any time. Material changes will be communicated via email or notice on the Website. Continued use of the Service implies acceptance of the updated Terms.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Applicable Law and Jurisdiction</h2>
|
||||
<p>These Terms are governed by the laws of Hong Kong. Any disputes arising from the interpretation or execution of these Terms shall be submitted to the courts of Hong Kong, unless another jurisdiction is mandated by applicable law.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Contact</h2>
|
||||
<p>For legal or operational inquiries, please contact: <a href="mailto:contact@vionow.com">contact@vionow.com</a></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Cookies Policy</h2>
|
||||
<p>Our Website uses cookies to improve the user experience. Cookies are small text files stored on your device that allow us to collect information about browsing behavior and usage of the Website.</p>
|
||||
<h3>Types of Cookies Used</h3>
|
||||
<p>We only use essential and analytics cookies that are exempt from requiring consent under applicable law:</p>
|
||||
<ul>
|
||||
<li><strong>purecookieDismiss:</strong> Records whether the user has dismissed the cookie banner. Required for proper functioning.</li>
|
||||
<li><strong>__gsas, _ga_GMVSXDG494, _ga:</strong> Google Analytics 4 cookies used for aggregate statistical insights. These cookies do not log or store IP addresses.</li>
|
||||
</ul>
|
||||
<p>Cookies are not used for advertising or profiling purposes. Users may configure their browser settings to manage or delete cookies.</p>
|
||||
</section>
|
||||
|
||||
<section class="conclusion">
|
||||
<p>This document constitutes the entire agreement between the User and Vionow with respect to the use of the Service and supersedes any prior agreements or communications.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terms-of-service {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.terms-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.terms-content {
|
||||
padding: 20px;
|
||||
line-height: 1.8;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.language-section {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.language-section:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-top: 40px;
|
||||
border-top: 2px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.language-section h1 {
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #409EFF;
|
||||
}
|
||||
|
||||
.language-section h2 {
|
||||
font-size: 22px;
|
||||
color: #409EFF;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #409EFF;
|
||||
}
|
||||
|
||||
.language-section h3 {
|
||||
font-size: 18px;
|
||||
color: #606266;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin-bottom: 30px;
|
||||
padding: 15px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
section p {
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
section ul {
|
||||
margin: 15px 0;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
section ul li {
|
||||
margin-bottom: 10px;
|
||||
color: #606266;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
section ul ul {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
section a {
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
section a:hover {
|
||||
color: #66b1ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.conclusion {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #67c23a;
|
||||
}
|
||||
|
||||
.conclusion p {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.terms-of-service {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.terms-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.language-section h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.language-section h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.language-section h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
858
frontend/src/views/TextToVideo.vue
Normal file
@@ -0,0 +1,858 @@
|
||||
<template>
|
||||
<div class="text-to-video-page">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToProfile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人主页</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSubscription">
|
||||
<el-icon><Compass /></el-icon>
|
||||
<span>会员订阅</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToMyWorks">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的作品</span>
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>文生视频</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToImageToVideo">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图生视频</span>
|
||||
</div>
|
||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
||||
<el-icon><Film /></el-icon>
|
||||
<span>分镜视频</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部用户信息卡片 -->
|
||||
<div class="user-info-card">
|
||||
<div class="user-avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="avatar-image" />
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username">mingzi_FBx7foZYDS7inLQb</div>
|
||||
<div class="profile-prompt">还没有设置个人简介,点击填写</div>
|
||||
<div class="user-id">ID 2994509784706419</div>
|
||||
</div>
|
||||
<div class="edit-profile-btn">
|
||||
<el-button type="primary">编辑资料</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已发布作品区域 -->
|
||||
<div class="published-works">
|
||||
<div class="works-tabs">
|
||||
<div class="tab active">已发布</div>
|
||||
</div>
|
||||
|
||||
<div class="works-grid">
|
||||
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.taskId || work.id" @click="openDetail(work)">
|
||||
<div class="work-thumbnail">
|
||||
<!-- 使用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="hover-create-btn" @click.stop="goToCreate(work)">
|
||||
<el-button type="primary" size="small" round>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
{{ $t('works.createSimilar') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="work-info">
|
||||
<div class="work-title">{{ work.prompt || work.title || $t('common.textToVideoCategory') }}</div>
|
||||
<div class="work-meta">{{ work.date || $t('common.unknownDate') }} · {{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
|
||||
</div>
|
||||
<div class="work-actions" v-if="index === 0">
|
||||
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">{{ $t('works.createSimilar') }}</el-button>
|
||||
</div>
|
||||
<div class="work-director" v-else>
|
||||
<span>DIRECTED BY VANNOCENT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 作品详情模态框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
:title="selectedItem?.title"
|
||||
width="60%"
|
||||
class="detail-dialog"
|
||||
:modal="true"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="detail-content">
|
||||
<div class="detail-left">
|
||||
<div class="video-player">
|
||||
<img :src="selectedItem?.cover" :alt="selectedItem?.title" class="video-thumbnail" />
|
||||
<div class="play-overlay">
|
||||
<div class="play-button">▶</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-right">
|
||||
<div class="metadata-section">
|
||||
<div class="metadata-item">
|
||||
<span class="label">作品 ID</span>
|
||||
<span class="value">{{ selectedItem?.id }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">文件大小</span>
|
||||
<span class="value">{{ selectedItem?.size }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ selectedItem?.createTime }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">分类</span>
|
||||
<span class="value">{{ selectedItem?.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">描述</h3>
|
||||
<p class="description-text">{{ getDescription(selectedItem) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-section">
|
||||
<button class="create-similar-btn" @click="createSimilar">
|
||||
做同款
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
|
||||
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
|
||||
import { textToVideoApi } from '@/api/textToVideo'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
// 模态框状态
|
||||
const detailDialogVisible = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
|
||||
// 已发布作品数据
|
||||
const publishedWorks = ref([])
|
||||
|
||||
// 导航函数
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
const goToSubscription = () => {
|
||||
router.push('/subscription')
|
||||
}
|
||||
|
||||
const goToMyWorks = () => {
|
||||
router.push('/works')
|
||||
}
|
||||
|
||||
const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
const goToStoryboardVideo = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
|
||||
const goToCreate = (work) => {
|
||||
// 跳转到文生视频创作页面
|
||||
router.push('/text-to-video/create')
|
||||
}
|
||||
|
||||
// 模态框相关函数
|
||||
const openDetail = (work) => {
|
||||
selectedItem.value = work
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
detailDialogVisible.value = false
|
||||
selectedItem.value = null
|
||||
}
|
||||
|
||||
const getDescription = (item) => {
|
||||
if (!item) return ''
|
||||
return `这是一个${item.category}作品,展现了"What Does it Mean To You"的主题。作品通过AI技术生成,具有独特的视觉风格和创意表达。`
|
||||
}
|
||||
|
||||
const createSimilar = () => {
|
||||
// 关闭模态框并跳转到创作页面
|
||||
handleClose()
|
||||
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 || t('common.textToVideoCategory'),
|
||||
text: task.prompt || t('common.textToVideoCategory'),
|
||||
category: t('common.textToVideoCategory'),
|
||||
createTime: task.createdAt ? new Date(task.createdAt).toLocaleString('zh-CN') : '',
|
||||
date: task.createdAt ? new Date(task.createdAt).toLocaleDateString('zh-CN') : t('common.unknownDate')
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
ElMessage.error(t('common.loadTaskListFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面初始化时加载任务列表
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-to-video-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 280px !important;
|
||||
background: #000000 !important;
|
||||
padding: 24px 0 !important;
|
||||
border-right: 1px solid #1a1a1a !important;
|
||||
flex-shrink: 0 !important;
|
||||
z-index: 100 !important;
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 0 24px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.sora-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 分镜视频特殊样式 */
|
||||
.storyboard-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.storyboard-item .sora-tag {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2) !important;
|
||||
border: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 11px !important;
|
||||
padding: 2px 8px !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3) !important;
|
||||
animation: pulse-glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0% {
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 用户信息卡片 */
|
||||
.user-info-card {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.profile-prompt {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edit-profile-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 已发布作品区域 */
|
||||
.published-works {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.works-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 0;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-item {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.work-item:hover {
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.work-thumbnail {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-thumbnail img,
|
||||
.work-thumbnail video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.work-video-thumbnail {
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.work-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示的做同款按钮 */
|
||||
.hover-create-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.work-thumbnail:hover .hover-create-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.hover-create-btn .el-button {
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
border: none;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.hover-create-btn .el-button:hover {
|
||||
background: rgba(64, 158, 255, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* .work-overlay 和 .overlay-text 已移除:不再使用 */
|
||||
|
||||
.work-info {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.work-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-meta {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-actions {
|
||||
padding: 0 12px 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.work-item:hover .work-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.work-director {
|
||||
padding: 0 12px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-director span {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.text-to-video-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
:deep(.detail-dialog .el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333 !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__title) {
|
||||
color: #fff !important;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__headerbtn) {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog .el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
/* 全局覆盖Element Plus默认样式 */
|
||||
:deep(.el-dialog) {
|
||||
background: #0a0a0a !important;
|
||||
border: 1px solid #333 !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__wrapper) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
:deep(.el-overlay) {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
height: 50vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.video-player:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.detail-right {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.metadata-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
color: #d1d5db;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.create-similar-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
</style>
|
||||
2577
frontend/src/views/TextToVideoCreate.vue
Normal file
420
frontend/src/views/UserAgreement.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="agreement-page">
|
||||
<div class="agreement-container">
|
||||
<!-- 返回按钮 -->
|
||||
<div class="back-button" @click="goBack">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>返回</span>
|
||||
</div>
|
||||
|
||||
<!-- 中文版本 -->
|
||||
<div class="agreement-content">
|
||||
<h1>最终用户许可协议(EULA)</h1>
|
||||
|
||||
<p class="intro">
|
||||
本最终用户许可协议(下称"协议")是您("用户")与 Vionow("Vionow"、"我们")之间订立的法律合同,管辖您对我们基于网络的视频创作平台及相关服务("服务")的访问和使用。
|
||||
</p>
|
||||
|
||||
<p class="intro">
|
||||
通过访问、注册或使用Vionow,即表示您同意受本协议条款的约束。如果您不同意这些条款,则不得使用本服务。
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. 许可授予</h2>
|
||||
<p>
|
||||
Vionow 授予您一项有限的、非排他性的、不可转让且可撤销的许可,允许您根据本协议和我们的服务条款,为个人或专业目的使用本服务。
|
||||
</p>
|
||||
|
||||
<h3>您可以:</h3>
|
||||
<ul>
|
||||
<li>通过网络浏览器访问和使用本服务</li>
|
||||
<li>上传素材并创作您自己的视频</li>
|
||||
<li>将处理后的结果用于商业或非商业目的(根据您的订阅级别允许)</li>
|
||||
</ul>
|
||||
|
||||
<h3>您不可以:</h3>
|
||||
<ul>
|
||||
<li>转售、再许可或重新分发本服务或其输出内容</li>
|
||||
<li>对平台进行逆向工程或修改</li>
|
||||
<li>使用自动化系统(如机器人、脚本等)访问或利用本服务</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. 所有权</h2>
|
||||
<p>
|
||||
本服务中的所有知识产权,包括软件、界面、模型和人工智能技术,均属于Vionow。您保留您上传的内容及生成输出内容的所有权,但须遵守本协议。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. 用户内容</h2>
|
||||
<p>
|
||||
上传内容即表示您确认您拥有该内容的使用权或已获得使用许可。您授予Vionow 一项有限许可,以处理和临时存储您的内容,从而提供本服务。
|
||||
</p>
|
||||
<p>
|
||||
我们不对您的输入或输出内容主张所有权。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. 可接受的使用</h2>
|
||||
<p>您同意不将本服务用于:</p>
|
||||
<ul>
|
||||
<li>侵犯第三方权利(例如版权、商标权、隐私权)</li>
|
||||
<li>非法、有害或滥用活动</li>
|
||||
<li>生成或传播攻击性、色情或仇恨内容</li>
|
||||
</ul>
|
||||
<p>违反规定可能导致您的访问权限被暂停或终止。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. 终止</h2>
|
||||
<p>
|
||||
如果您违反本协议,我们可能会暂停或终止您的账户及对本服务的访问权限。您可以随时取消您的订阅,但须遵守我们的退款和计费政策。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. 免责声明</h2>
|
||||
<p>
|
||||
本服务按"原样"提供,不附带任何形式的保证。虽然我们力求准确和高质量,但我们不保证特定的结果或不间断的访问。
|
||||
</p>
|
||||
<p>我们对以下情况不承担责任:</p>
|
||||
<ul>
|
||||
<li>数据丢失</li>
|
||||
<li>输出质量问题</li>
|
||||
<li>与您使用本服务相关的第三方索赔</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. 修改</h2>
|
||||
<p>
|
||||
我们保留随时修改本协议的权利。更新内容将在公开发布后立即生效。继续使用本服务即表示您接受修订后的条款。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. 联系方式</h2>
|
||||
<p>
|
||||
如果对本协议有任何疑问或疑虑,请通过以下方式联系我们:<br>
|
||||
<a href="mailto:contact@vionow.com">contact@vionow.com</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- English Version -->
|
||||
<h1 class="english-title">End User License Agreement (EULA)</h1>
|
||||
|
||||
<p class="intro">
|
||||
This End User License Agreement ("Agreement") is a legal contract between you (the "User") and Vionow ("Vionow", "we", or "us"), governing your access to and use of our web-based video creation platform and associated services (the "Service").
|
||||
</p>
|
||||
|
||||
<p class="intro">
|
||||
By accessing, registering for, or using Vionow, you agree to be bound by the terms of this Agreement. If you do not agree to the terms, you must not use the Service.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. License Grant</h2>
|
||||
<p>
|
||||
Vionow grants you a limited, non-exclusive, non-transferable, and revocable license to use the Service for personal or professional purposes in accordance with this Agreement and our Terms of Service.
|
||||
</p>
|
||||
|
||||
<h3>You may:</h3>
|
||||
<ul>
|
||||
<li>Access and use the Service via a web browser</li>
|
||||
<li>Upload content and create your own videos</li>
|
||||
<li>Use processed results for commercial or non-commercial purposes (as permitted by your subscription level)</li>
|
||||
</ul>
|
||||
|
||||
<h3>You may not:</h3>
|
||||
<ul>
|
||||
<li>Resell, sublicense, or redistribute the Service or its output</li>
|
||||
<li>Reverse engineer or modify the platform</li>
|
||||
<li>Use automated systems (bots, scripts, etc.) to access or exploit the Service</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Ownership</h2>
|
||||
<p>
|
||||
All intellectual property rights in the Service, including the software, interface, models, and AI technologies, belong to Vionow. You retain ownership of the content you upload and the outputs generated, subject to this Agreement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. User Content</h2>
|
||||
<p>
|
||||
By uploading content, you confirm that you own the rights or have permission to use it. You grant Vionow a limited license to process and temporarily store your content to provide the Service.
|
||||
</p>
|
||||
<p>
|
||||
We do not claim ownership of your input or output content.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Acceptable Use</h2>
|
||||
<p>You agree not to use the Service:</p>
|
||||
<ul>
|
||||
<li>To infringe upon third-party rights (e.g. copyright, trademark, privacy)</li>
|
||||
<li>For unlawful, harmful, or abusive activities</li>
|
||||
<li>To generate or distribute offensive, pornographic, or hateful content</li>
|
||||
</ul>
|
||||
<p>Violations may result in suspension or termination of access.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Termination</h2>
|
||||
<p>
|
||||
We may suspend or terminate your account and access to the Service if you breach this Agreement. You may cancel your subscription at any time, subject to our refund and billing policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Disclaimers</h2>
|
||||
<p>
|
||||
The Service is provided "as is" without warranties of any kind. While we strive for accuracy and quality, we do not guarantee specific outcomes or uninterrupted access.
|
||||
</p>
|
||||
<p>We are not liable for:</p>
|
||||
<ul>
|
||||
<li>Loss of data</li>
|
||||
<li>Output quality issues</li>
|
||||
<li>Third-party claims related to your use of the Service</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Modifications</h2>
|
||||
<p>
|
||||
We reserve the right to modify this Agreement at any time. Updates will be posted publicly and take effect upon publication. Continued use of the Service implies acceptance of the revised terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Contact</h2>
|
||||
<p>
|
||||
For any questions or concerns regarding this Agreement, please contact us at:<br>
|
||||
<a href="mailto:contact@vionow.com">contact@vionow.com</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agreement-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agreement-container {
|
||||
max-width: 900px;
|
||||
max-height: calc(100vh - 80px);
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 60px 80px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.agreement-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.agreement-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.agreement-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.agreement-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
.agreement-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 212, 255, 0.3) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #00D4FF;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 30px;
|
||||
transition: all 0.3s ease;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #00D4FF;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.english-title {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-top: 40px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
ul li {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
ul li::before {
|
||||
content: "·";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: #00D4FF;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00D4FF;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
margin: 60px 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.agreement-container {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p, ul li {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agreement-page {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.agreement-container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
866
frontend/src/views/VideoDetail.vue
Normal file
@@ -0,0 +1,866 @@
|
||||
<template>
|
||||
<div class="video-detail-page">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-item" @click="goToProfile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人主页</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="goToSubscription">
|
||||
<el-icon><Compass /></el-icon>
|
||||
<span>会员订阅</span>
|
||||
</div>
|
||||
<div class="nav-item active">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的作品</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<el-icon :size="60" color="#f56c6c"><CircleClose /></el-icon>
|
||||
<p>{{ error }}</p>
|
||||
<el-button type="primary" @click="loadVideoData">重试</el-button>
|
||||
<el-button @click="goBack">返回</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 正常内容 -->
|
||||
<template v-else>
|
||||
<!-- 左侧视频播放器区域 -->
|
||||
<div class="video-player-section">
|
||||
<div class="video-container">
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
class="video-player"
|
||||
:src="videoData.videoUrl"
|
||||
:poster="videoData.cover"
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onVideoEnded"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
|
||||
<!-- 视频文字叠加 已移除(用户要求) -->
|
||||
|
||||
<!-- 播放控制栏 -->
|
||||
<div class="video-controls">
|
||||
<div class="control-left">
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<el-icon v-if="!isPlaying"><VideoPlay /></el-icon>
|
||||
<el-icon v-else><Pause /></el-icon>
|
||||
</button>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" @click="seekTo">
|
||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<div class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-right">
|
||||
<button class="control-btn" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右上角操作按钮 -->
|
||||
<div class="video-actions">
|
||||
<el-tooltip :content="$t('video.download')" placement="bottom">
|
||||
<button class="action-btn" @click="downloadVideo">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="$t('common.delete')" placement="bottom">
|
||||
<button class="action-btn delete-btn" @click="deleteVideo">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧详情侧边栏 -->
|
||||
<div class="detail-sidebar">
|
||||
<!-- 用户信息头部 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="user-info">
|
||||
<div class="avatar">
|
||||
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" class="avatar-image" />
|
||||
</div>
|
||||
<div class="username">{{ videoData.username }}</div>
|
||||
</div>
|
||||
<button class="close-btn" @click="goBack">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<div class="tabs">
|
||||
<div class="tab active">视频详情</div>
|
||||
<div class="tab">文生视频</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词区域 -->
|
||||
<div class="description-section">
|
||||
<h3 class="section-title">提示词</h3>
|
||||
<p class="description-text">{{ videoData.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 元数据区域 -->
|
||||
<div class="metadata-section">
|
||||
<div class="metadata-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">{{ videoData.createTime }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">视频 ID</span>
|
||||
<span class="value">{{ videoData.id }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">时长</span>
|
||||
<span class="value">{{ videoData.duration }}s</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">清晰度</span>
|
||||
<span class="value">{{ videoData.resolution }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">分类</span>
|
||||
<span class="value">{{ videoData.category }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="label">宽高比</span>
|
||||
<span class="value">{{ videoData.aspectRatio }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-section">
|
||||
<button class="create-similar-btn" @click="createSimilar">
|
||||
做同款
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
User as VideoPlay,
|
||||
User as VideoPause,
|
||||
User as FullScreen,
|
||||
User as Download,
|
||||
User as Delete,
|
||||
User,
|
||||
User as Compass,
|
||||
Document,
|
||||
Close,
|
||||
Loading,
|
||||
CircleClose
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getWorkDetail } from '@/api/userWorks'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 视频播放器相关
|
||||
const videoPlayer = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const progressPercent = ref(0)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
// 视频数据
|
||||
const videoData = ref({
|
||||
id: '',
|
||||
username: '',
|
||||
title: '',
|
||||
description: '',
|
||||
createTime: '',
|
||||
duration: '',
|
||||
resolution: '',
|
||||
category: '',
|
||||
aspectRatio: '',
|
||||
videoUrl: '',
|
||||
cover: ''
|
||||
})
|
||||
|
||||
// 处理URL,确保相对路径正确
|
||||
const processUrl = (url) => {
|
||||
if (!url) return null
|
||||
// data: 协议(Base64 图片/视频等)直接返回
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return url
|
||||
}
|
||||
return '/' + url
|
||||
}
|
||||
|
||||
// 获取视频数据
|
||||
const loadVideoData = async () => {
|
||||
const videoId = route.params.id
|
||||
if (!videoId) {
|
||||
error.value = t('common.missingVideoId')
|
||||
loading.value = false
|
||||
ElMessage.error(t('common.missingVideoId'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getWorkDetail(videoId)
|
||||
|
||||
if (response.data.success) {
|
||||
const work = response.data.data
|
||||
|
||||
// 转换数据格式
|
||||
const resultUrl = processUrl(work.resultUrl)
|
||||
const thumbnailUrl = processUrl(work.thumbnailUrl)
|
||||
|
||||
videoData.value = {
|
||||
id: work.taskId || work.id?.toString() || '',
|
||||
username: work.username || work.user?.username || t('common.unknownUser'),
|
||||
title: work.title || work.prompt || t('common.untitledWork'),
|
||||
description: work.prompt || work.description || t('common.noPrompt'),
|
||||
createTime: work.createdAt ? new Date(work.createdAt).toLocaleString('zh-CN') : '',
|
||||
date: work.createdAt ? new Date(work.createdAt).toLocaleDateString('zh-CN') : '',
|
||||
duration: work.duration || work.videoDuration || work.length || 5,
|
||||
resolution: work.quality || work.resolution || '1080p',
|
||||
category: work.workType === 'TEXT_TO_VIDEO' ? t('common.textToVideoCategory') :
|
||||
work.workType === 'IMAGE_TO_VIDEO' ? t('common.imageToVideoCategory') :
|
||||
work.workType === 'STORYBOARD_VIDEO' ? t('common.storyboardVideoCategory') : t('common.unknownCategory'),
|
||||
aspectRatio: work.aspectRatio || work.ratio || work.aspect || '16:9',
|
||||
videoUrl: resultUrl || thumbnailUrl || '/images/backgrounds/welcome.jpg',
|
||||
cover: thumbnailUrl || resultUrl || '/images/backgrounds/welcome.jpg'
|
||||
}
|
||||
|
||||
error.value = null
|
||||
} else {
|
||||
throw new Error(response.data.message || t('common.loadWorkDetailFailed'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载视频数据失败:', err)
|
||||
error.value = err.message || t('common.loadWorkDetailFailed')
|
||||
ElMessage.error(t('common.loadWorkDetailFailed') + ': ' + (err.message || ''))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 视频播放控制
|
||||
const togglePlay = () => {
|
||||
if (!videoPlayer.value) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoPlayer.value.pause()
|
||||
} else {
|
||||
videoPlayer.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
const onVideoLoaded = () => {
|
||||
duration.value = videoPlayer.value.duration
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
currentTime.value = videoPlayer.value.currentTime
|
||||
progressPercent.value = (currentTime.value / duration.value) * 100
|
||||
}
|
||||
|
||||
const onVideoEnded = () => {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
const seekTo = (event) => {
|
||||
if (!videoPlayer.value) return
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const clickX = event.clientX - rect.left
|
||||
const percentage = clickX / rect.width
|
||||
const newTime = percentage * duration.value
|
||||
|
||||
videoPlayer.value.currentTime = newTime
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!videoPlayer.value) return
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
videoPlayer.value.requestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 操作功能
|
||||
const downloadVideo = async () => {
|
||||
if (!videoData.value.videoUrl) {
|
||||
ElMessage.warning(t('common.videoLinkUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info(t('common.preparingDownload'))
|
||||
|
||||
// 获取视频文件
|
||||
const response = await fetch(videoData.value.videoUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${videoData.value.title || 'video'}_${Date.now()}.mp4`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success(t('common.downloadStarted'))
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
// 备用方案:直接打开链接
|
||||
window.open(videoData.value.videoUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteVideo = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('common.confirm'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel')
|
||||
})
|
||||
ElMessage.success(t('common.videoDeleted'))
|
||||
router.back()
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const createSimilar = () => {
|
||||
ElMessage.info(t('common.jumpToTextToVideo'))
|
||||
// router.push('/create-video')
|
||||
}
|
||||
|
||||
// 导航函数
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
const goToSubscription = () => {
|
||||
router.push('/subscription')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 监听视频播放状态变化
|
||||
const handlePlay = () => {
|
||||
isPlaying.value = true
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载视频数据
|
||||
await loadVideoData()
|
||||
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.addEventListener('play', handlePlay)
|
||||
videoPlayer.value.addEventListener('pause', handlePause)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (videoPlayer.value) {
|
||||
videoPlayer.value.removeEventListener('play', handlePlay)
|
||||
videoPlayer.value.removeEventListener('pause', handlePause)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-detail-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: #000000;
|
||||
padding: 24px 0;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 0 24px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.nav-item .el-icon {
|
||||
margin-right: 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
font-size: 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.error-container p {
|
||||
font-size: 16px;
|
||||
color: #f56c6c;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 左侧视频播放器区域 */
|
||||
.video-player-section {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* video overlay 样式已移除(不再使用 overlayText) */
|
||||
|
||||
/* 视频控制栏 */
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #409eff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 右上角操作按钮 */
|
||||
.video-actions {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
color: #333;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #fff;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 右侧详情侧边栏 */
|
||||
.detail-sidebar {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px 0 0 0;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #d1d5db;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.create-similar-btn {
|
||||
width: 100%;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.create-similar-btn:hover {
|
||||
background: #337ecc;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.create-similar-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.video-detail-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-player-section {
|
||||
flex: 1;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.detail-sidebar {
|
||||
flex: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.video-overlay {
|
||||
bottom: 60px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.overlay-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.detail-sidebar {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
312
frontend/src/views/Welcome.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="welcome-page">
|
||||
<!-- 导航栏 -->
|
||||
<header class="navbar">
|
||||
<div class="navbar-content">
|
||||
<div class="logo">
|
||||
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
<a href="#" class="nav-link" @click.prevent="goToTextToVideo">{{ $t('welcome.textToVideo') }}</a>
|
||||
<a href="#" class="nav-link" @click.prevent="goToImageToVideo">{{ $t('welcome.imageToVideo') }}</a>
|
||||
<a href="#" class="nav-link" @click.prevent="goToStoryboardVideo">{{ $t('welcome.storyboardVideo') }}</a>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
<LanguageSwitcher />
|
||||
<button class="nav-button" @click="goToLogin">{{ $t('welcome.startExperience') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="content">
|
||||
<h1 class="title">
|
||||
<span class="title-line">
|
||||
<span class="bright-text">智创</span><span class="gradient-text">无限,</span>
|
||||
</span>
|
||||
<span class="title-line">
|
||||
<span class="bright-text">灵感</span><span class="gradient-text">变现。</span>
|
||||
</span>
|
||||
</h1>
|
||||
<button class="main-button" @click="goToLogin">立即体验</button>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 跳转到登录页面
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 跳转到文生视频页面
|
||||
const goToTextToVideo = () => {
|
||||
router.push('/text-to-video/create')
|
||||
}
|
||||
|
||||
// 跳转到图生视频页面
|
||||
const goToImageToVideo = () => {
|
||||
router.push('/image-to-video/create')
|
||||
}
|
||||
|
||||
// 跳转到分镜视频页面
|
||||
const goToStoryboardVideo = () => {
|
||||
router.push('/storyboard-video/create')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义字体 */
|
||||
@font-face {
|
||||
font-family: 'Taipei Sans TC';
|
||||
src: url('/fonts/TaipeiSansTC.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.welcome-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: 'Taipei Sans TC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.welcome-page::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: url('/images/backgrounds/welcome_bg.jpg') center/cover no-repeat;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 95%;
|
||||
max-width: 1200px;
|
||||
background: rgba(26, 26, 46, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1000;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 35px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-left: auto;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: rgba(74, 158, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(74, 158, 255, 1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 主要内容 */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
padding-top: 80px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Taipei Sans TC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 6.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 60px;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 40px;
|
||||
font-weight: 400;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.title-line {
|
||||
display: block;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bright-text {
|
||||
color: white;
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 700;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.fade-text {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 700;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.main-button {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: none;
|
||||
padding: 22px 60px;
|
||||
border-radius: 50px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 6px 25px rgba(74, 158, 255, 0.3);
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
.main-button::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.main-button:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(74, 158, 255, 0.5);
|
||||
}
|
||||
|
||||
.main-button:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 背景光影效果已删除 */
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.title {
|
||||
font-size: 5.5rem;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.main-button {
|
||||
padding: 20px 50px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
.title {
|
||||
font-size: 4rem;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.main-button {
|
||||
padding: 18px 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.main-button {
|
||||
padding: 16px 35px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 全局样式 - 隐藏滚动条 */
|
||||
html:has(.welcome-page),
|
||||
body:has(.welcome-page) {
|
||||
overflow: hidden !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
101
frontend/vite.config.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
// 生产/开发环境配置
|
||||
base: process.env.NODE_ENV === 'production' ? '/' : '/',
|
||||
|
||||
// 开发服务器配置
|
||||
server: {
|
||||
port: 8081,
|
||||
host: '0.0.0.0', // 允许外部访问
|
||||
allowedHosts: true, // 允许所有主机访问
|
||||
proxy: {
|
||||
'/api': {
|
||||
// 开发时代理到本地后端(统一为 localhost:8080)
|
||||
target: process.env.VITE_APP_API_URL || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// 后端服务器路径已经包含 /api,所以不需要 rewrite
|
||||
// 前端请求 /api/xxx 会转发到 http://localhost:8080/api/xxx
|
||||
// 调试时将 cookie 域改写为 localhost
|
||||
cookieDomainRewrite: 'localhost',
|
||||
cookiePathRewrite: '/',
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on('error', (err, _req, _res) => {
|
||||
console.log('proxy error', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, _res) => {
|
||||
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
const setCookie = proxyRes.headers['set-cookie'];
|
||||
if (setCookie) {
|
||||
console.log('Proxy Set-Cookie:', setCookie);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// public 目录配置(确保字体文件等静态资源被复制)
|
||||
publicDir: 'public',
|
||||
|
||||
// 生产环境构建配置
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'static',
|
||||
copyPublicDir: true,
|
||||
// 使用 terser 压缩,移除 console
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true
|
||||
}
|
||||
},
|
||||
// 启用 CSS 代码分割
|
||||
cssCodeSplit: true,
|
||||
// 禁用 source map
|
||||
sourcemap: false,
|
||||
// 代码分割优化
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 为所有资源文件(包括SVG)生成内容哈希
|
||||
assetFileNames: (assetInfo) => {
|
||||
// 获取文件扩展名
|
||||
const extType = assetInfo.name.split('.').pop();
|
||||
// 图片类型(包括SVG)
|
||||
if (/png|jpe?g|gif|svg|webp|ico/i.test(extType)) {
|
||||
return `static/images/[name]-[hash][extname]`;
|
||||
}
|
||||
// 字体类型
|
||||
if (/woff2?|eot|ttf|otf/i.test(extType)) {
|
||||
return `static/fonts/[name]-[hash][extname]`;
|
||||
}
|
||||
// 其他资源
|
||||
return `static/[name]-[hash][extname]`;
|
||||
},
|
||||
// JS文件哈希
|
||||
chunkFileNames: 'static/js/[name]-[hash].js',
|
||||
entryFileNames: 'static/js/[name]-[hash].js',
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'element-plus': ['element-plus', '@element-plus/icons-vue'],
|
||||
'utils': ['axios']
|
||||
}
|
||||
}
|
||||
},
|
||||
// 块大小警告限制
|
||||
chunkSizeWarningLimit: 1000
|
||||
}
|
||||
})
|
||||
34
frontend/vue.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
|
||||
// 生产/开发环境配置
|
||||
publicPath: process.env.NODE_ENV === 'production' ? '/' : '/',
|
||||
outputDir: 'dist',
|
||||
assetsDir: 'static',
|
||||
|
||||
// 开发服务器配置
|
||||
devServer: {
|
||||
port: 8081,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// 开发时代理到本地后端(统一为 localhost:8080)
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
// 不要移除 /api 前缀,后端路由以 /api 开头
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 生产环境配置
|
||||
configureWebpack: {
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
475
init_database.sql
Normal file
@@ -0,0 +1,475 @@
|
||||
-- ============================================
|
||||
-- 宝塔面板数据库初始化SQL文件
|
||||
-- 适用于MySQL/MariaDB数据库
|
||||
-- ============================================
|
||||
|
||||
-- 创建数据库(如果不存在,请根据实际情况修改数据库名)
|
||||
-- CREATE DATABASE IF NOT EXISTS `aigc_db` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- USE `aigc_db`;
|
||||
|
||||
-- ============================================
|
||||
-- 基础表结构
|
||||
-- ============================================
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(100) NOT NULL,
|
||||
role VARCHAR(30) NOT NULL DEFAULT 'ROLE_USER',
|
||||
points INT NOT NULL DEFAULT 50,
|
||||
frozen_points INT NOT NULL DEFAULT 0 COMMENT '冻结积分',
|
||||
phone VARCHAR(20),
|
||||
avatar TEXT COMMENT '头像URL',
|
||||
nickname VARCHAR(100),
|
||||
gender VARCHAR(10),
|
||||
birthday DATE,
|
||||
address TEXT,
|
||||
bio TEXT COMMENT '个人简介',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_login_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_role (role),
|
||||
INDEX idx_is_active (is_active),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- 支付表
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_id VARCHAR(50) NOT NULL COMMENT '订单号(不唯一,一个订单可以有多次支付尝试)',
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
|
||||
payment_method VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '支付状态:PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED',
|
||||
description VARCHAR(500),
|
||||
external_transaction_id VARCHAR(100) COMMENT '外部交易号(支付宝交易号等)',
|
||||
callback_url VARCHAR(1000),
|
||||
return_url VARCHAR(1000),
|
||||
payment_url VARCHAR(2000) COMMENT '支付跳转URL',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
paid_at TIMESTAMP NULL,
|
||||
user_id BIGINT COMMENT '用户ID',
|
||||
order_id_ref BIGINT COMMENT '关联的订单ID(外键)',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (order_id_ref) REFERENCES orders(id) ON DELETE SET NULL,
|
||||
INDEX idx_order_id (order_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_payment_method (payment_method),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付表';
|
||||
|
||||
-- 订单表
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_number VARCHAR(50) NOT NULL UNIQUE,
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
order_type VARCHAR(20) NOT NULL DEFAULT 'PRODUCT',
|
||||
description VARCHAR(500),
|
||||
notes TEXT,
|
||||
shipping_address TEXT,
|
||||
billing_address TEXT,
|
||||
contact_phone VARCHAR(20),
|
||||
contact_email VARCHAR(100),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
paid_at TIMESTAMP NULL,
|
||||
shipped_at TIMESTAMP NULL,
|
||||
delivered_at TIMESTAMP NULL,
|
||||
cancelled_at TIMESTAMP NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
INDEX idx_order_number (order_number),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';
|
||||
|
||||
-- 订单项表
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
product_name VARCHAR(100) NOT NULL,
|
||||
product_description VARCHAR(500),
|
||||
product_sku VARCHAR(200),
|
||||
unit_price DECIMAL(10,2) NOT NULL,
|
||||
quantity INT NOT NULL,
|
||||
subtotal DECIMAL(10,2) NOT NULL,
|
||||
product_image VARCHAR(100),
|
||||
order_id BIGINT NOT NULL,
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||
INDEX idx_order_id (order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单项表';
|
||||
|
||||
-- 会员等级表
|
||||
CREATE TABLE IF NOT EXISTS membership_levels (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(50) NOT NULL UNIQUE COMMENT '等级名称(内部标识)',
|
||||
display_name VARCHAR(50) NOT NULL COMMENT '显示名称',
|
||||
description TEXT COMMENT '描述',
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格(元)',
|
||||
duration_days INT NOT NULL DEFAULT 30 COMMENT '时长(天)',
|
||||
points_bonus INT NOT NULL DEFAULT 0 COMMENT '积分奖励(购买会员后获得的资源点数量)',
|
||||
features JSON COMMENT '功能特性(JSON格式)',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_name (name),
|
||||
INDEX idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员等级表';
|
||||
|
||||
-- 用户会员信息表
|
||||
CREATE TABLE IF NOT EXISTS user_memberships (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
membership_level_id BIGINT NOT NULL,
|
||||
start_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
end_date TIMESTAMP NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (membership_level_id) REFERENCES membership_levels(id),
|
||||
UNIQUE KEY unique_active_membership (user_id, status),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_end_date (end_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会员信息表';
|
||||
|
||||
-- 系统设置表(用于存储系统级别的设置)
|
||||
-- 注意:套餐价格已移至 membership_levels 表管理
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
points_per_generation INT NOT NULL DEFAULT 1 COMMENT '每次生成消耗的资源点',
|
||||
site_name VARCHAR(100) NOT NULL DEFAULT 'AIGC Demo' COMMENT '站点名称',
|
||||
site_subtitle VARCHAR(150) NOT NULL DEFAULT '现代化的Spring Boot应用演示' COMMENT '站点副标题',
|
||||
registration_open BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否开放注册',
|
||||
maintenance_mode BOOLEAN NOT NULL DEFAULT FALSE COMMENT '维护模式',
|
||||
enable_alipay BOOLEAN NOT NULL DEFAULT TRUE COMMENT '启用支付宝',
|
||||
enable_paypal BOOLEAN NOT NULL DEFAULT TRUE COMMENT '启用PayPal',
|
||||
contact_email VARCHAR(120) DEFAULT 'support@example.com' COMMENT '联系邮箱',
|
||||
prompt_optimization_model VARCHAR(50) DEFAULT 'gpt-5.1-thinking' COMMENT '优化提示词使用的模型',
|
||||
storyboard_system_prompt VARCHAR(2000) DEFAULT '' COMMENT '分镜图生成系统引导词',
|
||||
token_expire_hours INT NOT NULL DEFAULT 720 COMMENT 'Token过期时间(小时)'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统设置表';
|
||||
|
||||
-- 用户活跃度统计表
|
||||
CREATE TABLE IF NOT EXISTS user_activity_stats (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '统计ID',
|
||||
activity_date DATE NOT NULL UNIQUE COMMENT '统计日期',
|
||||
daily_active_users INT NOT NULL DEFAULT 0 COMMENT '日活用户数',
|
||||
monthly_active_users INT NOT NULL DEFAULT 0 COMMENT '月活用户数',
|
||||
new_users INT NOT NULL DEFAULT 0 COMMENT '新增用户数',
|
||||
returning_users INT NOT NULL DEFAULT 0 COMMENT '回访用户数',
|
||||
session_count INT NOT NULL DEFAULT 0 COMMENT '会话数',
|
||||
avg_session_duration DECIMAL(10,2) DEFAULT 0 COMMENT '平均会话时长(分钟)',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_activity_date (activity_date),
|
||||
INDEX idx_activity_date (activity_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户活跃度统计表';
|
||||
|
||||
-- 文生视频任务表
|
||||
CREATE TABLE IF NOT EXISTS text_to_video_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务唯一标识',
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
prompt TEXT COMMENT '文本描述/提示词',
|
||||
aspect_ratio VARCHAR(10) NOT NULL DEFAULT '16:9' COMMENT '宽高比:16:9, 4:3, 1:1, 3:4, 9:16',
|
||||
duration INT NOT NULL DEFAULT 5 COMMENT '视频时长(秒):5, 10, 15, 30',
|
||||
hd_mode BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否高清模式',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED',
|
||||
progress INT NOT NULL DEFAULT 0 COMMENT '进度(0-100)',
|
||||
result_url TEXT COMMENT '结果视频URL',
|
||||
real_task_id VARCHAR(100) COMMENT '外部API返回的真实任务ID',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
cost_points INT NOT NULL DEFAULT 0 COMMENT '消耗积分',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_real_task_id (real_task_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文生视频任务表';
|
||||
|
||||
-- 图生视频任务表
|
||||
CREATE TABLE IF NOT EXISTS image_to_video_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务唯一标识',
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
first_frame_url VARCHAR(500) NOT NULL COMMENT '首帧图片URL',
|
||||
last_frame_url VARCHAR(500) COMMENT '末帧图片URL',
|
||||
prompt TEXT COMMENT '文本描述/提示词',
|
||||
aspect_ratio VARCHAR(10) NOT NULL DEFAULT '16:9' COMMENT '宽高比:16:9, 4:3, 1:1, 3:4, 9:16',
|
||||
duration INT NOT NULL DEFAULT 5 COMMENT '视频时长(秒):5, 10, 15, 30',
|
||||
hd_mode BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否高清模式',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED',
|
||||
progress INT NOT NULL DEFAULT 0 COMMENT '进度(0-100)',
|
||||
result_url TEXT COMMENT '结果视频URL',
|
||||
real_task_id VARCHAR(100) COMMENT '外部API返回的真实任务ID',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
cost_points INT NOT NULL DEFAULT 0 COMMENT '消耗积分',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_real_task_id (real_task_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='图生视频任务表';
|
||||
|
||||
-- ============================================
|
||||
-- 任务队列相关表
|
||||
-- ============================================
|
||||
|
||||
-- 任务队列表
|
||||
CREATE TABLE IF NOT EXISTS task_queue (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
|
||||
task_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO', 'STORYBOARD_VIDEO') NOT NULL COMMENT '任务类型',
|
||||
status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT') NOT NULL DEFAULT 'PENDING' COMMENT '队列状态',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '优先级,数字越小优先级越高',
|
||||
real_task_id VARCHAR(100) COMMENT '外部API返回的真实任务ID',
|
||||
last_check_time DATETIME COMMENT '最后一次检查时间',
|
||||
check_count INT NOT NULL DEFAULT 0 COMMENT '检查次数',
|
||||
max_check_count INT NOT NULL DEFAULT 30 COMMENT '最大检查次数(30次 * 2分钟 = 60分钟)',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
|
||||
INDEX idx_username_status (username, status),
|
||||
INDEX idx_status_priority (status, priority),
|
||||
INDEX idx_last_check_time (last_check_time),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务队列表';
|
||||
|
||||
-- 积分冻结记录表
|
||||
CREATE TABLE IF NOT EXISTS points_freeze_records (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
|
||||
task_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO', 'STORYBOARD_VIDEO') NOT NULL COMMENT '任务类型',
|
||||
freeze_points INT NOT NULL COMMENT '冻结的积分数量',
|
||||
status ENUM('FROZEN', 'DEDUCTED', 'RETURNED', 'EXPIRED') NOT NULL DEFAULT 'FROZEN' COMMENT '冻结状态',
|
||||
freeze_reason VARCHAR(200) COMMENT '冻结原因',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
|
||||
INDEX idx_username_status (username, status),
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分冻结记录表';
|
||||
|
||||
-- 用户作品表
|
||||
CREATE TABLE IF NOT EXISTS user_works (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
|
||||
work_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO', 'STORYBOARD_VIDEO') NOT NULL COMMENT '作品类型',
|
||||
title VARCHAR(200) COMMENT '作品标题',
|
||||
description TEXT COMMENT '作品描述',
|
||||
prompt TEXT COMMENT '生成提示词',
|
||||
result_url VARCHAR(500) COMMENT '结果视频URL',
|
||||
thumbnail_url VARCHAR(500) COMMENT '缩略图URL',
|
||||
duration VARCHAR(10) COMMENT '视频时长',
|
||||
aspect_ratio VARCHAR(10) COMMENT '宽高比',
|
||||
quality VARCHAR(20) COMMENT '画质',
|
||||
file_size VARCHAR(20) COMMENT '文件大小',
|
||||
points_cost INT NOT NULL DEFAULT 0 COMMENT '消耗积分',
|
||||
status ENUM('PROCESSING', 'COMPLETED', 'FAILED', 'DELETED') NOT NULL DEFAULT 'PROCESSING' COMMENT '作品状态',
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否公开',
|
||||
view_count INT NOT NULL DEFAULT 0 COMMENT '浏览次数',
|
||||
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞次数',
|
||||
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
|
||||
tags VARCHAR(500) COMMENT '标签',
|
||||
uploaded_images LONGTEXT COMMENT '用户上传的参考图片(JSON数组),用于做同款功能恢复',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
|
||||
INDEX idx_username_status (username, status),
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_work_type (work_type),
|
||||
INDEX idx_is_public_status (is_public, status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_view_count (view_count),
|
||||
INDEX idx_like_count (like_count),
|
||||
INDEX idx_tags (tags),
|
||||
INDEX idx_prompt (prompt(100))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户作品表';
|
||||
|
||||
-- 任务状态表
|
||||
CREATE TABLE IF NOT EXISTS task_status (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(255) NOT NULL COMMENT '任务ID',
|
||||
username VARCHAR(255) NOT NULL COMMENT '用户名',
|
||||
task_type VARCHAR(50) NOT NULL COMMENT '任务类型',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
|
||||
progress INT DEFAULT 0 COMMENT '进度百分比',
|
||||
result_url TEXT COMMENT '结果URL',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
external_task_id VARCHAR(255) COMMENT '外部任务ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
last_polled_at TIMESTAMP NULL COMMENT '最后轮询时间',
|
||||
poll_count INT DEFAULT 0 COMMENT '轮询次数',
|
||||
max_polls INT DEFAULT 60 COMMENT '最大轮询次数(2小时)',
|
||||
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_last_polled (last_polled_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务状态表';
|
||||
|
||||
-- 成功任务归档表
|
||||
CREATE TABLE IF NOT EXISTS completed_tasks_archive (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
task_type VARCHAR(50) NOT NULL,
|
||||
prompt TEXT,
|
||||
aspect_ratio VARCHAR(20),
|
||||
duration INT,
|
||||
hd_mode BOOLEAN DEFAULT FALSE,
|
||||
result_url TEXT,
|
||||
real_task_id VARCHAR(255),
|
||||
progress INT DEFAULT 100,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
completed_at TIMESTAMP NOT NULL,
|
||||
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
points_cost INT DEFAULT 0,
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_task_type (task_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_completed_at (completed_at),
|
||||
INDEX idx_archived_at (archived_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成功任务归档表';
|
||||
|
||||
-- 失败任务清理日志表
|
||||
CREATE TABLE IF NOT EXISTS failed_tasks_cleanup_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
task_type VARCHAR(50) NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
failed_at TIMESTAMP NOT NULL,
|
||||
cleaned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_task_type (task_type),
|
||||
INDEX idx_cleaned_at (cleaned_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='失败任务清理日志表';
|
||||
|
||||
-- 分镜视频任务表
|
||||
CREATE TABLE IF NOT EXISTS storyboard_video_tasks (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
prompt TEXT COMMENT '文本描述/提示词',
|
||||
image_url TEXT COMMENT '上传的参考图片URL(可选)',
|
||||
aspect_ratio VARCHAR(10) NOT NULL COMMENT '宽高比:16:9, 4:3, 1:1, 3:4, 9:16',
|
||||
hd_mode BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否高清模式',
|
||||
status VARCHAR(20) NOT NULL COMMENT '任务状态:PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED',
|
||||
progress INT NOT NULL DEFAULT 0 COMMENT '进度(0-100)',
|
||||
result_url LONGTEXT COMMENT '分镜图URL(Base64编码的图片,可能非常大)- 网格图',
|
||||
storyboard_images LONGTEXT COMMENT '单独的分镜图片(JSON数组,每张图片为Base64格式,带data URI前缀)',
|
||||
real_task_id VARCHAR(255) COMMENT '外部API返回的真实任务ID',
|
||||
video_task_ids TEXT COMMENT '多个视频任务ID(JSON数组,每张图片对应一个视频任务)',
|
||||
video_urls LONGTEXT COMMENT '多个视频URL(JSON数组,用于拼接)',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
cost_points INT NOT NULL DEFAULT 0 COMMENT '消耗积分',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分镜视频任务表';
|
||||
|
||||
-- ============================================
|
||||
-- 初始化完成
|
||||
-- ============================================
|
||||
-- 所有表结构已创建完成
|
||||
-- 注意:此文件仅创建表结构,不包含初始数据
|
||||
-- 如需初始化数据,请通过系统管理界面或API进行
|
||||
--
|
||||
-- 宝塔面板使用说明:
|
||||
-- 1. 登录宝塔面板,进入"数据库" -> "phpMyAdmin"
|
||||
-- 2. 选择或创建数据库(建议数据库名:aigc_platform)
|
||||
-- 3. 点击"导入" -> 选择此SQL文件 -> 执行
|
||||
-- 4. 或者使用命令行:mysql -u用户名 -p数据库名 < init_database.sql
|
||||
--
|
||||
-- 数据库配置建议:
|
||||
-- - 数据库名:aigc_platform
|
||||
-- - 字符集:utf8mb4
|
||||
-- - 排序规则:utf8mb4_unicode_ci
|
||||
|
||||
-- ============================================
|
||||
-- 超级管理员权限设置
|
||||
-- ============================================
|
||||
-- 将 shanghairuiyi2026@163.com 设置为超级管理员
|
||||
-- 如果该用户存在,则更新其角色为超级管理员
|
||||
UPDATE users
|
||||
SET role = 'ROLE_SUPER_ADMIN',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE email = 'shanghairuiyi2026@163.com';
|
||||
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 分镜视频任务表字段更新
|
||||
-- ============================================
|
||||
-- 添加视频阶段参考图字段(与分镜图阶段的参考图分开存储)
|
||||
ALTER TABLE storyboard_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS video_reference_images LONGTEXT COMMENT '视频阶段用户上传的参考图片(JSON数组)- 生成视频时使用'
|
||||
AFTER uploaded_images;
|
||||
|
||||
-- 如果 uploaded_images 字段不存在,也添加它
|
||||
ALTER TABLE storyboard_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS uploaded_images LONGTEXT COMMENT '用户上传的多张参考图片(JSON数组)- 生成分镜图时使用'
|
||||
AFTER image_model;
|
||||
|
||||
-- 如果 image_model 字段不存在,添加它
|
||||
ALTER TABLE storyboard_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS image_model VARCHAR(50) DEFAULT 'nano-banana-2' COMMENT '图像生成模型'
|
||||
AFTER hd_mode;
|
||||
|
||||
-- 如果 duration 字段不存在,添加它
|
||||
ALTER TABLE storyboard_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS duration INT DEFAULT 10 COMMENT '视频时长(秒)'
|
||||
AFTER hd_mode;
|
||||
|
||||
-- ============================================
|
||||
-- Sora 2 模型集成 - 新增 video_model 字段
|
||||
-- ============================================
|
||||
ALTER TABLE text_to_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS video_model VARCHAR(50) DEFAULT 'grok-video-3' COMMENT '视频生成模型';
|
||||
|
||||
ALTER TABLE image_to_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS video_model VARCHAR(50) DEFAULT 'grok-video-3' COMMENT '视频生成模型';
|
||||
|
||||
ALTER TABLE storyboard_video_tasks
|
||||
ADD COLUMN IF NOT EXISTS video_model VARCHAR(50) DEFAULT 'grok-video-3' COMMENT '视频生成模型';
|
||||
|
||||
ALTER TABLE user_works
|
||||
ADD COLUMN IF NOT EXISTS video_model VARCHAR(50) DEFAULT 'grok-video-3' COMMENT '视频生成模型';
|
||||
|
||||
ALTER TABLE system_settings
|
||||
ADD COLUMN IF NOT EXISTS video_model VARCHAR(50) DEFAULT 'grok-video-3' COMMENT '视频生成模型';
|
||||
229
pom.xml
Normal file
@@ -0,0 +1,229 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>demo</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>demo</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<tomcat.version>10.1.34</tomcat.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Thymeleaf for server-side templates -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Security for login -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA for persistence -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Bean Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- In-memory H2 database -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL JDBC driver -->
|
||||
<dependency>
|
||||
<groupId>mysql</groupId>
|
||||
<artifactId>mysql-connector-java</artifactId>
|
||||
<version>8.0.33</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- IJPay 支付模块 -->
|
||||
<dependency>
|
||||
<groupId>com.github.javen205</groupId>
|
||||
<artifactId>IJPay-AliPay</artifactId>
|
||||
<version>2.9.12.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.javen205</groupId>
|
||||
<artifactId>IJPay-PayPal</artifactId>
|
||||
<version>2.9.12.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Servlet API -->
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<version>4.0.1</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT支持 -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis 缓存支持 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PayPal SDK -->
|
||||
<dependency>
|
||||
<groupId>com.paypal.sdk</groupId>
|
||||
<artifactId>rest-api-sdk</artifactId>
|
||||
<version>1.14.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON处理 -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Hibernate Jackson支持 -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-hibernate5-jakarta</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- HTTP客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Unirest HTTP客户端 -->
|
||||
<dependency>
|
||||
<groupId>com.konghq</groupId>
|
||||
<artifactId>unirest-java</artifactId>
|
||||
<version>3.14.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JCodec - Java视频处理库 -->
|
||||
<dependency>
|
||||
<groupId>org.jcodec</groupId>
|
||||
<artifactId>jcodec</artifactId>
|
||||
<version>0.2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jcodec</groupId>
|
||||
<artifactId>jcodec-javase</artifactId>
|
||||
<version>0.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 腾讯云SDK -->
|
||||
<dependency>
|
||||
<groupId>com.tencentcloudapi</groupId>
|
||||
<artifactId>tencentcloud-sdk-java</artifactId>
|
||||
<version>3.1.880</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 腾讯云COS对象存储SDK -->
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos_api</artifactId>
|
||||
<version>5.6.155</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SpringDoc OpenAPI (Swagger) - 升级到与Spring Boot 3.5.x兼容的版本 -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis支持 (可选,当前使用内存存储) -->
|
||||
<!--
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
-->
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- 缓存支持 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<!-- Caffeine 高性能本地缓存 -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
30
src/main/java/com/example/demo/DemoApplication.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.example.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication(exclude = {RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class})
|
||||
@EnableScheduling
|
||||
public class DemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 在应用启动时立即设置HTTP超时时间(支付宝API调用可能需要更长时间)
|
||||
// 连接超时:30秒,读取超时:120秒
|
||||
// 必须在SpringApplication.run()之前设置,确保在所有HTTP客户端创建之前生效
|
||||
System.setProperty("sun.net.client.defaultConnectTimeout", "30000");
|
||||
System.setProperty("sun.net.client.defaultReadTimeout", "120000");
|
||||
|
||||
// 增加HTTP缓冲区大小以支持大请求体(Base64编码的图片可能很大)
|
||||
// 设置Socket缓冲区大小为10MB
|
||||
System.setProperty("java.net.preferIPv4Stack", "true");
|
||||
// Apache HttpClient 使用系统属性
|
||||
System.setProperty("org.apache.http.client.connection.timeout", "30000");
|
||||
System.setProperty("org.apache.http.socket.timeout", "300000");
|
||||
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
46
src/main/java/com/example/demo/config/AsyncConfig.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* 异步执行器配置
|
||||
* 支持100-200人并发处理异步任务(如视频生成、图片处理等)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* 配置异步任务执行器
|
||||
* 核心线程数:5,最大线程数:20,队列容量:50
|
||||
* 可支持50人并发,每个用户最多3个任务,共150个任务
|
||||
* 大部分任务在队列中等待,实际并发执行的任务数量受线程池限制
|
||||
*/
|
||||
@Bean(name = "taskExecutor")
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
// 核心线程数:保持活跃的最小线程数
|
||||
executor.setCorePoolSize(10);
|
||||
// 最大线程数:最大并发执行的任务数
|
||||
executor.setMaxPoolSize(40);
|
||||
// 队列容量:等待执行的任务数
|
||||
executor.setQueueCapacity(100);
|
||||
// 线程名前缀
|
||||
executor.setThreadNamePrefix("async-task-");
|
||||
// 拒绝策略:当线程池和队列都满时,使用调用者线程执行(保证任务不丢失)
|
||||
executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
|
||||
// 等待所有任务完成后再关闭线程池
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
// 等待时间(秒)
|
||||
executor.setAwaitTerminationSeconds(60);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||