Initial commit: 添加项目代码

This commit is contained in:
2026-02-13 18:24:52 +08:00
commit 05d3cc539d
303 changed files with 97922 additions and 0 deletions

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# 依赖目录
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
# 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

View File

@@ -0,0 +1,71 @@
# 作品加载慢问题分析与优化方案
## 问题分析
通过分析MyWorks.vue文件的代码我发现了以下可能导致作品加载慢的原因
### 1. 视频加载方式不当
- **直接加载完整视频文件**:使用`<video>`标签直接加载视频文件,没有使用缩略图
- **缺少延迟加载**:所有视频同时开始加载,即使不在可视区域内
- **视频元数据处理**:每个视频加载后都会触发额外的元数据处理和第一帧跳转
### 2. 图片加载优化不足
- **懒加载实现可能不完善**:虽然使用了`v-lazy:loading`指令,但可能配置不够优化
- **图片尺寸和格式问题**:没有看到图片压缩或格式优化的处理
### 3. API调用策略问题
- **一次性加载过多数据**页面大小设置为100个作品(`pageSize = 100`)
- **缺少分页和增量加载**:没有实现真正的分页加载机制
### 4. 数据处理开销大
- **复杂的数据转换**:每个作品都需要经过`transformWorkData`函数处理
- **实时筛选计算**使用computed属性进行复杂的筛选每次数据变化都会重新计算
### 5. 其他潜在问题
- **轮询机制**:处理中的任务会启动轮询,可能增加页面负担
- **URL处理**每个作品的URL都需要额外处理
- **DOM元素过多**一次性渲染100个作品卡片DOM元素数量庞大
## 优化方案
### 1. 视频加载优化
- **实现视频缩略图**:为每个视频生成并使用缩略图,只在用户点击时加载完整视频
- **视频懒加载**使用Intersection Observer实现视频的延迟加载
- **优化视频元数据处理**:减少不必要的元数据处理操作
### 2. 图片加载优化
- **完善图片懒加载**:确保懒加载正确配置,只加载可视区域内的图片
- **图片尺寸优化**实现图片CDN和响应式图片尺寸
- **图片格式优化**使用WebP等现代图片格式
### 3. API调用优化
- **减少每页加载数量**:将`pageSize`减小到20-30个作品
- **实现虚拟滚动**只渲染可视区域内的作品大幅减少DOM元素
- **增量加载**:滚动到底部时再加载更多作品
### 4. 数据处理优化
- **优化数据转换**:减少数据转换的复杂度,考虑在后端处理部分转换
- **缓存筛选结果**:使用缓存存储筛选计算结果,避免重复计算
- **减少实时计算**将部分computed属性改为方法只在需要时计算
### 5. 其他优化措施
- **优化轮询机制**:确保轮询不会影响页面性能
- **URL处理优化**缓存URL处理结果避免重复处理
- **组件拆分**:将作品卡片拆分为独立组件,提高渲染性能
- **使用keep-alive**:缓存不活跃的组件,提高切换性能
## 预期效果
通过以上优化,预计可以:
1. **减少初始加载时间**从当前的数秒减少到1秒以内
2. **提高滚动流畅度**:实现平滑的滚动体验
3. **减少内存使用**:降低浏览器内存占用
4. **改善用户体验**:让页面加载和交互更加响应迅速
## 实施步骤
1. **视频和图片加载优化**:实现缩略图和懒加载
2. **API和数据处理优化**:调整分页策略和数据处理逻辑
3. **DOM优化**实现虚拟滚动减少DOM元素
4. **性能监控**:添加性能监控,验证优化效果
5. **用户测试**:进行用户测试,收集反馈并进一步优化

934
aigc_platform_backup.sql Normal file

File diff suppressed because one or more lines are too long

2
demo/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

82
demo/.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### 敏感配置文件 ###
# 开发环境配置(包含敏感信息)
src/main/resources/application-dev.properties
# 生产环境配置(包含敏感信息)
src/main/resources/application-prod.properties
# PayPal配置文件
application.properties.paypal-config
# 环境变量文件
.env
*.env
config/.env
### 上传文件和临时文件 ###
uploads/
temp/
logs/
*.log
### 构建产物 ###
target/
frontend/dist/
frontend/node_modules/
### 数据库相关 ###
*.sql.backup
*.db
*.sqlite
### IDE和编辑器 ###
.idea/
*.iml
*.ipr
*.iws
.DS_Store
Thumbs.db
### 测试文件 ###
test_*.py
test_*.java
test_*.html
test_*.sh
test_*.bat
*.test.*
### 备份文件 ###
*.backup
*.bak
*~

39
demo/DraftUrlTest.java Normal file
View File

@@ -0,0 +1,39 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
public class DraftUrlTest {
private static final ObjectMapper objectMapper = new ObjectMapper();
public static String extractDraftUrl(String jsonString) {
if (jsonString == null || jsonString.isEmpty()) {
return null;
}
try {
JsonNode rootNode = objectMapper.readTree(jsonString);
JsonNode outputNode = rootNode.get("Output");
if (outputNode == null || !outputNode.isObject()) {
return null;
}
JsonNode draftUrlNode = outputNode.get("output");
if (draftUrlNode == null || !draftUrlNode.isTextual()) {
return null;
}
String draftUrl = draftUrlNode.asText();
draftUrl = draftUrl.replaceAll("\\\"", "");
return draftUrl;
} catch (IOException e) {
return null;
}
}
public static void main(String[] args) {
String testJson = "{\"node_status\":\"0\",\"Output\":{\"output\":\"https://www.1818ai.com/api/v1/drafts/download_draft?draft_id=040ac6aa-a786-4fa5-8dee-506ba1d6c35d\"}}";
String draftUrl = extractDraftUrl(testJson);
System.out.println("提取的draft_url: " + draftUrl);
System.out.println("测试结果: " + (draftUrl != null ? "成功" : "失败"));
}
}

View 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

View 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

View 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

View 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凭证

View 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

View File

@@ -0,0 +1,3 @@
# 开发环境配置Vue CLI
VUE_APP_API_URL=http://localhost:8080/api
NODE_ENV=development

View File

@@ -0,0 +1,3 @@
# 开发环境配置Vite - 项目当前使用)
VITE_APP_API_URL=http://localhost:8080/api
NODE_ENV=development

View File

@@ -0,0 +1,2 @@
VUE_APP_API_URL=/api
NODE_ENV=production

View File

@@ -0,0 +1,4 @@
# Vite 环境变量(项目当前使用 Vite
# Vite 使用 VITE_ 前缀,而不是 VUE_APP_
VITE_APP_API_URL=/api
NODE_ENV=production

View 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
demo/frontend/index.html Normal file
View 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>

2400
demo/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.5.0",
"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"
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

363
demo/frontend/src/App.vue Normal file
View File

@@ -0,0 +1,363 @@
<template>
<a-config-provider :locale="antdLocale">
<div id="app" :data-route="route.name" :class="{ 'is-admin': isAdminRoute }">
<!-- 全局科技感背景 -->
<div class="app-background">
<div class="bg-gradient"></div>
<div class="bg-grid"></div>
<div class="bg-glow bg-glow--1"></div>
<div class="bg-glow bg-glow--2"></div>
</div>
<!-- 主要内容区域 -->
<main class="app-main">
<router-view v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</a-config-provider>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import enUS from 'ant-design-vue/es/locale/en_US'
const route = useRoute()
const { locale } = useI18n()
// 动态计算 Ant Design Vue 的语言配置
const antdLocale = computed(() => {
return locale.value === 'zh' ? zhCN : enUS
})
// 判断当前是否为后台管理路由
const isAdminRoute = computed(() => !!route.meta?.requiresAdmin)
// 后台管理页面时给 body 加 admin-mode 类,让 Teleport 到 body 的弹窗也能应用暗色主题
watch(isAdminRoute, (isAdmin) => {
if (isAdmin) {
document.body.classList.add('admin-mode')
document.documentElement.classList.add('admin-mode')
} else {
document.body.classList.remove('admin-mode')
document.documentElement.classList.remove('admin-mode')
}
}, { immediate: true })
</script>
<style>
/* ============================================================
Global Reset & Base Styles
Uses UI-UX-Pro-Max Design System Variables
============================================================ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
width: 100%;
overflow: hidden;
background: var(--bg-root);
}
/* Admin 模式下 html 也要暗色 */
html.admin-mode {
background: #07090F !important;
}
body {
height: 100%;
width: 100%;
overflow: hidden;
background: var(--bg-root);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ============================================================
App Container
============================================================ */
#app {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
/* ============================================================
Background Layer - 统一科技感背景
============================================================ */
.app-background {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.bg-gradient {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(0, 212, 255, 0.06) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(139, 92, 246, 0.04) 0%, transparent 60%),
var(--bg-root);
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
background-size: 60px 60px;
mask-image: radial-gradient(ellipse 70% 50% at 50% 50%, black 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 50% at 50% 50%, black 30%, transparent 80%);
}
.bg-glow {
position: absolute;
border-radius: 50%;
filter: blur(100px);
animation: breathe 8s ease-in-out infinite;
}
.bg-glow--1 {
width: 600px;
height: 400px;
top: -10%;
right: -5%;
background: rgba(8, 145, 178, 0.06);
}
.bg-glow--2 {
width: 500px;
height: 350px;
bottom: -10%;
left: -5%;
background: rgba(139, 92, 246, 0.05);
animation-delay: -4s;
}
/* 欢迎页和登录页使用自己的背景,隐藏全局背景 */
#app[data-route="Welcome"] .app-background,
#app[data-route="Login"] .app-background {
display: none;
}
/* ============================================================
Admin 后台页面 — 隐藏亮色背景层,使用暗色主题
============================================================ */
#app.is-admin .app-background {
display: none !important;
}
#app.is-admin {
background: #07090F !important;
}
/* ============================================================
Main Content
============================================================ */
.app-main {
flex: 1;
position: relative;
z-index: var(--z-base);
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}
/* ============================================================
Page Transition
============================================================ */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.2s var(--ease-default);
}
.page-fade-enter-from,
.page-fade-leave-to {
opacity: 0;
}
/* ============================================================
Ant Design Vue Overrides - Global Dark Theme
============================================================ */
/* Buttons */
.ant-btn {
font-family: var(--font-sans);
border-radius: var(--radius-md);
transition: var(--transition-all);
}
.ant-btn-primary {
background: var(--primary-500);
border-color: var(--primary-500);
box-shadow: none;
}
.ant-btn-primary:hover {
background: var(--primary-400);
border-color: var(--primary-400);
box-shadow: var(--glow-primary);
}
/* Inputs */
.ant-input,
.ant-input-affix-wrapper {
font-family: var(--font-sans);
background: var(--bg-surface) !important;
border: 1px solid var(--border-default) !important;
border-radius: var(--radius-md) !important;
color: var(--text-primary) !important;
transition: var(--transition-all);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover {
border-color: var(--border-strong) !important;
}
.ant-input:focus,
.ant-input-affix-wrapper-focused {
border-color: var(--primary-500) !important;
box-shadow: 0 0 0 2px var(--primary-glow-light) !important;
}
.ant-input::placeholder {
color: var(--text-tertiary) !important;
}
/* Select */
.ant-select-selector {
background: var(--bg-surface) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
/* Modal */
.ant-modal-content {
background: var(--bg-overlay) !important;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl) !important;
}
.ant-modal-header {
background: transparent !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.ant-modal-title {
color: var(--text-primary) !important;
}
.ant-modal-body {
color: var(--text-primary);
}
.ant-modal-close {
color: var(--text-secondary) !important;
}
.ant-modal-close:hover {
color: var(--text-primary) !important;
}
/* Message / Notification */
.ant-message-notice-content {
background: var(--bg-overlay) !important;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg) !important;
color: var(--text-primary);
box-shadow: var(--shadow-lg);
}
/* Dropdown */
.ant-dropdown-menu {
background: var(--bg-overlay) !important;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg);
}
.ant-dropdown-menu-item {
color: var(--text-primary) !important;
}
.ant-dropdown-menu-item:hover {
background: var(--bg-hover) !important;
}
/* Switch */
.ant-switch {
background: var(--bg-elevated);
}
.ant-switch-checked {
background: var(--primary-500) !important;
}
/* Tag */
.ant-tag {
border-radius: var(--radius-sm);
}
/* ============================================================
Payment Modal Specific Override
============================================================ */
.payment-modal-dialog .ant-modal-content {
background: var(--bg-base) !important;
border: 1px solid var(--border-default) !important;
box-shadow: var(--shadow-xl) !important;
}
.payment-modal-dialog .ant-modal-header {
background: transparent !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.payment-modal-dialog .ant-modal-body {
background: transparent !important;
}
/* ============================================================
Responsive
============================================================ */
@media (max-width: 768px) {
body {
font-size: var(--text-sm);
}
.bg-glow--1,
.bg-glow--2 {
display: none; /* 移动端减少特效 */
}
}
</style>

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

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

View 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

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

View File

@@ -0,0 +1,247 @@
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 || params.duration < 1 || params.duration > 60) {
throw new Error('视频时长必须在1-60秒之间')
}
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())
// 添加可选参数
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 || '16:9',
duration: params.duration || 5,
hdMode: params.hdMode || false
}
})
},
/**
* 获取用户任务列表
* @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

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

View File

@@ -0,0 +1,58 @@
import request from './request'
/**
* 小说漫剧生成 API异步模式
* 1. 提交任务 → 立即返回 taskId
* 2. 轮询状态 → 直到 SUCCESS/FAIL
*/
// 创建小说漫剧生成任务(异步提交,秒级返回 taskId
export const createNovelComicTask = (params) => {
if (!params.storyBackground || params.storyBackground.trim() === '') {
throw new Error('故事背景不能为空')
}
if (!params.storyScript || params.storyScript.trim() === '') {
throw new Error('故事文案不能为空')
}
const formData = new FormData()
formData.append('theme', params.theme?.trim() || '')
formData.append('storyBackground', params.storyBackground.trim())
formData.append('storyScript', params.storyScript.trim())
// 背景音乐文件(可选)
if (params.musicFile) {
formData.append('backgroundMusic', params.musicFile)
}
return request({
url: '/novel-comic/create',
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 60000 // 提交本身很快上传音乐文件可能较慢1 分钟足够
})
}
// 查询任务执行状态(轮询用)
export const getNovelComicTaskStatus = (executeId) => {
return request({
url: `/novel-comic/status/${executeId}`,
method: 'GET',
timeout: 15000
})
}
// 获取用户的小说漫剧历史列表
export const getNovelComicHistory = (params = {}) => {
return request({
url: '/novel-comic/history',
method: 'GET',
params: {
page: params.page || 0,
size: params.size || 20
}
})
}

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

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

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

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

View File

@@ -0,0 +1,206 @@
import axios from 'axios'
import { message } from 'ant-design-vue'
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') {
message.warning('登录已过期,请重新登录')
router.push('/')
}
}
// 返回错误,让调用方知道这是认证失败
return Promise.reject(new Error('认证失败收到HTML响应'))
}
// 检查401未授权Token过期
if (response.status === 401) {
console.error('收到401Token已过期:', response.config.url)
// #region agent log
fetch('http://127.0.0.1:7243/ingest/7d01a34a-7181-4a5e-9962-62bb50420571',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'request.js:401interceptor',message:'401 response intercepted',data:{url:response.config.url,currentPath:router.currentRoute?.value?.path},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'H-C'})}).catch(()=>{});
// #endregion
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) {
message.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')
message.warning('登录已过期,请重新登录')
router.push('/')
}
return Promise.reject(new Error('认证失败302重定向'))
}
// 直接返回response让调用方处理data
return response
},
(error) => {
if (error.response) {
const { status, data } = error.response
// 检查响应数据是否是HTML302重定向的结果
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') {
message.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') {
message.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) {
message.error('权限不足')
} else {
// 登录请求的403显示具体错误或网络问题
message.error(data?.message || '请求失败,请检查网络连接')
}
break
case 404:
message.error('请求的资源不存在')
break
case 500:
message.error('服务器内部错误')
break
default:
message.error(data?.message || '请求失败')
}
} else if (error.request) {
message.error('网络错误,请检查网络连接')
} else {
message.error('请求配置错误')
}
return Promise.reject(error)
}
)
export default api

View 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 - 列数默认32×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`)
}

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

View File

@@ -0,0 +1,179 @@
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 || params.duration < 1 || params.duration > 60) {
throw new Error('视频时长必须在1-60秒之间')
}
return request({
url: '/text-to-video/create',
method: 'POST',
data: {
prompt: params.prompt.trim(),
aspectRatio: params.aspectRatio,
duration: params.duration,
hdMode: params.hdMode
}
})
},
/**
* 获取用户的所有文生视频任务
* @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
}
}
}

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

View File

@@ -0,0 +1,91 @@
import request from './request'
/**
* 优质工作流视频 API
*/
// ==================== 公开接口 ====================
/**
* 获取已启用的工作流视频列表(无需认证)
*/
export function getActiveWorkflowVideos() {
return request({
url: '/workflow-videos',
method: 'GET'
})
}
// ==================== 管理员接口 ====================
/**
* 管理后台 - 获取所有工作流视频(分页)
*/
export function getAdminWorkflowVideos(page = 0, size = 20) {
return request({
url: '/admin/workflow-videos',
method: 'GET',
params: { page, size }
})
}
/**
* 管理后台 - 创建工作流视频
*/
export function createWorkflowVideo(data) {
return request({
url: '/admin/workflow-videos',
method: 'POST',
data
})
}
/**
* 管理后台 - 更新工作流视频
*/
export function updateWorkflowVideo(id, data) {
return request({
url: `/admin/workflow-videos/${id}`,
method: 'PUT',
data
})
}
/**
* 管理后台 - 删除工作流视频
*/
export function deleteWorkflowVideo(id) {
return request({
url: `/admin/workflow-videos/${id}`,
method: 'DELETE'
})
}
/**
* 管理后台 - 上传视频文件
*/
export function uploadWorkflowVideoFile(file) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/admin/workflow-videos/upload-video',
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
data: formData,
timeout: 600000 // 10分钟超时视频文件可能较大
})
}
/**
* 管理后台 - 上传缩略图
*/
export function uploadWorkflowThumbnail(file) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/admin/workflow-videos/upload-thumbnail',
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
data: formData
})
}

View File

@@ -0,0 +1,95 @@
<template>
<header class="top-header">
<div class="page-title">
<h2>{{ title }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
<a-dropdown>
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('dashboard.userAvatar')" />
<DownOutlined class="arrow-down" />
</div>
<template #overlay>
<a-menu @click="({ key }) => handleUserCommand(key)">
<a-menu-item key="exitAdmin">
{{ $t('admin.exitAdmin') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</header>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { DownOutlined } from '@ant-design/icons-vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
defineProps({
title: {
type: String,
required: true
}
})
const router = useRouter()
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
router.push('/profile')
}
}
</script>
<style scoped>
.top-header {
background: var(--bg-elevated, #1A1D2E);
border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06));
padding: var(--space-4, 16px) var(--space-6, 24px);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.05));
}
.page-title h2 {
margin: 0;
font-size: var(--text-xl, 20px);
font-weight: var(--font-semibold, 600);
color: var(--text-primary, #E2E8F0);
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.user-avatar {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
cursor: pointer;
padding: var(--space-1, 4px) var(--space-2, 8px);
border-radius: var(--radius-sm, 6px);
transition: var(--transition-all, all 0.2s);
}
.user-avatar:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
}
.user-avatar img {
width: 32px;
height: 32px;
border-radius: var(--radius-full, 50%);
object-fit: cover;
}
.user-avatar .arrow-down {
font-size: var(--text-xs, 12px);
color: var(--text-tertiary, #64748B);
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<aside class="sidebar">
<div class="logo">
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" :class="{ active: active === 'dashboard' }" @click="goTo('/admin/dashboard')">
<AppstoreOutlined />
<span>{{ $t('nav.dashboard') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'members' }" @click="goTo('/member-management')">
<UserOutlined />
<span>{{ $t('nav.members') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'orders' }" @click="goTo('/admin/orders')">
<ShoppingCartOutlined />
<span>{{ $t('nav.orders') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'apiManagement' }" @click="goTo('/api-management')">
<FileOutlined />
<span>{{ $t('nav.apiManagement') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'tasks' }" @click="goTo('/generate-task-record')">
<FileOutlined />
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'workflowVideos' }" @click="goTo('/admin/workflow-videos')">
<VideoCameraOutlined />
<span>{{ $t('nav.workflowVideos') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'errorStats' }" @click="goTo('/admin/error-statistics')">
<WarningOutlined />
<span>{{ $t('nav.errorStats') }}</span>
</div>
<div class="nav-item" :class="{ active: active === 'settings' }" @click="goTo('/system-settings')">
<SettingOutlined />
<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>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
AppstoreOutlined,
UserOutlined,
ShoppingCartOutlined,
FileOutlined,
SettingOutlined,
WarningOutlined,
VideoCameraOutlined
} from '@ant-design/icons-vue'
defineProps({
active: {
type: String,
required: true
}
})
const router = useRouter()
const { t } = useI18n()
const onlineUsers = ref('0')
const systemUptime = ref(t('nav.loading'))
let timer = null
const goTo = (path) => {
router.push(path)
}
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')
}
} catch {
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
}
onMounted(() => {
fetchSystemStats()
timer = setInterval(fetchSystemStats, 30000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.sidebar {
width: 240px;
background: var(--bg-elevated);
border-right: 1px solid var(--border-default);
display: flex;
flex-direction: column;
padding: var(--space-6) 0;
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-6);
margin-bottom: var(--space-8);
}
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}
.nav-menu {
flex: 1;
padding: 0 var(--space-4);
}
.nav-item {
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-1);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--duration-normal) var(--ease-default);
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--primary-glow-light);
color: var(--primary-500);
}
.nav-item .anticon {
margin-right: var(--space-3);
font-size: var(--text-lg);
}
.nav-item span {
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-subtle, rgba(255,255,255,0.06));
font-size: 12px;
color: var(--text-tertiary, #64748B);
}
.sidebar-footer .highlight {
color: #818CF8;
}
</style>

View File

@@ -0,0 +1,380 @@
<template>
<div class="generate-button-container" :class="{ 'is-loading': isLoading, 'is-disabled': isDisabled }">
<button
ref="buttonRef"
:disabled="isDisabled || isLoading"
:class="[
'generate-button',
{ 'btn-large': size === 'large' },
{ 'btn-small': size === 'small' },
{ 'is-loading': isLoading },
{ 'is-disabled': isDisabled }
]"
@click="handleClick"
:title="buttonTitle"
>
<template v-if="isLoading">
<LoadingOutlined :spin="true" />
<span>{{ loadingText || t('common.generate') }}</span>
</template>
<template v-else>
<template v-if="icon">
<component :is="icon" />
</template>
<template v-else>
<PlayCircleOutlined />
</template>
<span>{{ buttonText }}</span>
</template>
</button>
<!-- 智能提示气泡 -->
<div
v-if="showTooltip && tooltipContent"
class="generate-button-tooltip"
:class="{ 'tooltip-error': isError }"
ref="tooltipRef"
>
<span>{{ tooltipContent }}</span>
<div class="tooltip-arrow"></div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { PlayCircleOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
import { message } from 'ant-design-vue'
const props = defineProps({
// 生成类型text-to-video, image-to-video, storyboard-video
type: {
type: String,
default: 'text-to-video'
},
// 按钮大小default, large, small
size: {
type: String,
default: 'default'
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否正在加载
loading: {
type: Boolean,
default: false
},
// 按钮文本
text: {
type: String,
default: ''
},
// 加载状态文本
loadingText: {
type: String,
default: ''
},
// 图标组件
icon: {
type: Object,
default: null
},
// 工具提示内容
tooltip: {
type: String,
default: ''
},
// 是否显示错误状态
error: {
type: Boolean,
default: false
},
// 生成参数
generateParams: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['generate', 'click', 'loading', 'error'])
const { t } = useI18n()
const buttonRef = ref(null)
const tooltipRef = ref(null)
const showTooltip = ref(false)
// 响应式状态
const isLoading = ref(props.loading)
const isDisabled = ref(props.disabled)
const isError = ref(props.error)
const tooltipContent = ref(props.tooltip)
// 计算属性
const buttonText = computed(() => {
if (props.text) return props.text
const typeMap = {
'text-to-video': t('common.generateVideo'),
'image-to-video': t('common.generateVideo'),
'storyboard-video': t('common.generateVideo'),
'storyboard-image': t('common.generateImage')
}
return typeMap[props.type] || t('common.generate')
})
const buttonTitle = computed(() => {
if (tooltipContent.value) return tooltipContent.value
return buttonText.value
})
// 处理按钮点击
const handleClick = async () => {
if (isLoading.value || isDisabled.value) return
emit('click')
try {
isLoading.value = true
emit('loading', true)
// 触发生成事件
const result = await emit('generate', props.generateParams)
// 如果结果是Promise等待其完成
if (result && typeof result.then === 'function') {
await result
}
} catch (error) {
console.error('生成失败:', error)
isError.value = true
tooltipContent.value = error.message || t('common.generateFailed')
showTooltip.value = true
emit('error', error)
message.error(error.message || t('common.generateFailed'))
} finally {
isLoading.value = false
emit('loading', false)
}
}
// 监听属性变化
watch(
() => props.loading,
(newVal) => {
isLoading.value = newVal
}
)
watch(
() => props.disabled,
(newVal) => {
isDisabled.value = newVal
}
)
watch(
() => props.error,
(newVal) => {
isError.value = newVal
}
)
watch(
() => props.tooltip,
(newVal) => {
tooltipContent.value = newVal
}
)
onMounted(() => {
// 初始化工具提示位置
updateTooltipPosition()
})
// 更新工具提示位置
const updateTooltipPosition = () => {
nextTick(() => {
if (!tooltipRef.value || !buttonRef.value) return
const buttonRect = buttonRef.value.getBoundingClientRect()
const tooltipRect = tooltipRef.value.getBoundingClientRect()
tooltipRef.value.style.left = `${buttonRect.left + (buttonRect.width - tooltipRect.width) / 2}px`
tooltipRef.value.style.top = `${buttonRect.bottom + 8}px`
})
}
// 暴露方法
const showLoading = (text) => {
isLoading.value = true
if (text) {
loadingText.value = text
}
}
const hideLoading = () => {
isLoading.value = false
}
const showError = (message) => {
isError.value = true
tooltipContent.value = message
showTooltip.value = true
}
const hideError = () => {
isError.value = false
tooltipContent.value = ''
showTooltip.value = false
}
defineExpose({
showLoading,
hideLoading,
showError,
hideError,
updateTooltipPosition
})
</script>
<style scoped>
.generate-button-container {
position: relative;
display: inline-block;
}
.generate-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 14px 0 rgba(102, 126, 234, 0.39);
width: 100%;
}
.generate-button:hover:not(.is-disabled):not(.is-loading) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
.generate-button:active:not(.is-disabled):not(.is-loading) {
transform: translateY(0);
box-shadow: 0 4px 14px 0 rgba(102, 126, 234, 0.39);
}
.generate-button.is-disabled {
background: #f5f5f5;
color: #999;
cursor: not-allowed;
box-shadow: none;
}
.generate-button.is-loading {
cursor: not-allowed;
opacity: 0.8;
}
.generate-button.btn-large {
padding: 16px 32px;
font-size: 18px;
border-radius: 10px;
}
.generate-button.btn-small {
padding: 8px 16px;
font-size: 14px;
border-radius: 6px;
}
.generate-button-tooltip {
position: absolute;
bottom: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
animation: tooltipFadeIn 0.3s ease;
}
.generate-button-tooltip.tooltip-error {
background: rgba(255, 87, 34, 0.9);
}
.tooltip-arrow {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid rgba(0, 0, 0, 0.8);
}
.generate-button-tooltip.tooltip-error .tooltip-arrow {
border-top-color: rgba(255, 87, 34, 0.9);
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.generate-button {
padding: 10px 20px;
font-size: 14px;
}
.generate-button.btn-large {
padding: 14px 28px;
font-size: 16px;
}
.generate-button.btn-small {
padding: 6px 14px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.generate-button {
padding: 8px 16px;
font-size: 13px;
}
.generate-button.btn-large {
padding: 12px 24px;
font-size: 14px;
}
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<!-- 顶部导航栏 -->
<header class="creation-header">
<div class="header-left">
<button class="back-btn" @click="goBack">
{{ t('common.home') }}
</button>
</div>
<div class="header-right">
<div class="points-display">
<div class="points-icon">
<StarOutlined />
</div>
<span class="points-number">{{ userStore.availablePoints }}</span>
</div>
<LanguageSwitcher />
<div class="user-avatar" @click="toggleUserMenu" ref="userAvatarRef">
<img src="/images/backgrounds/avatar-default.svg" alt="Avatar" />
</div>
</div>
</header>
<!-- 用户菜单下拉 -->
<Teleport to="body">
<div v-if="showUserMenu" class="creation-user-menu" :style="menuStyle">
<!-- 管理员功能 -->
<template v-if="userStore.isAdmin">
<div class="menu-item" @click.stop="navigateTo('/admin/dashboard')">
<UserOutlined />
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click.stop="navigateTo('/admin/orders')">
<FileOutlined />
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click.stop="navigateTo('/member-management')">
<UserOutlined />
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click.stop="navigateTo('/system-settings')">
<SettingOutlined />
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click.stop="navigateTo('/admin/error-statistics')">
<WarningOutlined />
<span>{{ t('nav.errorStats') }}</span>
</div>
<div class="menu-item" @click.stop="navigateTo('/api-management')">
<FileOutlined />
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click.stop="navigateTo('/generate-task-record')">
<FileOutlined />
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="navigateTo('/change-password')" style="cursor: pointer;">
<LockOutlined />
<span>{{ t('profile.changePassword') }}</span>
</div>
<!-- 退出登录 -->
<div class="menu-item" @click.stop="handleLogout">
<PoweroffOutlined />
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { StarOutlined, UserOutlined, SettingOutlined, PoweroffOutlined, LockOutlined, FileOutlined, WarningOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const userStore = useUserStore()
const { t } = useI18n()
const showUserMenu = ref(false)
const userAvatarRef = ref(null)
const menuStyle = computed(() => {
if (!userAvatarRef.value || !showUserMenu.value) return {}
const rect = userAvatarRef.value.getBoundingClientRect()
return {
position: 'fixed',
top: `${rect.bottom + 8}px`,
right: `${window.innerWidth - rect.right}px`,
zIndex: 99999
}
})
const goBack = () => {
router.push('/')
}
const toggleUserMenu = () => {
if (!userStore.isAuthenticated) {
router.push('/login')
return
}
showUserMenu.value = !showUserMenu.value
}
const navigateTo = (path) => {
showUserMenu.value = false
router.push(path)
}
const handleLogout = async () => {
showUserMenu.value = false
await userStore.logoutUser()
router.push('/login')
}
</script>
<style scoped>
.creation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 32px;
background: var(--bg-base);
border-bottom: 1px solid var(--border-subtle);
min-height: 60px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
}
.back-btn {
background: none;
border: none;
color: var(--text-primary);
font-size: var(--text-base);
cursor: pointer;
padding: 10px 20px;
border-radius: var(--radius-md);
transition: var(--transition-all);
font-weight: var(--font-medium);
}
.back-btn:hover {
background: var(--bg-surface);
transform: translateX(-2px);
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-6);
}
.points-display {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 6px 12px;
background: rgba(64, 158, 255, 0.1);
border-radius: var(--radius-2xl);
border: 1px solid rgba(64, 158, 255, 0.3);
}
.points-icon {
width: 20px;
height: 20px;
background: var(--primary-500);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-size: var(--text-xs);
}
.points-number {
color: var(--primary-500);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform var(--duration-normal) var(--ease-default);
overflow: hidden;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-avatar:hover {
transform: scale(1.05);
}
/* 用户菜单 */
.creation-user-menu {
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
min-width: 160px;
overflow: hidden;
z-index: 99999;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color var(--duration-normal) var(--ease-default);
color: var(--text-primary);
font-size: var(--text-sm);
}
.menu-item:hover {
background: var(--bg-hover);
}
.menu-item .anticon {
margin-right: 8px;
font-size: var(--text-base);
}
.menu-item:not(:last-child) {
border-bottom: 1px solid var(--border-default);
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="creation-tabs">
<div
class="tab"
:class="{ active: active === 'text-to-video' }"
@click="navigate('/text-to-video/create')"
>{{ t('home.textToVideo') }}</div>
<div
class="tab"
:class="{ active: active === 'image-to-video' }"
@click="navigate('/image-to-video/create')"
>{{ t('home.imageToVideo') }}</div>
<div
class="tab"
:class="{ active: active === 'storyboard-video' }"
@click="navigate('/storyboard-video/create')"
>{{ t('home.storyboardVideo') }}</div>
<div
class="tab"
:class="{ active: active === 'novel-comic' }"
@click="navigate('/novel-comic/create')"
>小说漫剧</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
defineProps({
active: {
type: String,
required: true,
validator: (v) => ['text-to-video', 'image-to-video', 'storyboard-video', 'novel-comic'].includes(v)
}
})
const router = useRouter()
const { t } = useI18n()
const navigate = (path) => {
router.push(path)
}
</script>
<style scoped>
.creation-tabs {
display: flex;
gap: var(--space-2);
padding: 0;
flex-shrink: 0;
}
.tab {
width: 155px;
height: 44px;
padding: 0;
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition-all);
color: var(--text-tertiary);
font-size: var(--text-sm);
font-weight: var(--font-medium);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab.active {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: transparent;
}
.tab:hover:not(.active) {
background: rgba(49, 51, 56, 0.5);
color: var(--text-primary);
border-color: transparent;
}
</style>

View File

@@ -0,0 +1,404 @@
<template>
<div class="daily-active-users-chart">
<div class="chart-header">
<h3 class="chart-title">{{ $t('dashboard.dailyActive') }}</h3>
<div class="chart-controls">
<a-select v-model:value="selectedYear" @change="loadChartData" :placeholder="$t('dashboard.selectYear')" style="width: 120px">
<a-select-option
v-for="year in availableYears"
:key="year"
:value="year">
{{ `${year}${$t('dashboard.yearSuffix')}` }}
</a-select-option>
</a-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 { message } from 'ant-design-vue'
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()
])
// 处理图表数据axios 响应需要从 .data 中取)
const chartData = chartRes?.data?.data || chartRes?.data || chartRes
if (chartData && chartData.monthlyData) {
await nextTick()
initChart(chartData.monthlyData)
}
// 处理概览数据axios 响应需要从 .data 中取)
const overviewData = overviewRes?.data?.data || overviewRes?.data || overviewRes
if (overviewData) {
todayDAU.value = overviewData.todayDAU || 0
dayGrowthRate.value = overviewData.dayGrowthRate || 0
monthlyAvgDAU.value = overviewData.monthlyAvgDAU || 0
monthGrowthRate.value = overviewData.monthGrowthRate || 0
}
} catch (error) {
console.error('加载图表数据失败:', error)
message.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 = [t('dashboard.month1'), t('dashboard.month2'), t('dashboard.month3'), t('dashboard.month4'), t('dashboard.month5'), t('dashboard.month6'), t('dashboard.month7'), t('dashboard.month8'), t('dashboard.month9'), t('dashboard.month10'), t('dashboard.month11'), t('dashboard.month12')]
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(255, 255, 255, 0.96)',
borderColor: '#E5E7EB',
borderWidth: 1,
textStyle: {
color: '#1F2937',
fontSize: 12
},
extraCssText: 'box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);',
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/>
${t('chartLabels.avgDailyActive')}: ${formatNumber(avgValue)}<br/>
${t('chartLabels.maxDailyActive')}: ${formatNumber(maxValue)}<br/>
${t('chartLabels.minDailyActive')}: ${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: t('chartLabels.dailyActiveUsers'),
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)
message.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>

View File

@@ -0,0 +1,113 @@
<template>
<footer class="footer">
<div class="footer-content">
<div class="footer-info">
<p>&copy; 2024 AIGC Demo. All rights reserved.</p>
<p>基于 Vue.js 3 + Ant Design Vue 构建</p>
</div>
<div class="footer-links">
<a href="#" class="footer-link">{{ $t('footer.aboutUs') }}</a>
<a href="#" class="footer-link">{{ $t('footer.contactUs') }}</a>
<a href="#" class="footer-link">{{ $t('footer.privacyPolicy') }}</a>
<a href="#" class="footer-link">{{ $t('footer.termsOfService') }}</a>
</div>
</div>
</footer>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</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>

View File

@@ -0,0 +1,71 @@
<template>
<button class="language-switcher" @click="toggleLanguage" :title="currentLanguage === 'zh' ? $t('langSwitcher.switchToEn') : $t('langSwitcher.switchToZh')">
<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: var(--bg-hover);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border-subtle);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
}
.language-switcher:hover {
background: var(--bg-active);
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>

View File

@@ -0,0 +1,224 @@
# "我的作品"页面性能优化方案
## 性能问题分析
通过浏览器开发者工具的分析,"我的作品"页面加载慢的主要原因如下:
### 1. 数据获取问题
- 前端一次性加载100条数据
- 后端默认返回1000条数据
- 存在轮询机制每2分钟刷新数据
### 2. 前端渲染问题
- 页面加载时间约18秒
- 大量JavaScript执行时间和长任务
- 复杂的虚拟滚动实现
- 大量的计算属性和过滤操作
### 3. 资源加载问题
- 大量图片资源加载请求
- 存在CORS策略错误
- 图片资源加载策略不够优化
### 4. 其他问题
- 复杂的过滤和排序逻辑
- 轮询机制可能导致频繁数据刷新
## 优化方案
### 1. 优化数据获取策略
#### 方案A实现真正的分页加载
- 将pageSize从100减小到20-30
- 实现滚动到底部自动加载更多数据
- 后端保持分页查询,避免一次性返回过多数据
#### 方案B优化API响应
- 减少API响应中的冗余数据
- 只返回前端需要的字段
- 实现数据压缩,减少传输大小
### 2. 优化前端渲染性能
#### 方案A简化虚拟滚动实现
- 使用成熟的虚拟滚动库如vue-virtual-scroller
- 减少自定义虚拟滚动的复杂性
- 优化IntersectionObserver的配置
#### 方案B优化计算属性和过滤逻辑
- 使用缓存减少重复计算
- 实现防抖和节流,避免频繁计算
- 优化filteredItems计算属性减少不必要的过滤操作
#### 方案C代码分割和懒加载
- 实现组件的懒加载
- 优化JavaScript执行时间减少长任务
### 3. 优化资源加载策略
#### 方案A优化图片加载
- 实现真正的图片懒加载
- 使用适当的图片格式和尺寸
- 实现图片预加载策略
#### 方案B解决CORS问题
- 修复图片资源的CORS配置
- 确保所有图片资源都有正确的CORS头
### 4. 优化轮询机制
#### 方案A智能轮询
- 只在有处理中任务时启动轮询
- 增加轮询间隔从2分钟增加到5分钟
- 实现后台静默更新,避免影响用户体验
#### 方案B使用WebSocket替代轮询
- 实现WebSocket连接实时更新任务状态
- 减少不必要的HTTP请求
### 5. 其他优化方案
#### 方案A使用缓存
- 实现本地缓存,减少重复请求
- 使用sessionStorage或localStorage缓存数据
#### 方案B优化CSS和DOM结构
- 减少DOM节点数量
- 优化CSS选择器
- 避免不必要的DOM操作
## 推荐优化顺序
1. **优先实施**优化数据获取策略方案A- 这是最直接有效的优化
2. **其次实施**优化前端渲染性能方案A和B- 减少JavaScript执行时间
3. **然后实施**优化资源加载策略方案A- 减少图片加载时间
4. **最后实施**优化轮询机制方案A- 减少不必要的请求
## 预期效果
通过实施这些优化方案,预计可以:
- 将页面加载时间从18秒减少到3-5秒
- 减少JavaScript执行时间和长任务
- 优化图片加载速度和资源使用
- 提高页面的响应速度和用户体验
## 具体实施步骤
### 步骤1优化数据获取策略
1. 修改MyWorks.vue中的数据获取逻辑
```javascript
// 将pageSize从100减小到20-30
const pageSize = ref(20)
```
2. 确保滚动到底部自动加载更多数据的逻辑正常工作:
```javascript
const handleScroll = (event) => {
const target = event.target
const scrollTop = target.scrollTop
const scrollHeight = target.scrollHeight
const clientHeight = target.clientHeight
// 当滚动到距离底部100px时自动加载更多
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
```
### 步骤2优化虚拟滚动实现
1. 简化IntersectionObserver的配置
```javascript
const setupVirtualObserver = () => {
if (virtualObserver) virtualObserver.disconnect()
virtualObserver = new IntersectionObserver(
(entries) => {
const next = new Set(visibleItemIds.value)
let changed = false
for (const entry of entries) {
const id = entry.target.dataset.vid
if (!id) continue
if (entry.isIntersecting) {
if (!next.has(id)) { next.add(id); changed = true }
} else {
// 离开可视区前缓存高度
const h = entry.target.offsetHeight
if (h > 0) itemHeightCache[id] = h
if (next.has(id)) { next.delete(id); changed = true }
}
}
if (changed) visibleItemIds.value = next
},
{
root: contentAreaRef.value,
rootMargin: '50% 0px', // 上下 50% 视口高度的缓冲区
threshold: 0
}
)
// 观察已注册的元素
itemElMap.forEach(el => virtualObserver.observe(el))
}
```
2. 减少不必要的DOM操作确保只渲染可视区域内的元素。
### 步骤3优化图片加载
1. 确保图片懒加载正常工作:
```html
<img
v-if="item.thumbnailCover"
:src="item.thumbnailCover"
:alt="item.title"
class="work-thumbnail-img"
loading="lazy"
@error="onImageError"
/>
```
2. 修复CORS配置问题确保所有图片资源都有正确的CORS头。
### 步骤4优化轮询机制
1. 只在有处理中任务时启动轮询:
```javascript
const checkAndStartPolling = () => {
const hasProcessingTasks = items.value.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (hasProcessingTasks && !pollingIntervalId.value) {
console.log('[MyWorks] 检测到处理中的任务启动5分钟轮询')
startPolling()
} else if (!hasProcessingTasks && pollingIntervalId.value) {
console.log('[MyWorks] 没有处理中的任务,停止轮询')
stopPolling()
}
}
```
2. 增加轮询间隔从2分钟增加到5分钟
```javascript
const POLLING_INTERVAL = 300000 // 5分钟轮询间隔
```
### 步骤5测试和验证
1. 使用浏览器开发者工具验证优化效果:
- 检查页面加载时间
- 分析JavaScript执行时间
- 观察图片加载情况
2. 测试不同网络环境下的页面加载速度
3. 确保所有功能正常工作
## 结论
通过实施上述优化方案,可以显著改善"我的作品"页面的加载速度和用户体验。优化的关键是减少数据传输量、简化前端渲染逻辑、优化资源加载策略和减少不必要的网络请求。
这些优化措施不仅可以提高页面性能,还可以减少服务器负载,提升整个应用的响应速度。

View File

@@ -0,0 +1,247 @@
<template>
<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>
<!-- 导航菜单 -->
<a-menu
mode="horizontal"
class="navbar-menu"
:selected-keys="[]"
@click="handleMenuClick"
>
<a-menu-item key="/welcome">
<span>{{ $t('common.welcome') }}</span>
</a-menu-item>
<a-menu-item key="/admin/dashboard">
<span>{{ $t('common.home') }}</span>
</a-menu-item>
<a-menu-item v-if="userStore.isAuthenticated" key="/profile">
<span>{{ $t('common.profile') }}</span>
</a-menu-item>
<a-menu-item v-if="userStore.isAuthenticated" key="/admin/orders">
<span>{{ $t('common.orders') }}</span>
</a-menu-item>
<a-menu-item v-if="userStore.isAuthenticated" key="/payments">
<span>{{ $t('common.payments') }}</span>
</a-menu-item>
<a-menu-item v-if="userStore.isAdmin" key="/admin/dashboard">
<span>{{ $t('common.adminPanel') }}</span>
</a-menu-item>
</a-menu>
<!-- 用户菜单 -->
<div class="navbar-user">
<template v-if="userStore.isAuthenticated">
<LanguageSwitcher />
<a-dropdown>
<span class="user-dropdown">
<span>{{ userStore.username }}</span>
<a-tag v-if="userStore.availablePoints > 0" color="success" class="points-tag">
{{ userStore.availablePoints }}{{ $t('common.points') }}
</a-tag>
</span>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile">
{{ $t('common.userProfile') }}
</a-menu-item>
<a-menu-item v-if="userStore.isAdmin" key="admin">
{{ $t('common.adminPanel') }}
</a-menu-item>
<a-menu-item key="settings">
{{ $t('common.settings') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
{{ $t('common.logout') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template v-else>
<LanguageSwitcher />
<a-button type="primary" ghost @click="$router.push('/login')">
{{ $t('common.login') }}
</a-button>
<a-button ghost @click="$router.push('/register')">
{{ $t('common.register') }}
</a-button>
</template>
</div>
</div>
</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 { message, Modal } from 'ant-design-vue'
import LanguageSwitcher from './LanguageSwitcher.vue'
const { t } = useI18n()
const userStore = useUserStore()
const router = useRouter()
// 导航菜单点击
const handleMenuClick = ({ key }) => {
router.replace(key)
}
// 用户下拉菜单点击
const handleUserMenuClick = async ({ key }) => {
switch (key) {
case 'profile':
message.info(t('common.profileDevMsg'))
break
case 'admin':
if (userStore.isAdmin) {
router.push('/admin/dashboard')
} else {
message.warning(t('common.noPermissionMsg'))
}
break
case 'settings':
message.info(t('common.settingsDevMsg'))
break
case 'logout':
Modal.confirm({
title: t('common.tip'),
content: t('common.logoutConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
async onOk() {
await userStore.logoutUser()
message.success(t('common.logoutSuccess'))
router.push('/')
}
})
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 :deep(.ant-menu-item) {
height: 60px;
line-height: 60px;
border-bottom: none;
}
.navbar-menu :deep(.ant-menu-item:hover) {
background-color: var(--bg-hover) !important;
}
.navbar-user {
margin-left: auto;
}
.user-dropdown {
display: flex;
align-items: center;
color: var(--text-primary);
cursor: pointer;
padding: 0 12px;
border-radius: 4px;
transition: background-color 0.3s;
}
.user-dropdown:hover {
background-color: var(--bg-hover);
}
.points-tag {
margin-left: 4px;
font-size: 12px;
}
.user-dropdown .anticon {
margin-right: 4px;
}
.navbar-user .ant-btn {
margin-left: 8px;
}
.shortcut-hint {
font-size: 10px;
opacity: 0.7;
margin-left: 8px;
padding: 2px 4px;
background-color: var(--bg-active);
border-radius: 3px;
font-family: monospace;
}
.quick-switch-hint {
margin-right: 20px;
cursor: pointer;
color: var(--text-secondary);
transition: color 0.3s;
}
.quick-switch-hint:hover {
color: white;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
<template>
<aside class="sidebar">
<!-- Logo -->
<div class="sidebar-logo">
<router-link to="/" class="logo-link">
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" class="logo-img" />
</router-link>
</div>
<!-- 主导航 -->
<nav class="sidebar-nav">
<div
class="nav-item"
:class="{ active: active === 'profile' }"
@click="navigate('/profile')"
>
<UserOutlined class="nav-icon" />
<span class="nav-label">{{ t('profile.title') }}</span>
</div>
<div
class="nav-item"
:class="{ active: active === 'subscription' }"
@click="navigate('/subscription')"
>
<CompassOutlined class="nav-icon" />
<span class="nav-label">{{ t('profile.subscription') }}</span>
</div>
<div
class="nav-item"
:class="{ active: active === 'works' }"
@click="navigate('/works')"
>
<FileOutlined class="nav-icon" />
<span class="nav-label">{{ t('profile.myWorks') }}</span>
</div>
<div
class="nav-item"
:class="{ active: active === 'workflow-videos' }"
@click="navigate('/workflow-videos')"
>
<VideoCameraOutlined class="nav-icon" />
<span class="nav-label">{{ t('nav.workflowVideos') }}</span>
</div>
</nav>
<!-- 分隔线 + 工具标签 -->
<div class="sidebar-divider">
<span class="divider-text">{{ t('profile.tools') }}</span>
</div>
<!-- 工具菜单 -->
<nav class="sidebar-nav">
<div class="nav-item" @click="navigate('/text-to-video/create')">
<PlayCircleOutlined class="nav-icon" />
<span class="nav-label">{{ t('home.textToVideo') }}</span>
<span class="nav-badge badge-pro">Pro</span>
</div>
<div class="nav-item" @click="navigate('/image-to-video/create')">
<PictureOutlined class="nav-icon" />
<span class="nav-label">{{ t('home.imageToVideo') }}</span>
<span class="nav-badge badge-pro">Pro</span>
</div>
<div class="nav-item" @click="navigate('/storyboard-video/create')">
<VideoCameraOutlined class="nav-icon" />
<span class="nav-label">{{ t('home.storyboardVideo') }}</span>
<span class="nav-badge badge-max">Max</span>
</div>
<div
class="nav-item"
:class="{ active: active === 'novel-comic' }"
@click="navigate('/novel-comic/create')"
>
<ReadOutlined class="nav-icon" />
<span class="nav-label">小说漫剧</span>
<span class="nav-badge badge-new">New</span>
</div>
</nav>
</aside>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
UserOutlined,
CompassOutlined,
FileOutlined,
PlayCircleOutlined,
PictureOutlined,
VideoCameraOutlined,
ReadOutlined
} from '@ant-design/icons-vue'
// VideoCameraOutlined is reused for workflow videos nav item
defineProps({
active: {
type: String,
required: true,
validator: (v) => ['profile', 'subscription', 'works', 'novel-comic', 'workflow-videos'].includes(v)
}
})
const { t } = useI18n()
const router = useRouter()
const navigate = (path) => {
router.push(path)
}
</script>
<style scoped>
.sidebar {
width: var(--sidebar-width);
background: var(--bg-elevated);
border-right: 1px solid var(--border-subtle);
padding: var(--space-6) 0;
flex-shrink: 0;
display: flex;
flex-direction: column;
position: relative;
z-index: var(--z-sticky);
height: 100vh;
overflow-y: auto;
}
/* Logo */
.sidebar-logo {
padding: 0 var(--space-6) var(--space-8);
display: flex;
align-items: center;
justify-content: center;
}
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
}
.logo-img {
height: 36px;
width: auto;
transition: var(--transition-transform);
}
.logo-img:hover {
transform: scale(1.03);
}
/* Navigation */
.sidebar-nav {
padding: 0 var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.nav-item {
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-all);
color: var(--text-secondary);
font-size: var(--text-sm);
position: relative;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--primary-glow-light);
color: var(--primary-400);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--primary-500);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.nav-icon {
font-size: 18px;
margin-right: var(--space-3);
flex-shrink: 0;
}
.nav-label {
flex: 1;
font-weight: var(--font-medium);
}
/* Badges */
.nav-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: var(--font-semibold);
letter-spacing: 0.5px;
flex-shrink: 0;
}
.badge-pro {
background: var(--primary-glow-light);
color: var(--primary-400);
}
.badge-max {
background: rgba(255, 100, 150, 0.12);
color: #FF7EB3;
}
.badge-new {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(6, 182, 212, 0.12));
color: #10B981;
}
/* Divider */
.sidebar-divider {
padding: var(--space-6) var(--space-6) var(--space-4);
}
.divider-text {
color: var(--text-disabled);
font-size: var(--text-xs);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
padding: var(--space-3) 0;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-logo {
padding: 0 var(--space-4);
}
.sidebar-nav {
flex-direction: row;
gap: var(--space-1);
padding: 0 var(--space-3);
}
.nav-item {
white-space: nowrap;
padding: var(--space-2) var(--space-3);
}
.sidebar-divider {
display: none;
}
.nav-item.active::before {
display: none;
}
}
</style>

View File

@@ -0,0 +1,389 @@
<template>
<div class="task-status-display">
<div class="status-header">
<h3>{{ $t('taskMonitor.taskStatus') }}</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>{{ $t('taskMonitor.queuing') }}</div>
</div>
<div class="task-info">
<div class="info-item">
<span class="label">{{ $t('taskMonitor.taskId') }}:</span>
<span class="value">{{ taskStatus?.taskId }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskMonitor.createTime') }}:</span>
<span class="value">{{ formatDate(taskStatus?.createdAt) }}</span>
</div>
<div class="info-item" v-if="taskStatus?.completedAt">
<span class="label">{{ $t('taskMonitor.completeTime') }}:</span>
<span class="value">{{ formatDate(taskStatus.completedAt) }}</span>
</div>
<div class="info-item" v-if="taskStatus?.resultUrl">
<span class="label">{{ $t('taskMonitor.resultUrl') }}:</span>
<a :href="taskStatus.resultUrl" target="_blank" class="result-link">
{{ $t('taskMonitor.viewResult') }}
</a>
</div>
<div class="info-item" v-if="taskStatus?.errorMessage">
<span class="label">{{ $t('taskMonitor.errorMessage') }}:</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"
>
{{ $t('taskMonitor.retry') }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { message } from 'ant-design-vue'
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 t('taskMonitor.unknown')
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>

View File

@@ -0,0 +1,120 @@
<template>
<header class="top-header">
<div class="header-left">
<slot name="left" />
</div>
<div class="header-right">
<slot name="right">
<!-- 默认右侧内容积分 + 语言切换 + 头像 -->
<div v-if="showPoints" class="points-badge">
<div class="points-icon">
<StarOutlined />
</div>
<span class="points-number">{{ userStore.availablePoints }}</span>
</div>
<LanguageSwitcher />
<div v-if="showAvatar" ref="avatarRef" class="user-avatar" @click="$emit('avatar-click')">
<img src="/images/backgrounds/avatar-default.svg" alt="Avatar" />
</div>
</slot>
</div>
</header>
</template>
<script setup>
import { ref } from 'vue'
import { StarOutlined } from '@ant-design/icons-vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useUserStore } from '@/stores/user'
defineProps({
showPoints: { type: Boolean, default: true },
showAvatar: { type: Boolean, default: true }
})
defineEmits(['avatar-click'])
const userStore = useUserStore()
const avatarRef = ref(null)
defineExpose({ avatarRef })
</script>
<style scoped>
.top-header {
height: var(--header-height, 64px);
padding: 0 var(--space-8);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-surface);
flex-shrink: 0;
z-index: var(--z-sticky, 200);
}
.header-left {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-5);
margin-left: auto;
}
/* Points Badge */
.points-badge {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 6px 14px;
background: var(--gradient-brand);
border-radius: var(--radius-full, 9999px);
border: none;
box-shadow: 0 2px 10px rgba(124, 58, 237, 0.2);
}
.points-icon {
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.25);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 11px;
}
.points-number {
color: #fff;
font-size: var(--text-sm);
font-weight: var(--font-semibold, 600);
}
/* User Avatar */
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
transition: transform 250ms ease;
overflow: hidden;
border: 1px solid var(--border-subtle);
}
.user-avatar:hover {
transform: scale(1.08);
border-color: var(--border-default);
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
</style>

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

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

26
demo/frontend/src/main.js Normal file
View File

@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import './styles/design-system.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(Antd)
app.use(lazyLoadDirective)
console.log('[main.js] i18n 当前语言:', i18n.global.locale.value)
// 立即挂载应用
app.mount('#app')
console.log('[main.js] 应用已挂载,当前语言:', i18n.global.locale.value)

View File

@@ -0,0 +1,356 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { message } from 'ant-design-vue'
// 路由组件 - 使用懒加载优化性能
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 NovelComicCreate = () => import('@/views/NovelComicCreate.vue')
const WorkflowVideos = () => import('@/views/WorkflowVideos.vue')
const AdminWorkflowVideos = () => import('@/views/AdminWorkflowVideos.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: '/novel-comic/create',
name: 'NovelComicCreate',
component: NovelComicCreate,
meta: { title: '小说漫剧生成', requiresAuth: true }
},
{
path: '/workflow-videos',
name: 'WorkflowVideos',
component: WorkflowVideos,
meta: { title: '优质工作流', keepAlive: true }
},
{
path: '/admin/workflow-videos',
name: 'AdminWorkflowVideos',
component: AdminWorkflowVideos,
meta: { title: '视频管理', requiresAuth: true, requiresAdmin: true }
},
{
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 }
},
// 404 兜底路由 - 必须放在最后
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
redirect: '/profile'
},
]
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()
// #region agent log
fetch('http://127.0.0.1:7243/ingest/7d01a34a-7181-4a5e-9962-62bb50420571',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'router/index.js:beforeEach',message:'Route guard entry',data:{toPath:to.path,fromPath:from.path,isAuthenticated:userStore.isAuthenticated,initialized:userStore.initialized,hasToken:!!localStorage.getItem('token')},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'H-B'})}).catch(()=>{});
// #endregion
// 检查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()
// #region agent log
fetch('http://127.0.0.1:7243/ingest/7d01a34a-7181-4a5e-9962-62bb50420571',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'router/index.js:afterInit',message:'After store init',data:{isAuthenticated:userStore.isAuthenticated,hasToken:!!localStorage.getItem('token'),username:userStore.username},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'H-B'})}).catch(()=>{});
// #endregion
}
// 处理根路径:如果已登录,重定向到个人主页;否则重定向到欢迎页面
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) {
// #region agent log
fetch('http://127.0.0.1:7243/ingest/7d01a34a-7181-4a5e-9962-62bb50420571',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'router/index.js:authRedirect',message:'Redirecting to login - not authenticated',data:{toPath:to.path,hasToken:!!localStorage.getItem('token')},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'H-B'})}).catch(()=>{});
// #endregion
// 未登录,跳转到登录页
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// 检查管理员权限
if (to.meta.requiresAdmin && !userStore.isAdmin) {
// 权限不足,跳转到个人主页并显示警告
message.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

View File

@@ -0,0 +1,245 @@
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)
const responseData = response?.data || response || {}
if (responseData.success) {
const data = responseData.data || responseData
orders.value = data.content || data
pagination.value = {
page: data.number || 0,
size: data.size || 10,
total: data.totalElements || (Array.isArray(data) ? data.length : 0),
totalPages: data.totalPages || 1
}
console.log('OrderStore: 处理后的订单数据:', orders.value)
console.log('OrderStore: 分页信息:', pagination.value)
} else {
console.error('OrderStore: API返回失败:', responseData.message)
}
return responseData
} 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)
const responseData = response?.data || response || {}
if (responseData.success) {
// 刷新订单列表
await fetchOrders()
}
return responseData
} 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)
const responseData = response?.data || response || {}
if (responseData.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 responseData
} 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)
const responseData = response?.data || response || {}
if (responseData.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 responseData
} 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)
const responseData = response?.data || response || {}
if (responseData.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 responseData
} 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)
const responseData = response?.data || response || {}
if (responseData.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 responseData
} 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
}
})

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

View File

@@ -0,0 +1,737 @@
/* ============================================================
UI-UX-Pro-Max Design System
AI Video Generation Platform — Vionow
Light (User) ➜ :root 默认亮色主题
Dark (Admin) ➜ .admin-theme / body.admin-mode 暗色主题
============================================================ */
/* ============================================================
1. Light Theme (User Pages) — DEFAULT
============================================================ */
:root {
/* --- Background Layers (Light — warm white) --- */
--bg-root: #F8F7FF;
--bg-base: #F3F1FC;
--bg-surface: #FFFFFF;
--bg-elevated: #FFFFFF;
--bg-overlay: #FFFFFF;
--bg-hover: rgba(124, 58, 237, 0.04);
--bg-active: rgba(124, 58, 237, 0.08);
--bg-glass: rgba(255, 255, 255, 0.75);
--bg-glass-hover: rgba(255, 255, 255, 0.90);
/* --- Border Colors (Light) --- */
--border-subtle: #F0F0F0;
--border-default: #E5E7EB;
--border-strong: #D1D5DB;
--border-focus: rgba(124, 58, 237, 0.45);
/* --- Primary Color (Vivid Purple) — bold & creative --- */
--primary-50: #F5F3FF;
--primary-100: #EDE9FE;
--primary-200: #DDD6FE;
--primary-300: #C4B5FD;
--primary-400: #A78BFA;
--primary-500: #7C3AED;
--primary-600: #6D28D9;
--primary-700: #5B21B6;
--primary-800: #4C1D95;
--primary-900: #3B0764;
/* --- Primary Glow / Alpha --- */
--primary-glow-subtle: rgba(124, 58, 237, 0.05);
--primary-glow-light: rgba(124, 58, 237, 0.10);
--primary-glow-medium: rgba(124, 58, 237, 0.16);
--primary-glow-strong: rgba(124, 58, 237, 0.28);
/* --- Secondary Color (Hot Pink) --- */
--secondary-400: #F472B6;
--secondary-500: #EC4899;
--secondary-600: #DB2777;
--secondary-glow: rgba(236, 72, 153, 0.10);
/* --- Accent Color (Cyan) --- */
--accent-400: #22D3EE;
--accent-500: #06B6D4;
--accent-600: #0891B2;
--accent-glow: rgba(6, 182, 212, 0.10);
/* --- Vivid Gradients (大胆撞色) --- */
--gradient-brand: linear-gradient(135deg, #7C3AED 0%, #EC4899 100%);
--gradient-warm: linear-gradient(135deg, #F59E0B 0%, #EC4899 100%);
--gradient-cool: linear-gradient(135deg, #06B6D4 0%, #7C3AED 100%);
--gradient-fresh: linear-gradient(135deg, #10B981 0%, #06B6D4 100%);
--gradient-sunset: linear-gradient(135deg, #F97316 0%, #EC4899 50%, #7C3AED 100%);
--gradient-aurora: linear-gradient(135deg, #7C3AED 0%, #06B6D4 50%, #10B981 100%);
/* --- Warning Color (Amber) --- */
--warning-400: #FBBF24;
--warning-500: #F59E0B;
--warning-600: #D97706;
--warning-glow: rgba(245, 158, 11, 0.10);
/* --- Error Color (Red) --- */
--error-400: #F87171;
--error-500: #EF4444;
--error-600: #DC2626;
--error-glow: rgba(239, 68, 68, 0.10);
/* --- Success Color (Green) --- */
--success-400: #4ADE80;
--success-500: #22C55E;
--success-600: #16A34A;
--success-glow: rgba(34, 197, 94, 0.10);
/* ==================== Text Colors (Light) ==================== */
--text-primary: #0F172A;
--text-secondary: #475569;
--text-tertiary: #94A3B8;
--text-disabled: #CBD5E1;
--text-inverse: #FFFFFF;
--text-link: var(--primary-500);
--text-link-hover: var(--primary-600);
/* ==================== Typography ==================== */
--font-sans: 'Inter', 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Plus Jakarta Sans', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
--text-hero: clamp(3rem, 6vw, 6rem);
--leading-none: 1;
--leading-tight: 1.2;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.05em;
/* ==================== Spacing ==================== */
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
/* ==================== Border Radius ==================== */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-3xl: 24px;
--radius-full: 9999px;
/* ==================== Shadows (Light) ==================== */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.07), 0 4px 6px -2px rgba(0, 0, 0, 0.03);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03);
/* Glow Shadows (colorful tints) */
--glow-primary: 0 4px 20px rgba(124, 58, 237, 0.18);
--glow-primary-lg: 0 8px 40px rgba(124, 58, 237, 0.22);
--glow-secondary: 0 4px 20px rgba(236, 72, 153, 0.18);
--glow-accent: 0 4px 20px rgba(6, 182, 212, 0.18);
/* ==================== Transitions ==================== */
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
--transition-colors: color var(--duration-normal) var(--ease-default),
background-color var(--duration-normal) var(--ease-default),
border-color var(--duration-normal) var(--ease-default);
--transition-all: all var(--duration-normal) var(--ease-default);
--transition-transform: transform var(--duration-normal) var(--ease-default);
/* ==================== Z-Index Scale ==================== */
--z-base: 1;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
--z-tooltip: 600;
--z-max: 9999;
/* ==================== Layout ==================== */
--sidebar-width: 280px;
--sidebar-collapsed: 72px;
--header-height: 64px;
--content-max-width: 1400px;
--navbar-height: 60px;
}
/* ============================================================
2. Dark Theme (Admin Pages)
通过 .admin-theme 类或 body.admin-mode 激活
============================================================ */
.admin-theme {
/* --- Background Layers (Dark) --- */
--bg-root: #07090F;
--bg-base: #0B0D14;
--bg-surface: #111318;
--bg-elevated: #181B24;
--bg-overlay: #1E2130;
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-active: rgba(255, 255, 255, 0.08);
--bg-glass: rgba(255, 255, 255, 0.03);
--bg-glass-hover: rgba(255, 255, 255, 0.06);
/* --- Border Colors (Dark) --- */
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.15);
--border-focus: rgba(59, 130, 246, 0.45);
/* --- Primary override for admin: Blue --- */
--primary-50: #EFF6FF;
--primary-100: #DBEAFE;
--primary-200: #BFDBFE;
--primary-300: #93C5FD;
--primary-400: #60A5FA;
--primary-500: #3B82F6;
--primary-600: #2563EB;
--primary-700: #1D4ED8;
--primary-800: #1E40AF;
--primary-900: #1E3A8A;
/* --- Primary Glow (Dark) --- */
--primary-glow-subtle: rgba(59, 130, 246, 0.06);
--primary-glow-light: rgba(59, 130, 246, 0.12);
--primary-glow-medium: rgba(59, 130, 246, 0.20);
--primary-glow-strong: rgba(59, 130, 246, 0.35);
/* --- Secondary: Slate (neutral, no pink) --- */
--secondary-400: #A78BFA;
--secondary-500: #8B5CF6;
--secondary-600: #7C3AED;
--secondary-glow: rgba(139, 92, 246, 0.12);
/* --- Accent: Amber (original admin accent) --- */
--accent-400: #FBBF24;
--accent-500: #F59E0B;
--accent-600: #D97706;
--accent-glow: rgba(245, 158, 11, 0.12);
/* --- Gradients: Blue-based (no purple-pink) --- */
--gradient-brand: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%);
--gradient-warm: linear-gradient(135deg, #F59E0B 0%, #EF4444 100%);
--gradient-cool: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
--gradient-fresh: linear-gradient(135deg, #10B981 0%, #3B82F6 100%);
--gradient-sunset: linear-gradient(135deg, #F59E0B 0%, #EF4444 50%, #8B5CF6 100%);
--gradient-aurora: linear-gradient(135deg, #3B82F6 0%, #06B6D4 50%, #10B981 100%);
/* --- Text Colors (Dark) --- */
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-tertiary: #64748B;
--text-disabled: #475569;
--text-inverse: #0B0D14;
/* --- Shadows (Dark) --- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.6);
/* --- Glow (Dark) --- */
--glow-primary: 0 0 20px rgba(59, 130, 246, 0.15), 0 0 60px rgba(59, 130, 246, 0.05);
--glow-primary-lg: 0 0 40px rgba(59, 130, 246, 0.20), 0 0 100px rgba(59, 130, 246, 0.08);
--glow-secondary: 0 0 20px rgba(139, 92, 246, 0.15);
--glow-accent: 0 0 20px rgba(16, 185, 129, 0.15);
/* --- Functional Glow (Dark) --- */
--warning-glow: rgba(245, 158, 11, 0.15);
--error-glow: rgba(239, 68, 68, 0.15);
--success-glow: rgba(34, 197, 94, 0.15);
--secondary-glow: rgba(139, 92, 246, 0.15);
--accent-glow: rgba(16, 185, 129, 0.15);
}
/* Admin 暗色侧边栏 */
.admin-theme .sidebar {
background: var(--bg-elevated);
border-right: 1px solid var(--border-subtle);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.3);
}
.admin-theme .sidebar .nav-item {
color: var(--text-secondary);
}
.admin-theme .sidebar .nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.admin-theme .sidebar .nav-item.active {
background: rgba(59, 130, 246, 0.12);
color: #60A5FA;
}
.admin-theme .sidebar-footer {
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
}
/* Admin 暗色顶部栏 */
.admin-theme .top-header {
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-subtle);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
/* Admin 暗色卡片 */
.admin-theme .stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
}
.admin-theme .stat-card:hover {
border-color: var(--border-strong);
}
.admin-theme .chart-card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
}
.admin-theme .chart-header {
border-bottom: 1px solid var(--border-subtle);
}
/* Admin 暗色表格 */
.admin-theme .member-table thead,
.admin-theme .order-table thead,
.admin-theme table thead {
background: var(--bg-elevated);
}
.admin-theme .member-table th,
.admin-theme .order-table th,
.admin-theme table th {
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.admin-theme .member-table td,
.admin-theme .order-table td,
.admin-theme table td {
border-bottom: 1px solid var(--border-subtle);
}
/* Admin 暗色搜索框 */
.admin-theme .search-input input,
.admin-theme .search-box input {
background: var(--bg-surface);
border: 1px solid var(--border-default);
color: var(--text-primary);
}
.admin-theme .search-input input:focus,
.admin-theme .search-box input:focus {
background: var(--bg-elevated);
border-color: var(--primary-500);
}
/* Admin 暗色内容区域 */
.admin-theme .main-content {
background: var(--bg-base);
}
.admin-theme .content-area {
background: var(--bg-base);
}
/* ============================================================
3. body.admin-mode — Teleported 组件的暗色覆盖
Modal / Dropdown / Popover 通过 Teleport 渲染到 body 外,
需要通过 body 级别覆盖 CSS 变量
============================================================ */
body.admin-mode {
--bg-root: #07090F;
--bg-base: #0B0D14;
--bg-surface: #111318;
--bg-elevated: #181B24;
--bg-overlay: #1E2130;
--bg-hover: rgba(255, 255, 255, 0.04);
--bg-active: rgba(255, 255, 255, 0.08);
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.15);
--border-focus: rgba(59, 130, 246, 0.45);
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-tertiary: #64748B;
--text-disabled: #475569;
--text-inverse: #0B0D14;
/* --- Primary: Blue (same as .admin-theme) --- */
--primary-50: #EFF6FF;
--primary-100: #DBEAFE;
--primary-200: #BFDBFE;
--primary-300: #93C5FD;
--primary-400: #60A5FA;
--primary-500: #3B82F6;
--primary-600: #2563EB;
--primary-700: #1D4ED8;
--primary-800: #1E40AF;
--primary-900: #1E3A8A;
--primary-glow-subtle: rgba(59, 130, 246, 0.06);
--primary-glow-light: rgba(59, 130, 246, 0.12);
--primary-glow-medium: rgba(59, 130, 246, 0.20);
--primary-glow-strong: rgba(59, 130, 246, 0.35);
/* --- Secondary / Accent / Gradients: Blue-based --- */
--secondary-400: #A78BFA;
--secondary-500: #8B5CF6;
--secondary-600: #7C3AED;
--secondary-glow: rgba(139, 92, 246, 0.12);
--accent-400: #FBBF24;
--accent-500: #F59E0B;
--accent-600: #D97706;
--accent-glow: rgba(245, 158, 11, 0.12);
--gradient-brand: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%);
--gradient-warm: linear-gradient(135deg, #F59E0B 0%, #EF4444 100%);
--gradient-cool: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
--gradient-fresh: linear-gradient(135deg, #10B981 0%, #3B82F6 100%);
--gradient-sunset: linear-gradient(135deg, #F59E0B 0%, #EF4444 50%, #8B5CF6 100%);
--gradient-aurora: linear-gradient(135deg, #3B82F6 0%, #06B6D4 50%, #10B981 100%);
/* --- Glows (Dark) --- */
--glow-primary: 0 0 20px rgba(59, 130, 246, 0.15), 0 0 60px rgba(59, 130, 246, 0.05);
--glow-primary-lg: 0 0 40px rgba(59, 130, 246, 0.20), 0 0 100px rgba(59, 130, 246, 0.08);
--glow-secondary: 0 0 20px rgba(139, 92, 246, 0.15);
--glow-accent: 0 0 20px rgba(245, 158, 11, 0.15);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.6);
}
/* Modal 弹窗 — 暗色 */
body.admin-mode .ant-modal-content {
background: #181B24 !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5) !important;
}
body.admin-mode .ant-modal-header {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
}
body.admin-mode .ant-modal-title {
color: #F1F5F9 !important;
}
body.admin-mode .ant-modal-body {
color: #94A3B8 !important;
}
body.admin-mode .ant-modal-close {
color: #64748B !important;
}
body.admin-mode .ant-modal-close:hover {
color: #F1F5F9 !important;
}
body.admin-mode .ant-modal-footer {
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
}
/* Modal.confirm 确认弹窗 — 暗色 */
body.admin-mode .ant-modal-confirm .ant-modal-content {
background: #181B24 !important;
}
body.admin-mode .ant-modal-confirm-title {
color: #F1F5F9 !important;
}
body.admin-mode .ant-modal-confirm-content {
color: #94A3B8 !important;
}
/* Dropdown — 暗色 */
body.admin-mode .ant-dropdown-menu {
background: #1E2130 !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45) !important;
}
body.admin-mode .ant-dropdown-menu-item {
color: #94A3B8 !important;
}
body.admin-mode .ant-dropdown-menu-item:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: #F1F5F9 !important;
}
/* Select — 暗色 */
body.admin-mode .ant-select-dropdown {
background: #1E2130 !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45) !important;
}
body.admin-mode .ant-select-item {
color: #94A3B8 !important;
}
body.admin-mode .ant-select-item-option-active,
body.admin-mode .ant-select-item-option-selected {
background: rgba(255, 255, 255, 0.06) !important;
color: #F1F5F9 !important;
}
/* Popover / Tooltip — 暗色 */
body.admin-mode .ant-popover-inner {
background: #1E2130 !important;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45) !important;
}
body.admin-mode .ant-tooltip-inner {
background: rgba(0, 0, 0, 0.85) !important;
}
/* Message — 暗色 */
body.admin-mode .ant-message-notice-content {
background: #1E2130 !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #F1F5F9 !important;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45) !important;
}
/* Notification — 暗色 */
body.admin-mode .ant-notification-notice {
background: #1E2130 !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45) !important;
}
body.admin-mode .ant-notification-notice-message {
color: #F1F5F9 !important;
}
body.admin-mode .ant-notification-notice-description {
color: #94A3B8 !important;
}
/* Popconfirm — 暗色 */
body.admin-mode .ant-popconfirm .ant-popover-inner {
background: #1E2130 !important;
}
body.admin-mode .ant-popconfirm-message-title {
color: #F1F5F9 !important;
}
/* ============================================================
4. Gradient Utilities
============================================================ */
.gradient-primary {
background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-700) 100%);
}
.gradient-secondary {
background: linear-gradient(135deg, var(--secondary-500) 0%, var(--primary-600) 100%);
}
.gradient-accent {
background: linear-gradient(135deg, var(--primary-500) 0%, var(--accent-500) 100%);
}
.gradient-text {
background: linear-gradient(135deg, var(--primary-500), var(--secondary-500));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-surface {
background: linear-gradient(180deg, var(--bg-surface) 0%, var(--bg-base) 100%);
}
/* ============================================================
5. Glass Morphism Utility
============================================================ */
.glass {
background: var(--bg-glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border-subtle);
}
.glass-strong {
background: var(--bg-glass-hover);
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
border: 1px solid var(--border-default);
}
/* ============================================================
6. Animations
============================================================ */
@keyframes breathe {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px var(--primary-glow-subtle); }
50% { box-shadow: 0 0 40px var(--primary-glow-medium); }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes progress-sweep {
0% { transform: translateX(-100%); }
50% { transform: translateX(300%); }
100% { transform: translateX(-100%); }
}
@keyframes gradient-flow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes grid-move {
0% { transform: perspective(500px) rotateX(60deg) translateY(0); }
100% { transform: perspective(500px) rotateX(60deg) translateY(40px); }
}
/* ============================================================
7. Scrollbar — 自适应主题
============================================================ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.22);
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.12) transparent;
}
/* Admin 暗色滚动条 */
.admin-theme ::-webkit-scrollbar-thumb,
body.admin-mode ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
}
.admin-theme ::-webkit-scrollbar-thumb:hover,
body.admin-mode ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.22);
}
.admin-theme *,
body.admin-mode * {
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
}
/* ============================================================
8. Focus & Selection
============================================================ */
*:focus-visible {
outline: 2px solid var(--primary-500);
outline-offset: 2px;
}
::selection {
background: rgba(8, 145, 178, 0.20);
color: var(--text-primary);
}
.admin-theme ::selection {
background: rgba(59, 130, 246, 0.25);
color: var(--text-primary);
}

View File

@@ -0,0 +1,895 @@
/* UI-UX-Pro-Max Design System - Light Theme */
:root {
/* ========== Color Palette ========== */
--color-primary: #6366F1; /* Indigo */
--color-primary-light: #818CF8;
--color-primary-dark: #4F46E5;
--color-primary-50: #EEF2FF;
--color-primary-100: #E0E7FF;
--color-primary-200: #C7D2FE;
--color-primary-300: #A5B4FC;
--color-primary-400: #818CF8;
--color-primary-500: #6366F1;
--color-primary-600: #4F46E5;
--color-primary-700: #4338CA;
--color-primary-800: #3730A3;
--color-primary-900: #312E81;
--color-secondary: #10B981; /* Emerald */
--color-secondary-light: #34D399;
--color-secondary-dark: #059669;
--color-accent: #F59E0B; /* Amber */
--color-accent-light: #FBBF24;
--color-accent-dark: #D97706;
--color-danger: #EF4444; /* Red */
--color-danger-light: #FCA5A5;
--color-danger-dark: #DC2626;
--color-warning: #F59E0B; /* Amber */
--color-success: #10B981; /* Emerald */
--color-info: #3B82F6; /* Blue */
/* ========== Neutral Colors (Light Theme) ========== */
--color-background: #F9FAFB;
--color-surface: #FFFFFF;
--color-surface-hover: #F3F4F6;
--color-surface-active: #E5E7EB;
--color-text-primary: #111827;
--color-text-secondary: #4B5563;
--color-text-tertiary: #6B7280;
--color-text-quaternary: #9CA3AF;
--color-border: #E5E7EB;
--color-border-light: #F3F4F6;
--color-border-dark: #D1D5DB;
/* ========== Fonts ========== */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
/* ========== Font Sizes ========== */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
/* ========== Font Weights ========== */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* ========== Line Heights ========== */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 2;
/* ========== Letter Spacing ========== */
--tracking-tighter: -0.05em;
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* ========== Spacing ========== */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-7: 1.75rem; /* 28px */
--space-8: 2rem; /* 32px */
--space-9: 2.25rem; /* 36px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
--space-32: 8rem; /* 128px */
/* ========== Border Radius ========== */
--radius-sm: 0.125rem; /* 2px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: 0.5rem; /* 8px */
--radius-xl: 0.75rem; /* 12px */
--radius-2xl: 1rem; /* 16px */
--radius-3xl: 1.5rem; /* 24px */
--radius-full: 9999px;
/* ========== Shadows ========== */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
/* ========== Glow Effects ========== */
--glow-primary: 0 0 15px rgba(99, 102, 241, 0.3);
--glow-secondary: 0 0 15px rgba(16, 185, 129, 0.3);
--glow-accent: 0 0 15px rgba(245, 158, 11, 0.3);
--glow-danger: 0 0 15px rgba(239, 68, 68, 0.3);
/* ========== Transitions ========== */
--transition-none: none;
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
--transition-all: all var(--transition-normal);
--transition-colors: color var(--transition-normal), background-color var(--transition-normal), border-color var(--transition-normal), text-decoration-color var(--transition-normal), fill var(--transition-normal), stroke var(--transition-normal);
--transition-transform: transform var(--transition-normal);
--transition-opacity: opacity var(--transition-normal);
--transition-shadow: box-shadow var(--transition-normal);
--transition-border: border-color var(--transition-normal), box-shadow var(--transition-normal);
/* ========== Z-Index ========== */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
/* ========== Breakpoints ========== */
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
/* ========== Animations ========== */
--ease-linear: linear;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ========== Glass Morphism (Light Theme) ========== */
/* Surface layers — from subtle to heavy */
--glass-subtle: rgba(255, 255, 255, 0.8);
--glass-surface: rgba(255, 255, 255, 0.9);
--glass-light: rgba(255, 255, 255, 0.95);
--glass-medium: rgba(255, 255, 255, 0.98);
--glass-heavy: rgba(255, 255, 255, 1);
--glass-solid: rgba(255, 255, 255, 1);
/* Backdrop blur levels */
--glass-blur-sm: blur(8px);
--glass-blur-md: blur(16px);
--glass-blur: blur(24px);
--glass-blur-lg: blur(40px);
--glass-blur-xl: blur(64px);
/* Borders */
--glass-border-subtle: rgba(0, 0, 0, 0.06);
--glass-border: rgba(0, 0, 0, 0.10);
--glass-border-strong: rgba(0, 0, 0, 0.16);
/* Glass shadow — soft halo effect */
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.95);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.95);
/* Shorthand backdrop (kept for backward compat) */
--glass-backdrop: blur(24px) saturate(100%);
}
/* ========== Base Styles ========== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-normal);
line-height: var(--leading-normal);
color: var(--color-text-primary);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ========== Typography ========== */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
color: var(--color-text-primary);
margin-bottom: var(--space-4);
}
h1 {
font-size: var(--text-4xl);
}
h2 {
font-size: var(--text-3xl);
}
h3 {
font-size: var(--text-2xl);
}
h4 {
font-size: var(--text-xl);
}
h5 {
font-size: var(--text-lg);
}
h6 {
font-size: var(--text-base);
}
p {
margin-bottom: var(--space-4);
color: var(--color-text-secondary);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: var(--transition-colors);
}
a:hover {
color: var(--color-primary-light);
text-decoration: underline;
}
/* ========== Buttons ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-medium);
line-height: 1;
color: var(--color-text-primary);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition-all);
text-align: center;
white-space: nowrap;
user-select: none;
}
.btn:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-border-dark);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* ========== Button Variants ========== */
.btn-primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-dark);
border-color: var(--color-primary-dark);
box-shadow: var(--glow-primary);
}
.btn-secondary {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
color: white;
}
.btn-secondary:hover {
background-color: var(--color-secondary-dark);
border-color: var(--color-secondary-dark);
box-shadow: var(--glow-secondary);
}
.btn-accent {
background-color: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.btn-accent:hover {
background-color: var(--color-accent-dark);
border-color: var(--color-accent-dark);
box-shadow: var(--glow-accent);
}
.btn-danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
.btn-danger:hover {
background-color: var(--color-danger-dark);
border-color: var(--color-danger-dark);
box-shadow: var(--glow-danger);
}
.btn-outline {
background-color: transparent;
border-color: var(--color-border);
color: var(--color-text-primary);
}
.btn-outline:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-border-dark);
}
.btn-ghost {
background-color: transparent;
border-color: transparent;
color: var(--color-text-primary);
}
.btn-ghost:hover {
background-color: var(--color-surface-hover);
border-color: transparent;
}
/* ========== Button Sizes ========== */
.btn-sm {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
border-radius: var(--radius-md);
}
.btn-md {
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
border-radius: var(--radius-lg);
}
.btn-lg {
padding: var(--space-4) var(--space-8);
font-size: var(--text-lg);
border-radius: var(--radius-xl);
}
.btn-xl {
padding: var(--space-5) var(--space-10);
font-size: var(--text-xl);
border-radius: var(--radius-2xl);
}
/* ========== Inputs ========== */
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-normal);
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: var(--transition-border);
outline: none;
}
.input:hover {
border-color: var(--color-border-dark);
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.input::placeholder {
color: var(--color-text-tertiary);
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ========== Input Sizes ========== */
.input-sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
border-radius: var(--radius-md);
}
.input-md {
padding: var(--space-3) var(--space-4);
font-size: var(--text-base);
border-radius: var(--radius-lg);
}
.input-lg {
padding: var(--space-4) var(--space-5);
font-size: var(--text-lg);
border-radius: var(--radius-xl);
}
/* ========== Cards ========== */
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-6);
box-shadow: var(--shadow-md);
transition: var(--transition-all);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-header {
margin-bottom: var(--space-4);
}
.card-title {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text-primary);
margin-bottom: var(--space-2);
}
.card-subtitle {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.card-body {
margin-bottom: var(--space-4);
}
.card-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-light);
}
/* ========== Tabs ========== */
.tabs {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border-light);
}
.tab {
padding: var(--space-3) var(--space-4);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
background-color: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: var(--radius-md) var(--radius-md) 0 0;
cursor: pointer;
transition: var(--transition-all);
position: relative;
overflow: hidden;
}
.tab:hover {
color: var(--color-text-primary);
background-color: var(--color-surface-hover);
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tab.active::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--color-primary);
animation: tabSlideIn 0.3s var(--ease-out);
}
/* ========== Animations ========== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes breathe {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes tabSlideIn {
from {
width: 0;
left: 50%;
}
to {
width: 100%;
left: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
transform: translate3d(0,0,0);
}
40%, 43% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -8px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0,-2px,0);
}
}
/* ========== Utilities ========== */
.fade-in {
animation: fadeIn 0.5s var(--ease-out) both;
}
.pulse {
animation: pulse 2s var(--ease-in-out) infinite;
}
.breathe {
animation: breathe 4s var(--ease-in-out) infinite;
}
.spin {
animation: spin 1s var(--ease-linear) infinite;
}
.bounce {
animation: bounce 1s var(--ease-out);
}
/* ========== Flex Utilities ========== */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.gap-6 {
gap: var(--space-6);
}
/* ========== Text Utilities ========== */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-danger {
color: var(--color-danger);
}
.text-warning {
color: var(--color-warning);
}
.text-success {
color: var(--color-success);
}
.text-sm {
font-size: var(--text-sm);
}
.text-base {
font-size: var(--text-base);
}
.text-lg {
font-size: var(--text-lg);
}
.text-xl {
font-size: var(--text-xl);
}
.font-medium {
font-weight: var(--font-medium);
}
.font-semibold {
font-weight: var(--font-semibold);
}
.font-bold {
font-weight: var(--font-bold);
}
/* ========== Spacing Utilities ========== */
.m-0 {
margin: 0;
}
.m-4 {
margin: var(--space-4);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mt-4 {
margin-top: var(--space-4);
}
.p-0 {
padding: 0;
}
.p-4 {
padding: var(--space-4);
}
.py-4 {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
.px-4 {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
/* ========== Responsive Utilities ========== */
@media (max-width: 768px) {
.md\:hidden {
display: none;
}
.md\:block {
display: block;
}
.md\:flex {
display: flex;
}
.md\:items-center {
align-items: center;
}
.md\:justify-center {
justify-content: center;
}
}
@media (max-width: 640px) {
.sm\:hidden {
display: none;
}
.sm\:block {
display: block;
}
.sm\:flex {
display: flex;
}
.sm\:items-center {
align-items: center;
}
.sm\:justify-center {
justify-content: center;
}
}
/* ========== Accessibility ========== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ========== Print Styles ========== */
@media print {
*, *::before, *::after {
background: #fff !important;
color: #000 !important;
box-shadow: none !important;
text-shadow: none !important;
}
a, a:visited {
text-decoration: underline;
}
a[href]::after {
content: " (" attr(href) ")";
}
@page {
margin: 1cm;
}
}
/* ========== Light Mode Optimization ========== */
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
}
body {
color-scheme: light;
}
}
/* ========== End of UI-UX-Pro-Max Design System - Light Theme ========== */

View File

@@ -0,0 +1,895 @@
/* UI-UX-Pro-Max Design System */
:root {
/* ========== Color Palette ========== */
--color-primary: #6366F1; /* Indigo */
--color-primary-light: #818CF8;
--color-primary-dark: #4F46E5;
--color-primary-50: #EEF2FF;
--color-primary-100: #E0E7FF;
--color-primary-200: #C7D2FE;
--color-primary-300: #A5B4FC;
--color-primary-400: #818CF8;
--color-primary-500: #6366F1;
--color-primary-600: #4F46E5;
--color-primary-700: #4338CA;
--color-primary-800: #3730A3;
--color-primary-900: #312E81;
--color-secondary: #10B981; /* Emerald */
--color-secondary-light: #34D399;
--color-secondary-dark: #059669;
--color-accent: #F59E0B; /* Amber */
--color-accent-light: #FBBF24;
--color-accent-dark: #D97706;
--color-danger: #EF4444; /* Red */
--color-danger-light: #FCA5A5;
--color-danger-dark: #DC2626;
--color-warning: #F59E0B; /* Amber */
--color-success: #10B981; /* Emerald */
--color-info: #3B82F6; /* Blue */
/* ========== Neutral Colors ========== */
--color-background: #0F172A;
--color-surface: rgba(255, 255, 255, 0.06);
--color-surface-hover: rgba(255, 255, 255, 0.08);
--color-surface-active: rgba(255, 255, 255, 0.12);
--color-text-primary: #F1F5F9;
--color-text-secondary: #94A3B8;
--color-text-tertiary: #64748B;
--color-text-quaternary: #475569;
--color-border: rgba(255, 255, 255, 0.12);
--color-border-light: rgba(255, 255, 255, 0.08);
--color-border-dark: rgba(255, 255, 255, 0.16);
/* ========== Fonts ========== */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
/* ========== Font Sizes ========== */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
/* ========== Font Weights ========== */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
/* ========== Line Heights ========== */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 2;
/* ========== Letter Spacing ========== */
--tracking-tighter: -0.05em;
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* ========== Spacing ========== */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-7: 1.75rem; /* 28px */
--space-8: 2rem; /* 32px */
--space-9: 2.25rem; /* 36px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
--space-32: 8rem; /* 128px */
/* ========== Border Radius ========== */
--radius-sm: 0.125rem; /* 2px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: 0.5rem; /* 8px */
--radius-xl: 0.75rem; /* 12px */
--radius-2xl: 1rem; /* 16px */
--radius-3xl: 1.5rem; /* 24px */
--radius-full: 9999px;
/* ========== Shadows ========== */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
/* ========== Glow Effects ========== */
--glow-primary: 0 0 15px rgba(99, 102, 241, 0.5);
--glow-secondary: 0 0 15px rgba(16, 185, 129, 0.5);
--glow-accent: 0 0 15px rgba(245, 158, 11, 0.5);
--glow-danger: 0 0 15px rgba(239, 68, 68, 0.5);
/* ========== Transitions ========== */
--transition-none: none;
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
--transition-all: all var(--transition-normal);
--transition-colors: color var(--transition-normal), background-color var(--transition-normal), border-color var(--transition-normal), text-decoration-color var(--transition-normal), fill var(--transition-normal), stroke var(--transition-normal);
--transition-transform: transform var(--transition-normal);
--transition-opacity: opacity var(--transition-normal);
--transition-shadow: box-shadow var(--transition-normal);
--transition-border: border-color var(--transition-normal), box-shadow var(--transition-normal);
/* ========== Z-Index ========== */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
/* ========== Breakpoints ========== */
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
/* ========== Animations ========== */
--ease-linear: linear;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ========== Glass Morphism ========== */
/* Surface layers — from subtle to heavy */
--glass-subtle: rgba(255, 255, 255, 0.03);
--glass-surface: rgba(255, 255, 255, 0.06);
--glass-light: rgba(255, 255, 255, 0.08);
--glass-medium: rgba(255, 255, 255, 0.12);
--glass-heavy: rgba(255, 255, 255, 0.18);
--glass-solid: rgba(15, 23, 42, 0.75);
/* Backdrop blur levels */
--glass-blur-sm: blur(8px);
--glass-blur-md: blur(16px);
--glass-blur: blur(24px);
--glass-blur-lg: blur(40px);
--glass-blur-xl: blur(64px);
/* Borders */
--glass-border-subtle: rgba(255, 255, 255, 0.06);
--glass-border: rgba(255, 255, 255, 0.10);
--glass-border-strong: rgba(255, 255, 255, 0.16);
/* Glass shadow — soft halo effect */
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.06);
/* Shorthand backdrop (kept for backward compat) */
--glass-backdrop: blur(24px) saturate(180%);
}
/* ========== Base Styles ========== */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-normal);
line-height: var(--leading-normal);
color: var(--color-text-primary);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ========== Typography ========== */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
color: var(--color-text-primary);
margin-bottom: var(--space-4);
}
h1 {
font-size: var(--text-4xl);
}
h2 {
font-size: var(--text-3xl);
}
h3 {
font-size: var(--text-2xl);
}
h4 {
font-size: var(--text-xl);
}
h5 {
font-size: var(--text-lg);
}
h6 {
font-size: var(--text-base);
}
p {
margin-bottom: var(--space-4);
color: var(--color-text-secondary);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: var(--transition-colors);
}
a:hover {
color: var(--color-primary-light);
text-decoration: underline;
}
/* ========== Buttons ========== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-medium);
line-height: 1;
color: var(--color-text-primary);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition-all);
text-align: center;
white-space: nowrap;
user-select: none;
}
.btn:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-border-dark);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* ========== Button Variants ========== */
.btn-primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-dark);
border-color: var(--color-primary-dark);
box-shadow: var(--glow-primary);
}
.btn-secondary {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
color: white;
}
.btn-secondary:hover {
background-color: var(--color-secondary-dark);
border-color: var(--color-secondary-dark);
box-shadow: var(--glow-secondary);
}
.btn-accent {
background-color: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.btn-accent:hover {
background-color: var(--color-accent-dark);
border-color: var(--color-accent-dark);
box-shadow: var(--glow-accent);
}
.btn-danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
.btn-danger:hover {
background-color: var(--color-danger-dark);
border-color: var(--color-danger-dark);
box-shadow: var(--glow-danger);
}
.btn-outline {
background-color: transparent;
border-color: var(--color-border);
color: var(--color-text-primary);
}
.btn-outline:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-border-dark);
}
.btn-ghost {
background-color: transparent;
border-color: transparent;
color: var(--color-text-primary);
}
.btn-ghost:hover {
background-color: var(--color-surface-hover);
border-color: transparent;
}
/* ========== Button Sizes ========== */
.btn-sm {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
border-radius: var(--radius-md);
}
.btn-md {
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
border-radius: var(--radius-lg);
}
.btn-lg {
padding: var(--space-4) var(--space-8);
font-size: var(--text-lg);
border-radius: var(--radius-xl);
}
.btn-xl {
padding: var(--space-5) var(--space-10);
font-size: var(--text-xl);
border-radius: var(--radius-2xl);
}
/* ========== Inputs ========== */
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-normal);
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition: var(--transition-border);
outline: none;
}
.input:hover {
border-color: var(--color-border-dark);
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.input::placeholder {
color: var(--color-text-tertiary);
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ========== Input Sizes ========== */
.input-sm {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
border-radius: var(--radius-md);
}
.input-md {
padding: var(--space-3) var(--space-4);
font-size: var(--text-base);
border-radius: var(--radius-lg);
}
.input-lg {
padding: var(--space-4) var(--space-5);
font-size: var(--text-lg);
border-radius: var(--radius-xl);
}
/* ========== Cards ========== */
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-6);
box-shadow: var(--shadow-md);
transition: var(--transition-all);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-header {
margin-bottom: var(--space-4);
}
.card-title {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text-primary);
margin-bottom: var(--space-2);
}
.card-subtitle {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.card-body {
margin-bottom: var(--space-4);
}
.card-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-light);
}
/* ========== Tabs ========== */
.tabs {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border-light);
}
.tab {
padding: var(--space-3) var(--space-4);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
background-color: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: var(--radius-md) var(--radius-md) 0 0;
cursor: pointer;
transition: var(--transition-all);
position: relative;
overflow: hidden;
}
.tab:hover {
color: var(--color-text-primary);
background-color: var(--color-surface-hover);
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tab.active::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--color-primary);
animation: tabSlideIn 0.3s var(--ease-out);
}
/* ========== Animations ========== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes breathe {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes tabSlideIn {
from {
width: 0;
left: 50%;
}
to {
width: 100%;
left: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
transform: translate3d(0,0,0);
}
40%, 43% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -8px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0,-2px,0);
}
}
/* ========== Utilities ========== */
.fade-in {
animation: fadeIn 0.5s var(--ease-out) both;
}
.pulse {
animation: pulse 2s var(--ease-in-out) infinite;
}
.breathe {
animation: breathe 4s var(--ease-in-out) infinite;
}
.spin {
animation: spin 1s var(--ease-linear) infinite;
}
.bounce {
animation: bounce 1s var(--ease-out);
}
/* ========== Flex Utilities ========== */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.gap-6 {
gap: var(--space-6);
}
/* ========== Text Utilities ========== */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-danger {
color: var(--color-danger);
}
.text-warning {
color: var(--color-warning);
}
.text-success {
color: var(--color-success);
}
.text-sm {
font-size: var(--text-sm);
}
.text-base {
font-size: var(--text-base);
}
.text-lg {
font-size: var(--text-lg);
}
.text-xl {
font-size: var(--text-xl);
}
.font-medium {
font-weight: var(--font-medium);
}
.font-semibold {
font-weight: var(--font-semibold);
}
.font-bold {
font-weight: var(--font-bold);
}
/* ========== Spacing Utilities ========== */
.m-0 {
margin: 0;
}
.m-4 {
margin: var(--space-4);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mt-4 {
margin-top: var(--space-4);
}
.p-0 {
padding: 0;
}
.p-4 {
padding: var(--space-4);
}
.py-4 {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
.px-4 {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
/* ========== Responsive Utilities ========== */
@media (max-width: 768px) {
.md\:hidden {
display: none;
}
.md\:block {
display: block;
}
.md\:flex {
display: flex;
}
.md\:items-center {
align-items: center;
}
.md\:justify-center {
justify-content: center;
}
}
@media (max-width: 640px) {
.sm\:hidden {
display: none;
}
.sm\:block {
display: block;
}
.sm\:flex {
display: flex;
}
.sm\:items-center {
align-items: center;
}
.sm\:justify-center {
justify-content: center;
}
}
/* ========== Accessibility ========== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ========== Print Styles ========== */
@media print {
*, *::before, *::after {
background: #fff !important;
color: #000 !important;
box-shadow: none !important;
text-shadow: none !important;
}
a, a:visited {
text-decoration: underline;
}
a[href]::after {
content: " (" attr(href) ")";
}
@page {
margin: 1cm;
}
}
/* ========== Dark Mode Optimization ========== */
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
body {
color-scheme: dark;
}
}
/* ========== End of UI-UX-Pro-Max Design System ========== */

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

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

View File

@@ -0,0 +1,659 @@
<template>
<div class="admin-dashboard admin-theme">
<AdminSidebar active="dashboard" />
<!-- 主内容区域 -->
<main class="main-content">
<AdminHeader :title="$t('nav.dashboard')" />
<!-- 统计卡片 -->
<div class="stats-cards" :spinning="loading">
<div class="stat-card">
<div class="stat-icon users">
<UserOutlined />
</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">
<UserOutlined />
</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">
<DollarOutlined />
</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">
<a-button size="small" @click="changeYear(-1)" :disabled="selectedYear <= 2025"><template #icon><LeftOutlined /></template></a-button>
<span class="year-display">{{ selectedYear }}{{ $t('dashboard.yearSuffix') }}</span>
<a-button size="small" @click="changeYear(1)" :disabled="selectedYear >= 2099"><template #icon><RightOutlined /></template></a-button>
</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">
<a-button size="small" @click="changeYear2(-1)" :disabled="selectedYear2 <= 2025"><template #icon><LeftOutlined /></template></a-button>
<span class="year-display">{{ selectedYear2 }}{{ $t('dashboard.yearSuffix') }}</span>
<a-button size="small" @click="changeYear2(1)" :disabled="selectedYear2 >= 2099"><template #icon><RightOutlined /></template></a-button>
</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 { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
UserOutlined,
LeftOutlined,
RightOutlined,
DollarOutlined
} from '@ant-design/icons-vue'
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend } from '@/api/dashboard'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
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 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 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)
message.error(t('dashboard.loadDataFailed') + ': ' + (data.message || t('dashboard.unknownError')))
}
} catch (error) {
console.error('Load dashboard data failed:', error)
message.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)
}
}
// 页面加载时获取数据
onMounted(async () => {
console.log('后台管理页面加载完成')
await loadDashboardData()
await nextTick()
await loadDailyActiveChart()
await loadConversionChart()
})
// 组件卸载时清理图表
onUnmounted(() => {
if (dailyActiveChartInstance) {
dailyActiveChartInstance.dispose()
dailyActiveChartInstance = null
}
if (conversionChartInstance) {
conversionChartInstance.dispose()
conversionChartInstance = null
}
})
</script>
<style scoped>
.admin-dashboard {
display: flex;
height: 100vh;
background: var(--bg-base);
font-family: var(--font-sans);
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-base);
}
/* 统计卡片 */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
padding: var(--space-6);
}
.stat-card {
background: var(--bg-surface);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: var(--space-4);
transition: var(--transition-all);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--border-default);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
}
.stat-icon.users {
background: var(--warning-glow);
color: var(--warning-400);
}
.stat-icon.paid-users {
background: var(--primary-glow-light);
color: var(--primary-400);
}
.stat-icon.revenue {
background: rgba(236, 72, 153, 0.10);
color: #db2777;
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-bottom: var(--space-2);
font-weight: var(--font-medium);
}
.stat-number {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.stat-change {
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
.stat-change.positive {
color: var(--success-400);
}
.stat-change.negative {
color: var(--error-400);
}
/* 图表区域 */
.charts-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
padding: 0 var(--space-6) var(--space-6);
}
.chart-card {
background: var(--bg-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-subtle);
overflow: hidden;
}
.chart-header {
padding: var(--space-5) var(--space-6) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-header h3 {
margin: 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.year-picker {
display: flex;
align-items: center;
gap: var(--space-2);
}
.year-display {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
min-width: 60px;
text-align: center;
}
.year-picker .ant-btn {
--el-button-bg-color: transparent;
--el-button-border-color: var(--border-default);
--el-button-hover-bg-color: var(--bg-hover);
--el-button-hover-border-color: var(--primary-500);
}
.year-select {
width: 100px;
}
.chart-content {
padding: var(--space-6);
height: 300px;
}
.chart-placeholder {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
border-radius: var(--radius-md);
border: 2px dashed var(--border-default);
}
.chart-title {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.chart-description {
font-size: var(--text-sm);
color: var(--text-tertiary);
text-align: center;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.charts-section {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.admin-dashboard {
flex-direction: column;
}
.stats-cards {
grid-template-columns: 1fr;
padding: var(--space-4);
}
.charts-section {
padding: 0 var(--space-4) var(--space-4);
}
}
@media (max-width: 480px) {
.stat-card {
padding: var(--space-4);
}
.stat-number {
font-size: var(--text-2xl);
}
.chart-content {
padding: var(--space-4);
height: 250px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,659 @@
<template>
<div class="admin-workflow-videos admin-theme">
<AdminSidebar active="workflowVideos" />
<!-- 主内容区域 -->
<main class="main-content">
<AdminHeader :title="$t('nav.workflowVideos')" />
<!-- 视频管理内容 -->
<section class="video-content">
<div class="content-header">
<h2>{{ t('workflowVideo.management') }}</h2>
<a-button type="primary" size="small" @click="openCreateDialog">
<PlusOutlined /> {{ t('workflowVideo.addVideo') }}
</a-button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>{{ t('workflowVideo.thumbnail') }}</th>
<th>{{ t('workflowVideo.videoTitle') }}</th>
<th>{{ t('workflowVideo.tags') }}</th>
<th>{{ t('workflowVideo.sortOrder') }}</th>
<th>{{ t('workflowVideo.status') }}</th>
<th>{{ t('workflowVideo.createdAt') }}</th>
<th>{{ t('workflowVideo.operation') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="video in videoList" :key="video.id" class="table-row">
<td>{{ video.id }}</td>
<td>
<img
v-if="video.thumbnailUrl"
:src="video.thumbnailUrl"
class="thumb-preview"
@click="previewVideo(video)"
/>
<span v-else class="no-thumb">-</span>
</td>
<td>{{ video.title }}</td>
<td>
<span class="tag" v-for="tag in parseTags(video.tags)" :key="tag">{{ tag }}</span>
</td>
<td>{{ video.sortOrder }}</td>
<td>
<span class="status-tag" :class="video.isActive ? 'active' : 'inactive'">
{{ video.isActive ? t('workflowVideo.enabled') : t('workflowVideo.disabled') }}
</span>
</td>
<td>{{ formatDate(video.createdAt) }}</td>
<td>
<a class="action-link" @click="editVideo(video)">{{ t('common.edit') }}</a>
<a class="action-link" @click="toggleActive(video)">
{{ video.isActive ? t('workflowVideo.disable') : t('workflowVideo.enable') }}
</a>
<a class="action-link danger" @click="handleDelete(video)">{{ t('common.delete') }}</a>
</td>
</tr>
<tr v-if="videoList.length === 0">
<td colspan="8" class="empty-cell">{{ t('workflowVideo.noVideos') }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination-container" v-if="totalPages > 1">
<div class="pagination">
<span class="page-arrow" @click="prevPage" :class="{ disabled: currentPage === 0 }"><LeftOutlined /></span>
<button
v-for="page in visiblePageNumbers"
:key="page"
class="page-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page)"
>
{{ page + 1 }}
</button>
<span class="page-arrow" @click="nextPage" :class="{ disabled: currentPage >= totalPages - 1 }"><RightOutlined /></span>
</div>
</div>
</section>
</main>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="dialogVisible"
:title="isEditing ? t('workflowVideo.editVideo') : t('workflowVideo.addVideo')"
width="600px"
@cancel="dialogVisible = false"
>
<a-form :model="form" layout="vertical">
<a-form-item :label="t('workflowVideo.videoTitle')" required>
<a-input v-model:value="form.title" :placeholder="t('workflowVideo.titlePlaceholder')" />
</a-form-item>
<a-form-item :label="t('workflowVideo.description')">
<a-textarea v-model:value="form.description" :rows="3" :placeholder="t('workflowVideo.descriptionPlaceholder')" />
</a-form-item>
<a-form-item :label="t('workflowVideo.tags')">
<a-input v-model:value="form.tags" :placeholder="t('workflowVideo.tagsPlaceholder')" />
</a-form-item>
<a-form-item :label="t('workflowVideo.sortOrder')">
<a-input-number v-model:value="form.sortOrder" :min="0" :max="9999" />
</a-form-item>
<a-form-item :label="t('workflowVideo.enableStatus')">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
<!-- 上传视频 -->
<a-form-item :label="t('workflowVideo.videoFile')">
<div class="upload-area">
<input type="file" accept="video/*" @change="handleVideoUpload" ref="videoInputRef" class="file-input" />
<a-button :loading="uploadingVideo" @click="$refs.videoInputRef.click()">
<UploadOutlined /> {{ t('workflowVideo.uploadVideo') }}
</a-button>
<span v-if="form.videoUrl" class="upload-status success"> {{ t('workflowVideo.uploaded') }}</span>
</div>
<div v-if="form.videoUrl" class="preview-url">{{ form.videoUrl }}</div>
</a-form-item>
<!-- 上传缩略图 -->
<a-form-item :label="t('workflowVideo.thumbnailFile')">
<div class="upload-area">
<input type="file" accept="image/*" @change="handleThumbnailUpload" ref="thumbInputRef" class="file-input" />
<a-button :loading="uploadingThumb" @click="$refs.thumbInputRef.click()">
<UploadOutlined /> {{ t('workflowVideo.uploadThumbnail') }}
</a-button>
<span v-if="form.thumbnailUrl" class="upload-status success"> {{ t('workflowVideo.uploaded') }}</span>
</div>
<img v-if="form.thumbnailUrl" :src="form.thumbnailUrl" class="thumb-form-preview" />
</a-form-item>
</a-form>
<template #footer>
<a-button @click="dialogVisible = false">{{ t('common.cancel') }}</a-button>
<a-button type="primary" @click="saveVideo" :loading="saving">{{ t('common.save') }}</a-button>
</template>
</a-modal>
<!-- 视频预览弹窗 -->
<a-modal
v-model:open="previewVisible"
:title="previewItem?.title"
width="70%"
:footer="null"
:destroy-on-close="true"
>
<video
v-if="previewItem"
:src="previewItem.videoUrl"
:poster="previewItem.thumbnailUrl"
controls
autoplay
style="width: 100%; max-height: 70vh;"
></video>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { message, Modal } from 'ant-design-vue'
import {
LeftOutlined,
RightOutlined,
PlusOutlined,
UploadOutlined
} from '@ant-design/icons-vue'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
import {
getAdminWorkflowVideos,
createWorkflowVideo,
updateWorkflowVideo,
deleteWorkflowVideo,
uploadWorkflowVideoFile,
uploadWorkflowThumbnail
} from '@/api/workflowVideo'
const { t } = useI18n()
// 列表数据
const videoList = ref([])
const currentPage = ref(0)
const pageSize = ref(20)
const totalElements = ref(0)
const totalPages = computed(() => Math.ceil(totalElements.value / pageSize.value))
const visiblePageNumbers = computed(() => {
const pages = []
const total = totalPages.value
for (let i = 0; i < total && i < 10; i++) {
pages.push(i)
}
return pages
})
// 弹窗状态
const dialogVisible = ref(false)
const isEditing = ref(false)
const editingId = ref(null)
const saving = ref(false)
const form = ref({
title: '',
description: '',
tags: '',
sortOrder: 0,
isActive: true,
videoUrl: '',
thumbnailUrl: ''
})
// 上传状态
const uploadingVideo = ref(false)
const uploadingThumb = ref(false)
const videoInputRef = ref(null)
const thumbInputRef = ref(null)
// 预览
const previewVisible = ref(false)
const previewItem = ref(null)
// 加载数据
const loadVideos = async () => {
try {
const response = await getAdminWorkflowVideos(currentPage.value, pageSize.value)
if (response?.data?.success) {
videoList.value = response.data.data || []
totalElements.value = response.data.totalElements || 0
}
} catch (error) {
console.error('加载视频列表失败:', error)
message.error(t('workflowVideo.loadFailed'))
}
}
// 打开新增弹窗
const openCreateDialog = () => {
isEditing.value = false
editingId.value = null
form.value = {
title: '',
description: '',
tags: '',
sortOrder: 0,
isActive: true,
videoUrl: '',
thumbnailUrl: ''
}
dialogVisible.value = true
}
// 编辑
const editVideo = (video) => {
isEditing.value = true
editingId.value = video.id
form.value = {
title: video.title || '',
description: video.description || '',
tags: video.tags || '',
sortOrder: video.sortOrder || 0,
isActive: video.isActive !== false,
videoUrl: video.videoUrl || '',
thumbnailUrl: video.thumbnailUrl || ''
}
dialogVisible.value = true
}
// 保存
const saveVideo = async () => {
if (!form.value.title?.trim()) {
message.warning(t('workflowVideo.titleRequired'))
return
}
if (!form.value.videoUrl) {
message.warning(t('workflowVideo.videoRequired'))
return
}
saving.value = true
try {
const data = { ...form.value }
let response
if (isEditing.value) {
response = await updateWorkflowVideo(editingId.value, data)
} else {
response = await createWorkflowVideo(data)
}
if (response?.data?.success) {
message.success(response.data.message || t('common.success'))
dialogVisible.value = false
await loadVideos()
} else {
message.error(response?.data?.message || t('common.error'))
}
} catch (error) {
console.error('保存失败:', error)
message.error(t('common.saveFailed'))
} finally {
saving.value = false
}
}
// 切换启用状态
const toggleActive = async (video) => {
try {
const response = await updateWorkflowVideo(video.id, { isActive: !video.isActive })
if (response?.data?.success) {
message.success(t('common.success'))
await loadVideos()
}
} catch (error) {
message.error(t('common.updateFailed'))
}
}
// 删除
const handleDelete = (video) => {
Modal.confirm({
title: t('common.confirmDeleteTitle'),
content: `${t('workflowVideo.confirmDelete')} "${video.title}"?`,
okText: t('common.delete'),
cancelText: t('common.cancel'),
okType: 'danger',
onOk: async () => {
try {
const response = await deleteWorkflowVideo(video.id)
if (response?.data?.success) {
message.success(t('common.deleteSuccess'))
await loadVideos()
} else {
message.error(response?.data?.message || t('common.deleteFailed'))
}
} catch (error) {
message.error(t('common.deleteFailed'))
}
}
})
}
// 上传视频
const handleVideoUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
uploadingVideo.value = true
try {
const response = await uploadWorkflowVideoFile(file)
if (response?.data?.success) {
form.value.videoUrl = response.data.url
message.success(t('workflowVideo.uploadSuccess'))
} else {
message.error(response?.data?.message || t('workflowVideo.uploadFailed'))
}
} catch (error) {
message.error(t('workflowVideo.uploadFailed'))
} finally {
uploadingVideo.value = false
e.target.value = '' // 重置 input
}
}
// 上传缩略图
const handleThumbnailUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
uploadingThumb.value = true
try {
const response = await uploadWorkflowThumbnail(file)
if (response?.data?.success) {
form.value.thumbnailUrl = response.data.url
message.success(t('workflowVideo.uploadSuccess'))
} else {
message.error(response?.data?.message || t('workflowVideo.uploadFailed'))
}
} catch (error) {
message.error(t('workflowVideo.uploadFailed'))
} finally {
uploadingThumb.value = false
e.target.value = ''
}
}
// 预览视频
const previewVideo = (video) => {
previewItem.value = video
previewVisible.value = true
}
// 工具函数
const parseTags = (tags) => {
if (!tags) return []
return tags.split(',').map(t => t.trim()).filter(t => t)
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('zh-CN')
}
// 分页
const prevPage = () => {
if (currentPage.value > 0) {
currentPage.value--
loadVideos()
}
}
const nextPage = () => {
if (currentPage.value < totalPages.value - 1) {
currentPage.value++
loadVideos()
}
}
const goToPage = (page) => {
currentPage.value = page
loadVideos()
}
onMounted(() => {
loadVideos()
})
</script>
<style scoped>
.admin-workflow-videos {
display: flex;
height: 100vh;
width: 100vw;
background: #07090F;
color: #E2E8F0;
font-family: var(--font-sans);
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Video Content */
.video-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.content-header h2 {
font-size: 18px;
font-weight: 600;
color: #E2E8F0;
margin: 0;
}
/* Table */
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
padding: 12px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #94A3B8;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255,255,255,0.06);
white-space: nowrap;
}
.data-table td {
padding: 12px 16px;
font-size: 13px;
color: #CBD5E1;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.table-row:hover {
background: rgba(255,255,255,0.03);
}
.thumb-preview {
width: 60px;
height: 40px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s;
}
.thumb-preview:hover {
transform: scale(1.1);
}
.no-thumb {
color: #475569;
}
.tag {
display: inline-block;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
border-radius: 4px;
font-size: 11px;
margin-right: 4px;
margin-bottom: 2px;
}
.status-tag {
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background: rgba(16, 185, 129, 0.15);
color: #10B981;
}
.status-tag.inactive {
background: rgba(239, 68, 68, 0.15);
color: #EF4444;
}
.action-link {
color: #818CF8;
cursor: pointer;
font-size: 12px;
margin-right: 12px;
transition: color 0.2s;
}
.action-link:hover {
color: #A5B4FC;
}
.action-link.danger {
color: #EF4444;
}
.action-link.danger:hover {
color: #F87171;
}
.empty-cell {
text-align: center;
color: #475569;
padding: 40px !important;
}
/* Pagination */
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-arrow {
padding: 6px 10px;
cursor: pointer;
color: #94A3B8;
border-radius: 6px;
transition: all 0.2s;
}
.page-arrow:hover:not(.disabled) {
background: rgba(255,255,255,0.06);
color: #E2E8F0;
}
.page-arrow.disabled {
opacity: 0.3;
cursor: not-allowed;
}
.page-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid rgba(255,255,255,0.1);
color: #94A3B8;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.page-btn:hover {
background: rgba(255,255,255,0.06);
color: #E2E8F0;
}
.page-btn.active {
background: rgba(99, 102, 241, 0.2);
border-color: #818CF8;
color: #818CF8;
}
/* Form styles */
.upload-area {
display: flex;
align-items: center;
gap: 12px;
}
.file-input {
display: none;
}
.upload-status.success {
color: #10B981;
font-size: 13px;
}
.preview-url {
margin-top: 6px;
font-size: 11px;
color: #64748B;
word-break: break-all;
}
.thumb-form-preview {
margin-top: 8px;
max-width: 200px;
max-height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
}
</style>

View File

@@ -0,0 +1,367 @@
<template>
<div class="api-management admin-theme">
<AdminSidebar active="apiManagement" />
<!-- 主内容区域 -->
<main class="main-content">
<AdminHeader :title="$t('nav.apiManagement')" />
<!-- 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: var(--text-primary);">{{ $t('apiManagement.modifyConfig') }}</h3>
<a-form :model="apiForm" label-width="120px" class="api-form">
<a-form-item :label="$t('apiManagement.apiKey')">
<a-input
v-model="apiForm.apiKey"
type="password"
:placeholder="$t('apiManagement.apiKeyPlaceholder')"
show-password
style="width: 100%; max-width: 600px;"
/>
</a-form-item>
<a-form-item :label="$t('apiManagement.apiBaseUrl')">
<a-input
v-model="apiForm.apiBaseUrl"
:placeholder="$t('apiManagement.apiBaseUrlPlaceholder')"
style="width: 100%; max-width: 600px;"
/>
<div style="margin-top: 8px; color: var(--text-tertiary); font-size: 12px;">
{{ $t('apiManagement.apiBaseUrlHint') }}: {{ currentApiBaseUrl || $t('common.notConfigured') }}
</div>
</a-form-item>
<a-form-item :label="$t('apiManagement.tokenExpiration')">
<div style="display: flex; align-items: center; gap: 12px; width: 100%; max-width: 600px;">
<a-input
v-model.number="apiForm.tokenExpireHours"
type="number"
:placeholder="$t('apiManagement.tokenPlaceholder')"
style="flex: 1;"
:min="1"
:max="720"
/>
<span style="color: var(--text-tertiary); font-size: 14px;">{{ $t('apiManagement.hours') }}</span>
</div>
<div style="margin-top: 8px; color: var(--text-tertiary); font-size: 12px;">
{{ $t('apiManagement.rangeHint') }}
</div>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="saveApiKey" :loading="saving">{{ $t('common.save') }}</a-button>
<a-button @click="resetForm">{{ $t('common.reset') }}</a-button>
</a-form-item>
</a-form>
</div>
</section>
</main>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/request'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
const { t } = useI18n()
const saving = ref(false)
const loading = ref(false)
const apiForm = reactive({
apiKey: '',
apiBaseUrl: '',
tokenExpireHours: null // 从数据库加载
})
const currentApiBaseUrl = ref('')
const currentMaskedKey = ref('')
// 格式化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) {
message.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) {
message.success(response.data.message || t('common.configSavedToDb'))
// 清空输入框
apiForm.apiKey = ''
apiForm.apiBaseUrl = ''
// 重新加载当前配置
loadApiKey()
} else {
message.error(response.data?.error || t('common.saveFailed'))
}
} catch (error) {
console.error('保存配置失败:', error)
message.error(t('common.saveFailed') + ': ' + (error.response?.data?.message || error.message || ''))
} finally {
saving.value = false
}
}
// 重置表单
const resetForm = () => {
apiForm.apiKey = ''
apiForm.apiBaseUrl = ''
loadApiKey()
}
// 页面加载时获取当前API密钥状态
onMounted(() => {
loadApiKey()
})
</script>
<style scoped>
.api-management {
display: flex;
min-height: 100vh;
background: var(--bg-base);
font-family: var(--font-sans);
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-base);
}
/* API内容区域 */
.api-content {
padding: var(--space-6);
flex: 1;
background: var(--bg-surface);
margin: var(--space-6);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
}
.content-header h2 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
.api-form-container {
max-width: 800px;
}
.current-config {
background: var(--primary-glow-subtle);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-6);
margin-bottom: var(--space-8);
}
.current-config h3 {
margin: 0 0 var(--space-4) 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--primary-500);
}
.config-item {
display: flex;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border-subtle);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-size: var(--text-sm);
color: var(--text-tertiary);
width: 120px;
flex-shrink: 0;
}
.config-value {
font-size: var(--text-sm);
color: var(--text-primary);
font-weight: var(--font-medium);
font-family: var(--font-mono);
background: var(--bg-glass);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-sm);
}
.api-form {
background: var(--bg-elevated);
padding: var(--space-8);
border-radius: var(--radius-md);
}
/* ========================================
Ant Design 暗底白字覆盖
======================================== */
/* 表单标签 */
:deep(.ant-form-item-label > label) {
color: var(--text-secondary, #94A3B8) !important;
}
/* 输入框 */
:deep(.ant-input),
:deep(.ant-input-number),
:deep(.ant-input-password .ant-input) {
background: var(--bg-base, #0F1219) !important;
border-color: var(--border-default, rgba(255, 255, 255, 0.12)) !important;
color: var(--text-primary, #E2E8F0) !important;
}
:deep(.ant-input:focus),
:deep(.ant-input-focused),
:deep(.ant-input-affix-wrapper-focused) {
border-color: var(--primary-400, #60A5FA) !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15) !important;
}
:deep(.ant-input::placeholder),
:deep(.ant-input-number-input::placeholder) {
color: var(--text-tertiary, #64748B) !important;
}
/* 密码输入框 */
:deep(.ant-input-password) {
background: var(--bg-base, #0F1219) !important;
border-color: var(--border-default, rgba(255, 255, 255, 0.12)) !important;
}
:deep(.ant-input-password .ant-input) {
background: transparent !important;
}
:deep(.ant-input-suffix) {
color: var(--text-tertiary, #64748B) !important;
}
/* 按钮 */
:deep(.ant-btn-default) {
background: var(--bg-elevated, #1A1D2E) !important;
border-color: var(--border-default, rgba(255, 255, 255, 0.12)) !important;
color: var(--text-primary, #E2E8F0) !important;
}
:deep(.ant-btn-default:hover) {
border-color: var(--primary-400, #60A5FA) !important;
color: var(--primary-400, #60A5FA) !important;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.api-management {
flex-direction: column;
}
.api-content {
padding: 16px;
}
}
</style>

View 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">
<a-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">
<a-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">
<a-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>
<!-- 确定修改按钮 -->
<a-button
type="primary"
class="submit-button"
:loading="loading"
@click="handleSubmit"
>
{{ loading ? $t('changePassword.submitting') : $t('changePassword.confirm') }}
</a-button>
<!-- 返回按钮 -->
<div class="back-button-wrapper">
<a-button
class="back-button"
@click="handleBack"
>
{{ $t('common.back') }}
</a-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 { message } from 'ant-design-vue'
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) {
message.success(t('common.passwordSetSuccess'))
// 清除首次设置标记
localStorage.removeItem('needSetPassword')
// 跳转到首页或之前的页面
const redirect = route.query.redirect || '/profile'
router.replace(redirect)
} else {
message.error(result?.message || t('common.updateFailed'))
}
} catch (error) {
console.error('修改密码失败:', error)
const errorMsg = error.response?.data?.message || error.message || t('common.updateFailed')
message.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: var(--bg-root) url('/images/backgrounds/login_bg.png') center/cover no-repeat;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
font-family: var(--font-sans);
margin: 0;
padding: 0;
z-index: var(--z-base);
}
/* 左上角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: var(--bg-glass);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
border-radius: var(--radius-2xl);
border: 1px solid var(--border-default);
padding: 60px 80px;
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/* 页面标题 */
.page-title {
text-align: center;
font-size: var(--text-4xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin-bottom: 50px;
}
/* 表单 */
.password-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* 输入组 */
.input-group {
margin-bottom: 5px;
}
.password-input {
width: 100%;
}
.password-input :deep(.ant-input) {
background: var(--bg-glass);
border: none;
border-radius: var(--radius-lg);
box-shadow: none;
height: 60px;
transition: all var(--duration-slow) var(--ease-default);
}
.password-input :deep(.ant-input:hover) {
background: var(--bg-glass-hover);
}
.password-input :deep(.ant-input:focus) {
background: var(--bg-active);
box-shadow: none;
}
.password-input :deep(.ant-input) {
color: var(--text-primary);
background: transparent;
font-size: var(--text-base);
}
.password-input :deep(.ant-input::placeholder) {
color: var(--text-tertiary);
}
.input-error {
color: var(--error-400);
font-size: var(--text-xs);
margin-top: var(--space-1);
text-align: left;
}
/* 确定修改按钮 */
.submit-button {
width: 100%;
height: 60px;
background: var(--primary-500);
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: var(--text-lg);
font-weight: var(--font-medium);
margin-top: var(--space-5);
transition: all var(--duration-slow) var(--ease-default);
}
.submit-button:hover {
background: var(--primary-400);
transform: translateY(-1px);
}
.submit-button:active {
transform: translateY(0);
}
/* 返回按钮 */
.back-button {
width: 100%;
height: 60px;
background: var(--bg-glass);
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: var(--text-lg);
font-weight: var(--font-medium);
transition: all var(--duration-slow) var(--ease-default);
}
.back-button:hover {
background: var(--bg-glass-hover);
}
.back-button-wrapper {
width: 100%;
}
.back-button-wrapper .back-button {
width: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card {
width: 90%;
padding: var(--space-10) var(--space-8);
}
.page-title {
font-size: var(--text-3xl);
}
}
@media (max-width: 480px) {
.login-card {
padding: var(--space-8) var(--space-5);
}
.page-title {
font-size: var(--text-2xl);
}
}
</style>

View File

@@ -0,0 +1,583 @@
<template>
<div class="error-statistics-page admin-theme">
<AdminSidebar active="errorStats" />
<!-- 主内容区域 -->
<main class="main-content">
<AdminHeader :title="$t('errorStats.title')" />
<!-- 内容包装器 -->
<div class="content-wrapper">
<!-- 统计卡片 -->
<div class="stats-cards" :spinning="loading">
<div class="stat-card">
<div class="stat-icon error">
<WarningOutlined />
</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">
<ClockCircleOutlined />
</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">
<CalendarOutlined />
</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>
<a-select v-model:value="selectedDays" @change="loadStatistics" class="days-select">
<a-select-option :label="$t('errorStats.last7Days')" :value="7"></a-select-option>
<a-select-option :label="$t('errorStats.last30Days')" :value="30"></a-select-option>
<a-select-option :label="$t('errorStats.last90Days')" :value="90"></a-select-option>
</a-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>
<a-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>
<a-button type="primary" size="small" @click="loadRecentErrors">{{ $t('errorStats.refresh') }}</a-button>
</div>
<a-table :data="recentErrors" :spinning="tableLoading" stripe>
<a-table-column prop="createdAt" :label="$t('errorStats.time')" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</a-table-column>
<a-table-column prop="errorType" :label="$t('errorStats.errorType')" width="150">
<template #default="{ row }">
<a-tag :type="getTagType(row.errorType)">{{ row.errorType }}</a-tag>
</template>
</a-table-column>
<a-table-column prop="username" :label="$t('errorStats.user')" width="120" />
<a-table-column prop="taskId" :label="$t('errorStats.taskId')" width="200" />
<a-table-column prop="errorMessage" :label="$t('errorStats.errorMessage')" show-overflow-tooltip />
</a-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<a-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 { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
WarningOutlined,
ClockCircleOutlined,
CalendarOutlined
} from '@ant-design/icons-vue'
import request from '@/api/request'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
const { t } = useI18n()
// 状态
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 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)
message.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)
message.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 = ['var(--error-400)', 'var(--warning-500)', 'var(--primary-500)', 'var(--accent-400)', 'var(--text-disabled)', 'var(--warning-600)', 'var(--secondary-500)', 'var(--accent-600)']
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'
}
onMounted(async () => {
await loadErrorTypes()
await loadStatistics()
await loadErrorLogs()
})
</script>
<style scoped>
.error-statistics-page {
display: flex;
height: 100vh;
background: var(--bg-base);
font-family: var(--font-sans);
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-base);
overflow-y: auto;
padding: 0;
}
/* 内容包装器 */
.content-wrapper {
padding: var(--space-6);
flex: 1;
overflow-y: auto;
}
/* 统计卡片 */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
margin-bottom: var(--space-8);
}
.stat-card {
background: var(--bg-surface);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-2xl);
}
.stat-icon.error {
background: var(--error-glow);
color: var(--error-400);
}
.stat-icon.today {
background: var(--warning-glow);
color: var(--warning-500);
}
.stat-icon.week {
background: var(--primary-glow-light);
color: var(--primary-500);
}
.stat-icon.users {
background: var(--accent-glow);
color: var(--accent-400);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: var(--text-sm);
color: var(--text-disabled);
margin-bottom: var(--space-1);
}
.stat-number {
font-size: var(--text-3xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
/* 图表区域 */
.charts-section {
margin-bottom: var(--space-8);
}
.chart-card {
background: var(--bg-surface);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.chart-card.full-width {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-5);
}
.chart-header h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--text-primary);
}
.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: var(--text-primary);
font-size: var(--text-sm);
}
.type-count {
color: var(--text-disabled);
font-size: var(--text-xs);
}
.type-bar {
flex: 1;
height: 8px;
background: var(--bg-active);
border-radius: var(--radius-sm);
overflow: hidden;
}
.type-bar-fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width var(--duration-slow) var(--ease-default);
}
.type-percentage {
width: 60px;
text-align: right;
color: var(--text-disabled);
font-size: var(--text-xs);
}
/* 最近错误区域 */
.recent-errors-section {
background: var(--bg-surface);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-5);
}
.section-header h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--text-primary);
}
.pagination-wrapper {
margin-top: var(--space-5);
display: flex;
justify-content: flex-end;
}
/* ========================================
Ant Design 暗底白字覆盖
======================================== */
/* 表格 */
:deep(.ant-table) {
background: transparent !important;
color: var(--text-primary, #E2E8F0) !important;
}
:deep(.ant-table-thead > tr > th),
:deep(.ant-table-thead > tr > td) {
background: var(--bg-elevated, #1A1D2E) !important;
color: var(--text-secondary, #94A3B8) !important;
border-bottom-color: var(--border-subtle, rgba(255, 255, 255, 0.08)) !important;
}
:deep(.ant-table-tbody > tr > td) {
background: var(--bg-surface, #111427) !important;
color: var(--text-primary, #E2E8F0) !important;
border-bottom-color: var(--border-subtle, rgba(255, 255, 255, 0.08)) !important;
}
:deep(.ant-table-tbody > tr:hover > td) {
background: var(--bg-hover, rgba(255, 255, 255, 0.04)) !important;
}
:deep(.ant-table-wrapper .ant-table-container),
:deep(.ant-table-content) {
background: transparent !important;
}
:deep(.ant-table-cell) {
color: var(--text-primary, #E2E8F0) !important;
}
:deep(.ant-table-placeholder) {
background: var(--bg-surface, #111427) !important;
color: var(--text-tertiary, #64748B) !important;
}
:deep(.ant-table-placeholder .ant-empty-description) {
color: var(--text-tertiary, #64748B) !important;
}
/* 分页器 */
:deep(.ant-pagination) {
color: var(--text-secondary, #94A3B8);
}
:deep(.ant-pagination-item) {
background: var(--bg-elevated, #1A1D2E) !important;
border-color: var(--border-subtle, rgba(255, 255, 255, 0.08)) !important;
}
:deep(.ant-pagination-item a) {
color: var(--text-secondary, #94A3B8) !important;
}
:deep(.ant-pagination-item-active) {
background: var(--primary-500, #3B82F6) !important;
border-color: var(--primary-500, #3B82F6) !important;
}
:deep(.ant-pagination-item-active a) {
color: #fff !important;
}
:deep(.ant-pagination-prev .ant-pagination-item-link),
:deep(.ant-pagination-next .ant-pagination-item-link) {
background: var(--bg-elevated, #1A1D2E) !important;
border-color: var(--border-subtle, rgba(255, 255, 255, 0.08)) !important;
color: var(--text-secondary, #94A3B8) !important;
}
:deep(.ant-pagination-disabled .ant-pagination-item-link) {
color: var(--text-disabled, #475569) !important;
}
/* 下拉选择器 */
:deep(.ant-select-selector) {
background: var(--bg-elevated, #1A1D2E) !important;
border-color: var(--border-default, rgba(255, 255, 255, 0.12)) !important;
color: var(--text-primary, #E2E8F0) !important;
}
:deep(.ant-select-selection-item) {
color: var(--text-primary, #E2E8F0) !important;
}
:deep(.ant-select-arrow) {
color: var(--text-tertiary, #64748B) !important;
}
@media (max-width: 1200px) {
.stats-cards {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.error-statistics-page {
flex-direction: column;
}
.stats-cards {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,701 @@
<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">
<UserOutlined />
<span>个人主页</span>
</div>
<div class="nav-item" @click="goToSubscription">
<CompassOutlined />
<span>会员订阅</span>
</div>
<div class="nav-item" @click="goToMyWorks">
<FileOutlined />
<span>我的作品</span>
</div>
<div class="nav-divider"></div>
<div class="nav-item" @click="goToTextToVideo">
<PlayCircleOutlined />
<span>文生视频</span>
</div>
<div class="nav-item active">
<PictureOutlined />
<span>图生视频</span>
</div>
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
<VideoCameraOutlined />
<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="$t('dashboard.userAvatar')" 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">
<a-button type="primary">编辑资料</a-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)">
<a-button type="primary" size="small" round>
<PlayCircleOutlined />
{{ $t('works.createSimilar') }}
</a-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">
<a-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">{{ $t('works.createSimilar') }}</a-button>
</div>
<div class="work-director" v-else>
<span>DIRECTED BY VANNOCENT</span>
</div>
</div>
</div>
</div>
</main>
<!-- 作品详情模态框 -->
<a-modal
v-model:open="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>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, FileOutlined, PlayCircleOutlined, PictureOutlined, VideoCameraOutlined, CompassOutlined } from '@ant-design/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)
message.error(t('common.loadTaskListFailed'))
}
}
onMounted(() => {
// 页面初始化时加载任务列表
loadTasks()
})
</script>
<style scoped>
/* Lazy loading */
.lazy-LoadingOutlined {
background: linear-gradient(90deg, var(--bg-surface) 25%, var(--bg-elevated) 50%, var(--bg-surface) 75%);
background-size: 200% 100%;
animation: lazy-shimmer 1.5s infinite;
}
.lazy-loaded { animation: lazy-fade-in 0.3s ease-in; }
.lazy-error { background: var(--bg-surface); }
@keyframes lazy-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes lazy-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Page layout */
.image-to-video-page {
display: flex;
height: 100vh;
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-sans);
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width) !important;
background: var(--bg-elevated) !important;
padding: var(--space-6) 0 !important;
border-right: 1px solid var(--border-subtle) !important;
flex-shrink: 0 !important;
z-index: 100 !important;
display: block !important;
position: relative !important;
}
.logo {
padding: 0 var(--space-6) var(--space-8);
display: flex;
align-items: center;
justify-content: center;
}
.logo img { height: 40px; width: auto; }
.nav-menu { padding: 0 var(--space-5); }
.nav-item {
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-1);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-all);
color: var(--text-secondary);
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--primary-glow-light);
color: var(--primary-400);
}
.nav-item .anticon {
margin-right: var(--space-3);
font-size: 18px;
}
.nav-item span {
font-size: var(--text-sm);
flex: 1;
}
.nav-divider {
height: 1px;
background: var(--border-subtle);
margin: var(--space-4) 0;
}
/* Main content */
.main-content {
flex: 1;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
overflow-y: auto;
}
/* User info card */
.user-info-card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
padding: var(--space-6);
display: flex;
align-items: center;
gap: var(--space-5);
}
.user-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
overflow: hidden;
border: 2px solid var(--border-default);
}
.avatar-image { width: 100%; height: 100%; object-fit: cover; }
.user-details {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.username {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.profile-prompt {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.user-id {
font-size: var(--text-xs);
color: var(--text-muted);
}
.edit-profile-btn { margin-left: auto; }
/* Published works */
.published-works {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.works-tabs {
display: flex;
gap: var(--space-6);
}
.tab {
padding: var(--space-2) 0;
color: var(--text-tertiary);
cursor: pointer;
position: relative;
font-size: var(--text-sm);
transition: var(--transition-colors);
}
.tab.active { color: var(--text-primary); }
.tab.active::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 2px;
background: var(--primary-500);
border-radius: var(--radius-full);
}
.works-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.work-item {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
transition: var(--transition-all);
cursor: pointer;
}
.work-item:hover {
border-color: var(--primary-500);
transform: translateY(-2px);
box-shadow: var(--glow-primary);
}
.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,
.work-video-thumbnail { display: block; background: #000; }
.work-placeholder {
width: 100%; height: 100%;
background: var(--bg-base);
display: flex;
align-items: center;
justify-content: center;
}
.play-icon {
width: 56px; height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-size: 22px;
}
/* Hover create button */
.hover-create-btn {
position: absolute;
right: var(--space-2);
bottom: var(--space-2);
opacity: 0;
transform: translateY(8px);
transition: var(--transition-all);
z-index: 10;
}
.work-thumbnail:hover .hover-create-btn {
opacity: 1;
transform: translateY(0);
}
.hover-create-btn .ant-btn {
background: var(--primary-500);
border: none;
backdrop-filter: blur(8px);
box-shadow: var(--glow-primary);
}
.work-info {
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.work-title {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-meta {
font-size: var(--text-xs);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-actions {
padding: 0 var(--space-3) var(--space-3);
opacity: 0;
transition: var(--transition-base);
}
.work-item:hover .work-actions { opacity: 1; }
.work-director {
padding: 0 var(--space-3) var(--space-3);
text-align: center;
}
.work-director span {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
}
/* Responsive */
@media (max-width: 768px) {
.image-to-video-page { flex-direction: column; }
.sidebar { width: 100% !important; height: auto !important; }
.works-grid { grid-template-columns: 1fr; }
}
/* Detail Dialog */
:deep(.detail-dialog .ant-modal) {
background: var(--bg-base) !important;
border-radius: var(--radius-xl);
border: 1px solid var(--border-default) !important;
box-shadow: var(--shadow-xl);
}
:deep(.detail-dialog .ant-modal-header),
:deep(.ant-modal-header) {
background: transparent !important;
border-bottom: 1px solid var(--border-subtle);
}
:deep(.detail-dialog .ant-modal-title),
:deep(.ant-modal-title) {
color: var(--text-primary) !important;
}
:deep(.detail-dialog .ant-modal-close),
:deep(.ant-modal-close) {
color: var(--text-secondary) !important;
}
:deep(.detail-dialog .ant-modal-body),
:deep(.ant-modal-body) {
background: transparent !important;
padding: 0 !important;
}
:deep(.ant-modal-wrap),
:deep(.ant-modal-mask) {
background-color: rgba(0, 0, 0, 0.75) !important;
}
.detail-content {
display: flex;
height: 50vh;
background: var(--bg-base);
}
.detail-left {
flex: 1;
padding: var(--space-5);
display: flex;
align-items: center;
justify-content: center;
}
.video-player {
position: relative;
width: 100%;
max-width: 400px;
aspect-ratio: 16/9;
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
}
.video-thumbnail { width: 100%; height: 100%; object-fit: cover; }
.play-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: var(--transition-base);
}
.video-player:hover .play-overlay { opacity: 1; }
.play-button {
width: 56px; height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #000;
}
.detail-right {
flex: 1;
padding: var(--space-5);
background: var(--bg-base);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.metadata-section {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.metadata-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border-subtle);
}
.metadata-item:last-child { border-bottom: none; }
.label { font-size: var(--text-sm); color: var(--text-tertiary); }
.value { font-size: var(--text-sm); color: var(--text-primary); font-weight: var(--font-medium); }
.description-section { flex: 1; }
.section-title {
font-size: var(--text-base);
color: var(--text-primary);
font-weight: var(--font-semibold);
margin-bottom: var(--space-3);
}
.description-text {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
margin: 0;
}
.action-section { margin-top: auto; }
.create-similar-btn {
width: 100%;
background: var(--primary-500);
color: var(--text-inverse);
border: none;
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: var(--font-semibold);
cursor: pointer;
transition: var(--transition-all);
}
.create-similar-btn:hover {
background: var(--primary-400);
box-shadow: var(--glow-primary);
transform: translateY(-1px);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,642 @@
<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">
<UserOutlined class="action-icon" />
<SettingOutlined class="action-icon" />
</div>
</div>
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="nav-item">
<FileOutlined />
<span>{{ $t('imageDetail.files') }}</span>
</div>
<div class="nav-item">
<PictureOutlined />
<span>{{ $t('imageDetail.images') }}</span>
</div>
<div class="nav-item">
<PlayCircleOutlined />
<span>{{ $t('imageDetail.videos') }}</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"
>
{{ $t('imageDetail.browserNotSupport') }}
</video>
<!-- 视频控制栏 -->
<div class="video-controls" v-show="showControls">
<div class="controls-left">
<a-button circle size="small" @click="togglePlay">
<PlayCircleOutlined v-if="!isPlaying" /><PauseCircleOutlined v-else />
</a-button>
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
</div>
<div class="controls-right">
<a-button circle size="small" @click="toggleFullscreen">
<FullscreenOutlined />
</a-button>
</div>
</div>
<!-- 视频操作按钮 -->
<div class="video-actions">
<a-tooltip :content="$t('video.download')" placement="bottom">
<a-button circle size="small" @click="downloadVideo">
<DownloadOutlined />
</a-button>
</a-tooltip>
<a-tooltip :content="$t('common.delete')" placement="bottom">
<a-button circle size="small" @click="deleteVideo">
<DeleteOutlined />
</a-button>
</a-tooltip>
</div>
</div>
</div>
<!-- 右侧详情区域 -->
<div class="detail-section">
<div class="detail-header">
<h3>{{ $t('imageDetail.imageDetail') }}</h3>
<p class="subtitle">{{ $t('video.imageToVideo.referenceImage') }}</p>
</div>
<div class="detail-content">
<div class="input-section">
<a-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">{{ $t('imageDetail.createTime') }}</span>
<span class="value">{{ videoData.createTime }}</span>
</div>
<div class="meta-item">
<span class="label">{{ $t('imageDetail.videoId') }}</span>
<span class="value">{{ videoData.id }}</span>
</div>
<div class="meta-item">
<span class="label">{{ $t('imageDetail.duration') }}</span>
<span class="value">{{ videoData.duration }}s</span>
</div>
<div class="meta-item">
<span class="label">{{ $t('imageDetail.quality') }}</span>
<span class="value">{{ videoData.resolution }}</span>
</div>
<div class="meta-item">
<span class="label">{{ $t('imageDetail.aspectRatio') }}</span>
<span class="value">{{ videoData.aspectRatio }}</span>
</div>
</div>
<div class="action-button">
<a-button type="primary" size="large" @click="makeSimilar">
{{ $t('imageDetail.createSimilar') }}
</a-button>
</div>
</div>
<!-- 滚动指示器 -->
<div class="scroll-indicators">
<ArrowUpOutlined class="scroll-arrow up" />
<DownOutlined class="scroll-arrow down" />
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
import { useI18n } from 'vue-i18n'
import {
UserOutlined, SettingOutlined, FileOutlined, PictureOutlined, PlayCircleOutlined, PauseCircleOutlined,
FullscreenOutlined, DownloadOutlined, DeleteOutlined, ArrowUpOutlined, DownOutlined
} from '@ant-design/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 = () => {
message.success(t('common.downloadStarted'))
}
const deleteVideo = async () => {
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('common.confirm'),
content: t('common.confirm'),
okText: t('common.delete'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
message.success(t('common.videoDeleted'))
} catch (_) {}
}
const makeSimilar = () => {
message.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) {
message.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 {
message.error(response.data?.message || t('common.loadWorkDetailFailed'))
router.push('/image-to-video')
}
} catch (error) {
console.error('加载任务详情失败:', error)
message.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: var(--bg-base);
color: var(--text-primary);
display: flex;
flex-direction: column;
font-family: var(--font-sans);
}
/* 顶部导航栏 */
.top-bar {
height: 60px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-default);
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: var(--space-4);
}
.action-icon {
font-size: var(--text-xl);
color: var(--text-secondary);
cursor: pointer;
transition: color var(--duration-normal) var(--ease-default);
}
.action-icon:hover {
color: var(--text-primary);
}
/* 左侧导航栏 */
.sidebar {
position: fixed;
left: 0;
top: 60px;
width: 200px;
height: calc(100vh - 60px);
background: var(--bg-surface);
border-right: 1px solid var(--border-default);
padding: 20px 0;
z-index: 90;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 20px;
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-all);
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item .anticon {
margin-right: 12px;
font-size: var(--text-lg);
}
/* 主内容区域 */
.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: var(--bg-base);
border-radius: var(--radius-md);
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: var(--space-3);
}
.time-display {
color: var(--text-primary);
font-size: var(--text-sm);
font-family: monospace;
}
.video-actions {
position: absolute;
top: 20px;
right: 20px;
display: flex;
gap: var(--space-2);
}
.video-actions .ant-btn {
background: rgba(0,0,0,0.6);
border: 1px solid rgba(255,255,255,0.2);
color: var(--text-primary);
}
.video-actions .ant-btn:hover {
background: rgba(0,0,0,0.8);
border-color: rgba(255,255,255,0.4);
}
/* 右侧详情区域 */
.detail-section {
flex: 1;
background: var(--bg-surface);
border-left: 1px solid var(--border-default);
padding: 20px;
overflow-y: auto;
position: relative;
}
.detail-header h3 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
margin-bottom: 4px;
color: var(--text-primary);
}
.subtitle {
color: var(--text-tertiary);
font-size: var(--text-sm);
margin-bottom: 20px;
}
.detail-content {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.input-section {
margin-bottom: 10px;
}
.thumbnails {
display: flex;
gap: var(--space-2);
}
.thumbnail {
width: 60px;
height: 60px;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-hover);
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.description h4 {
font-size: var(--text-base);
font-weight: var(--font-semibold);
margin-bottom: 8px;
color: var(--text-primary);
}
.description p {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.metadata {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.meta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.meta-item:last-child {
border-bottom: none;
}
.label {
color: var(--text-tertiary);
font-size: var(--text-sm);
}
.value {
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.action-button {
margin-top: 20px;
}
.action-button .ant-btn {
width: 100%;
height: 44px;
font-size: var(--text-base);
font-weight: var(--font-semibold);
}
.scroll-indicators {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.scroll-arrow {
font-size: var(--text-base);
color: var(--text-muted);
cursor: pointer;
transition: color var(--duration-normal) var(--ease-default);
}
.scroll-arrow:hover {
color: var(--text-tertiary);
}
/* 响应式设计 */
@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>

View File

@@ -0,0 +1,997 @@
<template>
<div class="login-page">
<!-- 左侧品牌区 -->
<div class="login-brand">
<div class="brand-decor">
<div class="brand-orb brand-orb--1"></div>
<div class="brand-orb brand-orb--2"></div>
<div class="brand-grid"></div>
</div>
<div class="brand-content">
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" class="brand-logo" />
<h2 class="brand-tagline">{{ $t('welcomePage.brandTagline') }}</h2>
<p class="brand-desc">{{ $t('welcomePage.brandDesc') }}</p>
<!-- 品牌特性 -->
<div class="brand-features">
<div class="feature-item">
<div class="feature-icon">🚀</div>
<div class="feature-text">AI 驱动</div>
</div>
<div class="feature-item">
<div class="feature-icon">🎬</div>
<div class="feature-text">视频创作</div>
</div>
<div class="feature-item">
<div class="feature-icon"></div>
<div class="feature-text">智能编辑</div>
</div>
<div class="feature-item">
<div class="feature-icon">🌐</div>
<div class="feature-text">全球服务</div>
</div>
</div>
</div>
</div>
<!-- 右侧登录区 -->
<div class="login-panel">
<div class="login-card">
<!-- 欢迎标题 -->
<div class="login-header">
<h1 class="login-title">
<span class="title-light">{{ $t('welcomePage.welcomeTo') }}</span>
<span class="title-accent">Vionow</span>
</h1>
<p class="login-subtitle">AI 驱动的视频创作平台</p>
</div>
<!-- 登录方式切换 -->
<div class="login-tabs">
<button
class="tab-btn"
:class="{ active: loginType === 'email' }"
@click="loginType = 'email'"
>
{{ $t('welcomePage.emailLogin') }}
</button>
<div class="tab-divider"></div>
<button
class="tab-btn"
:class="{ active: loginType === 'password' }"
@click="loginType = 'password'"
>
{{ $t('welcomePage.accountLogin') }}
</button>
</div>
<!-- 登录表单 -->
<div class="login-form">
<!-- 邮箱输入 -->
<div class="form-group">
<label class="form-label">{{ $t('login.email') }}</label>
<input
ref="emailInput"
v-model="loginForm.email"
:placeholder="$t('login.emailPlaceholder')"
class="form-input"
type="email"
@keyup.enter="handleLogin"
/>
<div class="form-error" v-if="errors.email">{{ errors.email }}</div>
</div>
<!-- 验证码输入仅验证码登录 -->
<div class="form-group" v-if="loginType === 'email'">
<label class="form-label">{{ $t('login.verificationCode') }}</label>
<div class="input-with-action">
<input
ref="codeInput"
v-model="loginForm.code"
:placeholder="$t('login.codePlaceholder')"
class="form-input"
@keyup.enter="handleLogin"
@input="filterCodeSpaces"
/>
<button
class="code-btn"
:class="{ disabled: countdown > 0 || !isEmailValid }"
:disabled="countdown > 0 || !isEmailValid"
@click="getEmailCode"
>
{{ countdown > 0 ? `${countdown}s` : $t('login.getCode') }}
</button>
</div>
<div class="form-error" v-if="errors.code">{{ errors.code }}</div>
</div>
<!-- 密码输入仅密码登录 -->
<div class="form-group" v-if="loginType === 'password'">
<div class="form-label-row">
<label class="form-label">{{ $t('login.password') }}</label>
<router-link to="/set-password" class="form-link">{{ $t('login.forgotPassword') }}</router-link>
</div>
<input
ref="passwordInput"
v-model="loginForm.password"
:placeholder="$t('login.passwordPlaceholder')"
class="form-input"
type="password"
@keyup.enter="handleLogin"
/>
<div class="form-error" v-if="errors.password">{{ errors.password }}</div>
</div>
<!-- 登录按钮 -->
<button
class="login-btn"
:class="{ loading: userStore.loading }"
:disabled="userStore.loading"
@click="handleLogin"
>
<span v-if="!userStore.loading">{{ $t('login.loginOrRegister') }}</span>
<span v-else class="btn-loading">
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-dasharray="32" stroke-dashoffset="32">
<animate attributeName="stroke-dashoffset" values="32;0;32" dur="1.2s" repeatCount="indefinite"/>
</circle>
</svg>
{{ $t('login.loggingIn') }}
</span>
</button>
<!-- 协议 -->
<p class="agreement">
{{ $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>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { message } from 'ant-design-vue'
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')
const loginForm = reactive({
email: '',
code: '',
password: ''
})
const errors = reactive({
email: '',
code: '',
password: '',
server: ''
})
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
await nextTick()
emailInput.value && emailInput.value.focus && emailInput.value.focus()
}
const filterCodeSpaces = () => {
loginForm.code = loginForm.code.replace(/\s/g, '')
}
onMounted(() => {
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 = t('welcomePage.pleaseEnterEmail')
emailInput.value && emailInput.value.focus && emailInput.value.focus()
return
}
if (!isEmailValid.value) {
errors.email = t('welcomePage.invalidEmail')
emailInput.value && emailInput.value.focus && emailInput.value.focus()
return
}
try {
const response = await sendEmailCode(loginForm.email)
if (response.data && response.data.success) {
message.success(t('common.codeSentToEmail'))
startCountdown()
} else {
message.error(response.data?.message || t('common.updateFailed'))
}
} catch (error) {
console.error('发送验证码失败:', error)
if (process.env.NODE_ENV === 'development') {
const randomCode = Array.from({length: 6}, () => Math.floor(Math.random() * 10)).join('')
try {
await setDevEmailCode(loginForm.email, randomCode)
} catch (syncError) {
console.warn('同步验证码到后端失败:', syncError)
}
message.success(t('common.codeSentToEmail'))
startCountdown()
} else {
message.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) {
message.warning(t('common.pleaseEnterEmail'))
return
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(loginForm.email)) {
message.warning(t('common.pleaseEnterValidEmail'))
return
}
const oldToken = localStorage.getItem('token')
localStorage.removeItem('token')
localStorage.removeItem('user')
userStore.token = null
userStore.user = null
try {
let response = null
if (loginType.value === 'email') {
if (!loginForm.code) {
errors.code = t('welcomePage.pleaseEnterCode')
codeInput.value && codeInput.value.focus && codeInput.value.focus()
return
}
if (!isCodeValid.value) {
errors.code = t('welcomePage.invalidCode')
codeInput.value && codeInput.value.focus && codeInput.value.focus()
return
}
response = await loginWithEmail({ email: loginForm.email, code: loginForm.code })
} else {
if (!loginForm.password) {
errors.password = t('welcomePage.pleaseEnterPassword')
passwordInput.value && passwordInput.value.focus && passwordInput.value.focus()
return
}
if (!isPasswordValid.value) {
errors.password = t('welcomePage.passwordMinLength')
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) {
const loginUser = response.data.data.user
const loginToken = response.data.data.token
const needsPasswordChange = response.data.data.needsPasswordChange
localStorage.setItem('token', loginToken)
localStorage.setItem('user', JSON.stringify(loginUser))
userStore.user = loginUser
userStore.token = loginToken
userStore.resetInitialized()
if (needsPasswordChange) {
localStorage.setItem('needSetPassword', '1')
} else {
localStorage.removeItem('needSetPassword')
}
message.success(t('common.loginSuccess'))
const needSetPassword = localStorage.getItem('needSetPassword') === '1'
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
// #region agent log
fetch('http://127.0.0.1:7243/ingest/7d01a34a-7181-4a5e-9962-62bb50420571',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Login.vue:redirect',message:'Login success - navigating',data:{redirectPath,method:'router.push',hasToken:!!localStorage.getItem('token')},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'H-A'})}).catch(()=>{});
// #endregion
// 使用 router.push 进行 SPA 内部导航,避免 window.location.href 导致的全页面重载黑屏
await router.push(redirectPath)
return
} else {
const msg = response?.data?.message || t('common.loginFailed')
errors.server = msg
message.error(msg)
}
} catch (error) {
console.error('Login error:', error)
const msg = error.response?.data?.message || t('common.loginFailedRetry')
errors.server = msg
message.error(msg)
}
}
</script>
<style scoped>
/* 导入设计系统 */
@import '../styles/ui-ux-pro-max.css';
.login-page {
width: 100vw;
height: 100vh;
display: flex;
position: fixed;
inset: 0;
overflow: hidden;
font-family: var(--font-sans);
background: linear-gradient(135deg, var(--color-background) 0%, rgba(30, 41, 59, 0.8) 100%);
background-image: url('/images/backgrounds/login_bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* ==================== Left Brand Area ==================== */
.login-brand {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: var(--space-16);
}
.brand-decor {
position: absolute;
inset: 0;
pointer-events: none;
}
.brand-orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
animation: breathe 8s ease-in-out infinite;
}
.brand-orb--1 {
width: 600px;
height: 400px;
top: 10%;
left: 20%;
background: radial-gradient(circle, rgba(99, 102, 241, 0.12) 0%, transparent 70%);
}
.brand-orb--2 {
width: 400px;
height: 400px;
bottom: 10%;
right: 10%;
background: radial-gradient(circle, rgba(16, 185, 129, 0.08) 0%, transparent 70%);
animation-delay: -4s;
}
.brand-grid {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50%;
background-image:
linear-gradient(var(--color-border-light) 1px, transparent 1px),
linear-gradient(90deg, var(--color-border-light) 1px, transparent 1px);
background-size: 60px 60px;
transform: perspective(400px) rotateX(50deg);
transform-origin: bottom center;
mask-image: linear-gradient(to top, rgba(0,0,0,0.3) 0%, transparent 80%);
-webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0.3) 0%, transparent 80%);
}
.brand-content {
position: relative;
z-index: 2;
text-align: center;
animation: fadeIn 1s var(--ease-out) both;
max-width: 600px;
}
.brand-logo {
height: 64px;
width: auto;
margin-bottom: var(--space-8);
animation: bounce 1s var(--ease-out);
}
.brand-tagline {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: #FFFFFF;
margin: 0 0 var(--space-4) 0;
letter-spacing: var(--tracking-wide);
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-desc {
font-size: var(--text-lg);
color: rgba(255, 255, 255, 0.8);
margin: 0 0 var(--space-8) 0;
letter-spacing: var(--tracking-wide);
line-height: var(--leading-relaxed);
}
/* 品牌特性 */
.brand-features {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-top: var(--space-10);
}
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
background: var(--color-surface);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
transition: var(--transition-all);
animation: fadeIn 0.8s var(--ease-out) both;
}
.feature-item:hover {
background: var(--color-surface-hover);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--glow-primary);
}
.feature-item:nth-child(1) { animation-delay: 0.1s; }
.feature-item:nth-child(2) { animation-delay: 0.2s; }
.feature-item:nth-child(3) { animation-delay: 0.3s; }
.feature-item:nth-child(4) { animation-delay: 0.4s; }
.feature-icon {
font-size: var(--text-2xl);
margin-bottom: var(--space-2);
}
.feature-text {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: rgba(255, 255, 255, 0.8);
text-align: center;
}
/* ==================== Right Login Panel ==================== */
.login-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8);
position: relative;
}
.login-card {
width: 100%;
max-width: 420px;
animation: fadeIn 0.8s var(--ease-out) 0.2s both;
background: var(--glass-solid);
backdrop-filter: var(--glass-blur-lg);
-webkit-backdrop-filter: var(--glass-blur-lg);
border-radius: var(--radius-2xl);
padding: var(--space-8);
border: 1px solid var(--glass-border-strong);
box-shadow: var(--glass-shadow-lg);
}
/* Header */
.login-header {
margin-bottom: var(--space-8);
text-align: center;
}
.login-title {
font-size: var(--text-3xl);
font-weight: var(--font-bold);
margin: 0 0 var(--space-2) 0;
line-height: var(--leading-tight);
}
.title-light {
color: #FFFFFF;
margin-right: var(--space-2);
}
.title-accent {
color: var(--color-primary);
font-weight: var(--font-bold);
text-shadow: var(--glow-primary);
}
.login-subtitle {
font-size: var(--text-base);
color: rgba(255, 255, 255, 0.7);
margin: 0;
font-weight: var(--font-medium);
}
/* Tabs */
.login-tabs {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-8);
background: rgba(255, 255, 255, 0.06);
border-radius: var(--radius-lg);
padding: var(--space-1);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.tab-btn {
flex: 1;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: var(--text-base);
font-weight: var(--font-medium);
padding: var(--space-3) var(--space-4);
cursor: pointer;
transition: var(--transition-all);
letter-spacing: var(--tracking-wide);
border-radius: var(--radius-md);
position: relative;
overflow: hidden;
}
.tab-btn:hover {
color: #FFFFFF;
background: var(--color-surface-hover);
}
.tab-btn.active {
color: #FFFFFF;
background: var(--color-primary);
box-shadow: var(--glow-primary);
}
.tab-divider {
display: none;
}
/* Form */
.login-form {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.form-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: #FFFFFF;
margin-bottom: var(--space-1);
}
.form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
.form-label-row .form-label {
margin-bottom: 0;
}
.form-link {
font-size: var(--text-sm);
color: var(--color-primary);
text-decoration: none;
transition: var(--transition-colors);
}
.form-link:hover {
color: var(--color-primary-light);
text-decoration: underline;
}
.form-input {
width: 100%;
height: 56px;
padding: var(--space-3) var(--space-4);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-lg);
color: #FFFFFF;
font-size: var(--text-base);
font-family: var(--font-sans);
outline: none;
transition: var(--transition-border);
}
.form-input:hover {
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.08);
}
.form-input:focus {
border-color: var(--color-primary-light);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25);
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.input-with-action {
position: relative;
display: flex;
align-items: center;
}
.input-with-action .form-input {
padding-right: 120px;
}
.code-btn {
position: absolute;
right: var(--space-3);
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
color: var(--color-primary-light);
font-size: var(--text-sm);
font-weight: var(--font-medium);
cursor: pointer;
padding: var(--space-2) var(--space-4);
transition: var(--transition-all);
white-space: nowrap;
border-radius: var(--radius-md);
}
.code-btn:hover:not(.disabled) {
background: var(--color-primary);
border-color: var(--color-primary);
color: #FFFFFF;
box-shadow: var(--glow-primary);
}
.code-btn.disabled {
color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
cursor: not-allowed;
}
.form-error {
color: var(--color-danger);
font-size: var(--text-xs);
margin-top: var(--space-1);
}
/* Login Button */
.login-btn {
width: 100%;
height: 56px;
background: var(--color-primary);
color: #FFFFFF;
border: none;
border-radius: var(--radius-lg);
font-size: var(--text-base);
font-weight: var(--font-bold);
cursor: pointer;
transition: var(--transition-all);
letter-spacing: var(--tracking-wide);
margin-top: var(--space-4);
position: relative;
overflow: hidden;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
}
.login-btn:hover:not(:disabled) {
background: var(--color-primary-light);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.5);
transform: translateY(-2px);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-loading {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.spinner {
width: 20px;
height: 20px;
animation: spin 1s var(--ease-linear) infinite;
}
/* 快速登录 */
.quick-login {
margin-top: var(--space-6);
}
.quick-login-divider {
display: flex;
align-items: center;
margin-bottom: var(--space-6);
}
.quick-login-divider::before,
.quick-login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.quick-login-divider span {
padding: 0 var(--space-4);
color: rgba(255, 255, 255, 0.6);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.quick-login-buttons {
display: flex;
gap: var(--space-4);
}
.quick-login-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: #FFFFFF;
font-size: var(--text-sm);
font-weight: var(--font-medium);
cursor: pointer;
transition: var(--transition-all);
}
.quick-login-btn:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-dark);
transform: translateY(-1px);
}
.quick-login-btn.google:hover {
border-color: #DB4437;
box-shadow: 0 0 15px rgba(219, 68, 55, 0.3);
}
.quick-login-btn.apple:hover {
border-color: #000;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
.btn-icon {
font-size: var(--text-lg);
font-weight: var(--font-bold);
}
/* Agreement */
.agreement {
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: var(--text-xs);
line-height: var(--leading-relaxed);
margin: var(--space-6) 0 0 0;
}
.agreement-link {
color: var(--color-primary);
text-decoration: none;
transition: var(--transition-colors);
font-weight: var(--font-medium);
}
.agreement-link:hover {
color: var(--color-primary-light);
text-decoration: underline;
}
/* ==================== Animations ==================== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes breathe {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
transform: translate3d(0,0,0);
}
40%, 43% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -8px, 0);
}
70% {
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0,-2px,0);
}
}
/* ==================== Responsive ==================== */
@media (max-width: 1024px) {
.login-brand {
display: none;
}
.login-panel {
width: 100%;
background: rgba(15, 23, 42, 0.95);
}
}
@media (max-width: 768px) {
.login-panel {
padding: var(--space-6);
}
.login-card {
padding: var(--space-6);
}
.login-title {
font-size: var(--text-2xl);
}
.form-input {
height: 48px;
font-size: var(--text-sm);
}
.login-btn {
height: 48px;
}
.quick-login-buttons {
flex-direction: column;
}
.brand-features {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.login-panel {
padding: var(--space-4);
}
.login-card {
padding: var(--space-4);
}
.login-title {
font-size: var(--text-xl);
}
.login-tabs {
flex-direction: column;
gap: var(--space-2);
}
.tab-btn {
width: 100%;
}
.brand-features {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,991 @@
<template>
<div class="member-management admin-theme">
<AdminSidebar active="members" />
<!-- 主内容区域 -->
<main class="main-content">
<AdminHeader :title="$t('nav.memberManagement')" />
<!-- 会员列表内容 -->
<section class="member-content">
<div class="content-header">
<h2>{{ $t('members.title') }}</h2>
<div class="selection-info" v-if="selectedMembers.length > 0">
{{ $t('orders.selected', { count: selectedMembers.length }) }}
</div>
</div>
<div class="table-toolbar">
<div class="toolbar-left">
<a-select v-model:value="selectedLevel" :placeholder="$t('members.memberLevel')" size="small" @change="handleFilterChange">
<a-select-option value="all">{{ $t('members.allLevels') }}</a-select-option>
<a-select-option value="free">{{ $t('members.freeMember') }}</a-select-option>
<a-select-option value="starter">{{ $t('members.starterMember') }}</a-select-option>
<a-select-option value="standard">{{ $t('members.standardMember') }}</a-select-option>
<a-select-option value="professional">{{ $t('members.professionalMember') }}</a-select-option>
</a-select>
<a-select v-model:value="selectedStatus" :placeholder="$t('members.userStatus')" size="small" @change="handleFilterChange" style="margin-left: 10px;">
<a-select-option value="active">{{ $t('members.activeUsers') }}</a-select-option>
<a-select-option value="banned">{{ $t('members.bannedUsers') }}</a-select-option>
<a-select-option value="all">{{ $t('members.allUsers') }}</a-select-option>
</a-select>
</div>
<div class="toolbar-right">
<a-button type="danger" size="small" @click="deleteSelected" :disabled="selectedMembers.length === 0">
<DeleteOutlined />
{{ $t('common.delete') }}
</a-button>
</div>
</div>
<div class="table-container">
<table class="member-table">
<thead>
<tr>
<th class="checkbox-col">
<input type="checkbox" @change="toggleAllSelection" :checked="isAllSelected" />
</th>
<th>{{ $t('members.userId') }}</th>
<th>{{ $t('members.username') }}</th>
<th>{{ $t('members.role') }}</th>
<th>{{ $t('members.level') }}</th>
<th>{{ $t('members.status') }}</th>
<th>{{ $t('members.points') }}</th>
<th>{{ $t('members.expiryDate') }}</th>
<th>{{ $t('members.operation') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in memberList" :key="member.id" class="table-row">
<td class="checkbox-col">
<input
type="checkbox"
:checked="selectedMembers.some(m => m.id === member.id)"
@change="toggleMemberSelection(member)" />
</td>
<td>{{ member.userId || member.id }}</td>
<td>{{ member.username }}</td>
<td>
<span class="role-tag" :class="getRoleClass(member.role)">
{{ getRoleLabel(member.role) }}
</span>
</td>
<td>
<span class="level-tag" :class="member.level === $t('members.professionalMember') ? 'professional' : 'standard'">
{{ member.level }}
</span>
</td>
<td>
<span class="status-tag" :class="member.isActive ? 'active' : 'banned'">
{{ member.isActive ? $t('members.active') : $t('members.banned') }}
</span>
</td>
<td>{{ member.points.toLocaleString() }}</td>
<td>{{ member.expiryDate }}</td>
<td>
<a type="primary" class="action-link" @click="editMember(member)">{{ $t('common.edit') }}</a>
<a
v-if="isSuperAdmin && member.role !== 'ROLE_SUPER_ADMIN'"
:type="member.role === 'ROLE_ADMIN' ? 'info' : 'primary'"
class="action-link"
@click="toggleRole(member)">
{{ member.role === 'ROLE_ADMIN' ? $t('members.revokeAdmin') : $t('members.setAdmin') }}
</a>
<a
:type="member.isActive ? 'warning' : 'success'"
class="action-link"
@click="toggleBan(member)">
{{ member.isActive ? $t('members.ban') : $t('members.unban') }}
</a>
<a type="danger" class="action-link" @click="deleteMember(member)">{{ $t('common.delete') }}</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination-container">
<div class="pagination">
<span class="page-arrow" @click="prevPage" :class="{ disabled: currentPage === 1 }"><LeftOutlined /></span>
<button
v-for="page in visiblePages"
:key="page"
class="page-btn"
:class="{ active: page === currentPage }"
@click="goToPage(page)">
{{ page }}
</button>
<template v-if="totalPages > 7 && currentPage < totalPages - 2">
<span class="page-ellipsis">...</span>
<button
class="page-btn"
:class="{ active: totalPages === currentPage }"
@click="goToPage(totalPages)">
{{ totalPages }}
</button>
</template>
<span class="page-arrow" @click="nextPage" :class="{ disabled: currentPage === totalPages }"><RightOutlined /></span>
</div>
</div>
</section>
</main>
<!-- 编辑会员对话框 -->
<a-modal
v-model:open="editDialogVisible"
:title="$t('members.editMember')"
width="500px"
@cancel="handleCloseEditDialog">
<a-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-width="100px">
<a-form-item :label="$t('members.userId')" prop="id">
<a-input v-model:value="editForm.id" disabled />
</a-form-item>
<a-form-item :label="$t('members.username')" prop="username">
<a-input v-model:value="editForm.username" :placeholder="$t('members.usernamePlaceholder')" />
</a-form-item>
<a-form-item v-if="isSuperAdmin && editForm.role !== 'ROLE_SUPER_ADMIN'" :label="$t('members.userRole')" prop="role">
<a-select v-model:value="editForm.role" :placeholder="$t('members.selectRole')">
<a-select-option value="ROLE_USER">{{ $t('members.normalUser') }}</a-select-option>
<a-select-option value="ROLE_ADMIN">{{ $t('members.admin') }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('members.level')" prop="level">
<a-select v-model:value="editForm.level" :placeholder="$t('members.levelPlaceholder')">
<a-select-option
v-for="level in membershipLevels"
:key="level.id"
:value="level.displayName"
>{{ level.displayName }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('members.points')" prop="points">
<a-input-number
v-model="editForm.points"
:min="0"
:max="99999"
:placeholder="$t('members.pointsPlaceholder')" />
</a-form-item>
<a-form-item :label="$t('members.expiryDate')" prop="expiryDate">
<a-date-picker
v-model:value="editForm.expiryDate"
type="date"
:placeholder="$t('members.expiryPlaceholder')"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD" />
</a-form-item>
</a-form>
<template #footer>
<span class="dialog-footer">
<a-button @click="editDialogVisible = false">{{ $t('common.cancel') }}</a-button>
<a-button type="primary" @click="saveEdit" :loading="saveLoading">{{ $t('common.save') }}</a-button>
</span>
</template>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { message, Modal } from 'ant-design-vue'
import {
LeftOutlined,
RightOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import * as memberAPI from '@/api/members'
import AdminSidebar from '@/components/AdminSidebar.vue'
import AdminHeader from '@/components/AdminHeader.vue'
const { t } = useI18n()
// 数据状态
const selectedMembers = ref([])
const selectedLevel = ref('all')
const selectedStatus = ref('active') // 默认显示活跃用户
const currentPage = ref(1)
const pageSize = ref(10)
const totalMembers = ref(50)
// 当前用户角色(从 localStorage 或 API 获取)
const currentUserRole = ref('')
const isSuperAdmin = computed(() => currentUserRole.value === 'ROLE_SUPER_ADMIN')
// 编辑相关状态
const editDialogVisible = ref(false)
const editFormRef = ref()
const saveLoading = ref(false)
const editForm = ref({
id: '',
username: '',
role: '',
level: '',
points: 0,
expiryDate: ''
})
// 表单验证规则
const editRules = {
username: [
{ required: true, message: t('members.usernamePlaceholder'), trigger: 'blur' },
{ min: 2, max: 20, message: t('register.usernameLength'), trigger: 'blur' }
],
level: [
{ required: true, message: t('members.levelPlaceholder'), trigger: 'change' }
],
points: [
{ required: true, message: t('members.pointsPlaceholder'), trigger: 'blur' },
{ type: 'number', min: 0, message: t('systemSettings.enterValidNumber'), trigger: 'blur' }
],
expiryDate: [
{ required: true, message: t('members.expiryPlaceholder'), trigger: 'change' }
]
}
// 会员数据
const memberList = ref([])
// 会员等级列表从API动态获取
const membershipLevels = ref([])
// 表格操作
const isAllSelected = computed(() => {
return memberList.value.length > 0 && selectedMembers.value.length === memberList.value.length
})
const totalPages = computed(() => {
return Math.ceil(totalMembers.value / pageSize.value)
})
const visiblePages = computed(() => {
const pages = []
const total = totalPages.value
const current = currentPage.value
if (total <= 7) {
// 如果总页数少于等于7显示所有页码
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 如果总页数大于7显示部分页码
if (current <= 3) {
// 当前页在前3页
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
} else if (current >= total - 2) {
// 当前页在后3页
for (let i = total - 4; i <= total; i++) {
pages.push(i)
}
} else {
// 当前页在中间
for (let i = current - 2; i <= current + 2; i++) {
pages.push(i)
}
}
}
return pages
})
const toggleAllSelection = () => {
if (isAllSelected.value) {
selectedMembers.value = []
} else {
selectedMembers.value = [...memberList.value]
}
}
const toggleMemberSelection = (member) => {
const index = selectedMembers.value.findIndex(m => m.id === member.id)
if (index > -1) {
selectedMembers.value.splice(index, 1)
} else {
selectedMembers.value.push(member)
}
}
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--
loadMembers()
}
}
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++
loadMembers()
}
}
const goToPage = (page) => {
currentPage.value = page
loadMembers()
}
const editMember = (member) => {
// 填充编辑表单
editForm.value = {
id: member.id,
username: member.username,
role: member.role,
// 这里必须用后端的 displayName否则 <a-select> 的 value 可能无法命中选项
level: member.levelDisplayName || member.level,
points: member.points,
expiryDate: member.expiryDate
}
editDialogVisible.value = true
}
const handleCloseEditDialog = () => {
editDialogVisible.value = false
// 重置表单
editFormRef.value?.resetFields()
}
const saveEdit = async () => {
if (!editFormRef.value) return
try {
// 验证表单
await editFormRef.value.validate()
saveLoading.value = true
// 调用API更新会员信息超级管理员可以修改角色
const updateData = {
username: editForm.value.username,
level: editForm.value.level,
points: editForm.value.points,
expiryDate: editForm.value.expiryDate
}
// 只有超级管理员才能修改角色,且不能修改超级管理员的角色
if (isSuperAdmin.value && editForm.value.role && editForm.value.role !== 'ROLE_SUPER_ADMIN') {
updateData.role = editForm.value.role
}
const response = await memberAPI.updateMember(editForm.value.id, updateData)
if (response.data && response.data.success) {
message.success(t('common.memberUpdateSuccess'))
editDialogVisible.value = false
// 重新加载列表以确保数据一致
await loadMembers()
} else {
message.error(response.data?.message || t('common.updateFailed'))
}
} catch (error) {
console.error('保存失败:', error)
message.error(t('common.saveFailed'))
} finally {
saveLoading.value = false
}
}
const deleteMember = async (member) => {
console.log('deleteMember 函数被调用, member:', member)
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('common.confirmDeleteTitle'),
content: t('members.confirmDeleteMember', { username: member.username }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
// 调用API删除会员
console.log('准备发送DELETE请求, id:', member.id)
const response = await memberAPI.deleteMember(member.id)
console.log('DELETE请求响应:', response)
if (response.data && response.data.success) {
message.success(t('common.deleteSuccess'))
// 重新加载列表
await loadMembers()
} else {
console.log('删除失败, response.data:', response.data)
message.error(response.data?.message || t('common.deleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
const errorMsg = error.response?.data?.message || t('common.deleteFailed')
message.error(errorMsg)
}
}
}
const deleteSelected = async () => {
if (selectedMembers.value.length === 0) {
message.warning(t('common.pleaseSelectMembers'))
return
}
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('common.confirmBatchDelete'),
content: t('members.confirmBatchDeleteMembers', { count: selectedMembers.value.length }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
const ids = selectedMembers.value.map(m => m.id)
// 调用API批量删除
const response = await memberAPI.deleteMembers(ids)
if (response.data && response.data.success) {
message.success(response.data.message || t('members.batchDeleteSuccess'))
selectedMembers.value = []
// 重新加载列表
await loadMembers()
} else {
message.error(response.data?.message || t('members.batchDeleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
const errorMsg = error.response?.data?.message || t('members.batchDeleteFailed')
message.error(errorMsg)
}
}
}
// 监听筛选条件变化
const handleFilterChange = () => {
currentPage.value = 1
loadMembers()
}
// 封禁/解封会员
const toggleBan = async (member) => {
const action = member.isActive ? t('members.ban') : t('members.unban')
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('members.confirmAction', { action: action }),
content: t('members.confirmBanAction', { action: action, username: member.username }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
const response = await memberAPI.toggleBanMember(member.id, !member.isActive)
if (response.data && response.data.success) {
message.success(response.data.message || t('members.actionSuccess', { action: action }))
await loadMembers()
} else {
message.error(response.data?.message || t('members.actionFailed', { action: action }))
}
} catch (error) {
if (error !== 'cancel') {
console.error(`${action}失败:`, error)
const errorMsg = error.response?.data?.message || t('members.actionFailed', { action: action })
message.error(errorMsg)
}
}
}
// 加载会员数据
const loadMembers = async () => {
try {
const response = await memberAPI.getMembers({
page: currentPage.value,
pageSize: pageSize.value,
level: selectedLevel.value === 'all' ? '' : selectedLevel.value,
status: selectedStatus.value
})
console.log('获取会员列表响应:', response)
// 处理API响应数据 - axios会将数据包装在response.data中
const data = response?.data || response || {}
console.log('解析后的数据:', data)
if (data && data.list) {
memberList.value = data.list.map(member => ({
id: member.id,
username: member.username,
role: member.role,
// 展示用(支持国际化)
level: getMembershipLevel(member.membership),
// 提交给后端用:保持与 MembershipLevel.displayName 一致,避免编辑弹窗下拉无法回显
levelDisplayName: member.membership?.display_name || member.membership?.displayName || member.membership?.display_name,
isActive: member.isActive,
points: member.points || 0,
expiryDate: getMembershipExpiry(member.membership)
}))
totalMembers.value = data.total || 0
console.log('设置后的会员列表:', memberList.value)
} else {
console.error('API返回数据格式错误:', data)
message.error(t('common.updateFailed'))
}
} catch (error) {
console.error('加载会员数据失败:', error)
message.error(t('common.loadTaskListFailed') + ': ' + (error.message || ''))
}
}
// 辅助函数:获取会员等级显示名称(支持国际化)
const getMembershipLevel = (membership) => {
if (!membership) return t('members.standardMember')
// 🔥 优先使用后端返回的 display_name后端已根据充值记录动态调整
const displayName = membership.display_name
if (displayName) {
// 直接匹配后端返回的具体名称
if (displayName === '入门会员') {
return t('members.starterMember') || '入门会员'
}
if (displayName === '免费会员') {
return t('members.freeMember')
}
// 模糊匹配(兼容老数据)
if (displayName.includes('入门') || displayName.toLowerCase().includes('starter')) {
return t('members.starterMember') || '入门级会员'
}
if (displayName.includes('免费') || displayName.toLowerCase().includes('free')) {
return t('members.freeMember')
}
if (displayName.includes('标准') || displayName.toLowerCase().includes('standard')) {
return t('members.standardMember')
}
if (displayName.includes('专业') || displayName.toLowerCase().includes('professional')) {
return t('members.professionalMember')
}
}
// 根据会员等级name进行翻译fallback
const levelName = membership.name || membership.level_name
if (levelName) {
const levelMap = {
'free': t('members.freeMember'),
'starter': t('members.starterMember'),
'standard': t('members.standardMember'),
'professional': t('members.professionalMember')
}
if (levelMap[levelName]) {
return levelMap[levelName]
}
}
return membership.display_name || t('members.standardMember')
}
// 辅助函数:获取会员到期时间
const getMembershipExpiry = (membership) => {
if (!membership) return '2025-12-31'
return membership.end_date ? membership.end_date.split(' ')[0] : '2025-12-31'
}
// 辅助函数:获取角色显示名称
const getRoleLabel = (role) => {
const roleMap = {
'ROLE_SUPER_ADMIN': t('members.superAdmin'),
'ROLE_ADMIN': t('members.admin'),
'ROLE_USER': t('members.normalUser')
}
return roleMap[role] || t('members.normalUser')
}
// 辅助函数:获取角色样式类
const getRoleClass = (role) => {
const classMap = {
'ROLE_SUPER_ADMIN': 'super-admin',
'ROLE_ADMIN': 'admin',
'ROLE_USER': 'user'
}
return classMap[role] || 'user'
}
// 设置/取消管理员
const toggleRole = async (member) => {
const newRole = member.role === 'ROLE_ADMIN' ? 'ROLE_USER' : 'ROLE_ADMIN'
const action = newRole === 'ROLE_ADMIN' ? t('members.setAdmin') : t('members.revokeAdmin')
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('members.confirmAction', { action: action }),
content: t('members.confirmRoleChange', { username: member.username, action: action }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
const response = await memberAPI.setUserRole(member.id, newRole)
if (response.data && response.data.success) {
message.success(response.data.message || t('members.actionSuccess', { action: action }))
await loadMembers()
} else {
message.error(response.data?.message || t('members.actionFailed', { action: action }))
}
} catch (error) {
if (error !== 'cancel') {
console.error(`${action}失败:`, error)
const errorMsg = error.response?.data?.message || t('members.actionFailed', { action: action })
message.error(errorMsg)
}
}
}
// 获取当前用户角色
const fetchCurrentUserRole = () => {
try {
// 登录时保存的是 'user' 而不是 'userInfo'
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
currentUserRole.value = user.role || 'ROLE_USER'
console.log('当前用户角色:', currentUserRole.value)
}
} catch (error) {
console.error('获取用户角色失败:', error)
currentUserRole.value = 'ROLE_USER'
}
}
// 加载会员等级列表
const loadMembershipLevels = async () => {
try {
const response = await memberAPI.getMembershipLevels()
if (response.data && response.data.success) {
membershipLevels.value = response.data.data || []
}
} catch (error) {
console.error('加载会员等级失败:', error)
}
}
onMounted(() => {
fetchCurrentUserRole()
loadMembershipLevels()
loadMembers()
})
</script>
<style scoped>
.member-management {
display: flex;
min-height: 100vh;
background: var(--bg-base);
font-family: var(--font-sans);
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-base);
}
/* 会员内容区域 */
.member-content {
padding: var(--space-6);
flex: 1;
background: var(--bg-surface);
margin: var(--space-6);
border-radius: var(--radius-md);
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.content-header h2 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
.selection-info {
font-size: var(--text-sm);
color: var(--text-tertiary);
background: var(--bg-glass);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
}
/* 确保下拉框选中值可见 */
.toolbar-left :deep(.ant-select) {
min-width: 120px;
}
.toolbar-left :deep(.ant-select-selector) {
color: var(--text-primary) !important;
font-size: var(--text-sm);
min-height: 32px;
}
.toolbar-left :deep(.ant-select-selector *) {
color: var(--text-primary) !important;
}
.toolbar-left :deep(.ant-select-selector) {
display: flex !important;
align-items: center !important;
}
.toolbar-left :deep(.ant-select-selection-item) {
color: var(--text-primary) !important;
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.toolbar-left :deep(.ant-select-selection-placeholder) {
color: var(--text-disabled) !important;
opacity: 1 !important;
visibility: visible !important;
}
.table-container {
background: var(--bg-surface);
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border-subtle);
margin-bottom: var(--space-6);
}
.member-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.member-table thead {
background: var(--bg-elevated);
}
.member-table th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: var(--font-semibold);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.member-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
}
.table-row:hover {
background: var(--bg-hover);
}
.checkbox-col {
width: 50px;
text-align: center;
}
.checkbox-col input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.level-tag {
display: inline-block;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-inverse);
}
.level-tag.professional {
background: var(--secondary-500);
color: var(--text-inverse);
}
.level-tag.standard {
background: var(--primary-500);
color: var(--text-inverse);
}
.status-tag {
display: inline-block;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
.status-tag.active {
background: var(--accent-500);
color: var(--text-inverse);
}
.status-tag.banned {
background: var(--error-500);
color: var(--text-inverse);
}
.role-tag {
display: inline-block;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
.role-tag.super-admin {
background: var(--error-600);
color: var(--text-inverse);
}
.role-tag.admin {
background: var(--warning-500);
color: var(--text-inverse);
}
.role-tag.UserOutlined {
background: var(--text-tertiary);
color: var(--text-inverse);
}
.role-icon {
margin-right: 4px;
}
.action-link {
margin-right: var(--space-3);
font-size: var(--text-sm);
text-decoration: none;
}
.action-link:last-child {
margin-right: 0;
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: var(--space-6);
}
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-arrow {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-default);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: var(--text-sm);
transition: var(--transition-all);
}
.page-arrow:hover:not(.disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.page-arrow.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn {
min-width: 32px;
height: 32px;
padding: 0 var(--space-3);
border: 1px solid var(--border-default);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: var(--text-sm);
transition: var(--transition-all);
display: flex;
align-items: center;
justify-content: center;
}
.page-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.page-btn.active {
background: var(--primary-500);
color: var(--text-inverse);
border-color: var(--primary-500);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-ellipsis {
padding: 0 var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-sm);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.member-management {
flex-direction: column;
}
.member-content {
padding: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
<template>
<div class="order-create">
<a-page-header @back="$router.go(-1)" :content="$t('orderCreate.title')">
<template #extra>
<a-button type="primary" @click="handleSubmit" :loading="loading">
<CheckOutlined />
{{ $t('orderCreate.createButton') }}
</a-button>
</template>
</a-page-header>
<a-card class="form-card">
<a-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
@submit.prevent="handleSubmit"
>
<a-form-item :label="$t('orderCreate.orderTypeLabel')" prop="orderType">
<a-select v-model:value="form.orderType" :placeholder="$t('orderCreate.orderTypePlaceholder')">
<a-select-option value="SERVICE">{{ $t('orderCreate.orderTypeService') }}</a-select-option>
<a-select-option value="SUBSCRIPTION">{{ $t('orderCreate.orderTypeSubscription') }}</a-select-option>
<a-select-option value="DIGITAL">{{ $t('orders.digitalProduct') }}</a-select-option>
<a-select-option value="VIRTUAL">{{ $t('orderCreate.orderTypeProduct') }}</a-select-option>
</a-select>
<div class="field-description">
<InfoFilled />
<span>{{ $t('orderCreate.orderTypeHint') }}</span>
</div>
</a-form-item>
<a-form-item :label="$t('orderCreate.currencyLabel')" prop="currency">
<a-select v-model:value="form.currency" :placeholder="$t('orderCreate.currencyPlaceholder')">
<a-select-option value="CNY">{{ $t('orderCreate.currencyCNY') }}</a-select-option>
<a-select-option value="USD">{{ $t('orderCreate.currencyUSD') }}</a-select-option>
<a-select-option value="EUR">EUR (Euro)</a-select-option>
</a-select>
<div class="field-description">
<InfoFilled />
<span>{{ $t('orderCreate.currencyHint') }}</span>
</div>
</a-form-item>
<a-form-item :label="$t('orderCreate.descriptionLabel')" prop="description">
<a-input
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="$t('orderCreate.descriptionPlaceholder')"
/>
<div class="field-description">
<InfoFilled />
<span>{{ $t('orderCreate.descriptionHint') }}</span>
</div>
</a-form-item>
<a-form-item :label="$t('orderCreate.contactEmailLabel')" prop="contactEmail">
<a-input
v-model="form.contactEmail"
type="email"
:placeholder="$t('orderCreate.contactEmailPlaceholder')"
/>
<div class="field-description">
<InfoFilled />
<span>{{ $t('orderCreate.contactEmailHint') }}</span>
</div>
</a-form-item>
<a-form-item :label="$t('orderCreate.contactPhoneLabel')" prop="contactPhone">
<a-input
v-model="form.contactPhone"
:placeholder="$t('orderCreate.contactPhonePlaceholder')"
/>
<div class="field-description">
<InfoFilled />
<span>{{ $t('orderCreate.contactPhoneHint') }}</span>
</div>
</a-form-item>
<!-- 虚拟商品不需要收货地址 -->
<template v-if="isPhysicalOrder">
<a-form-item :label="$t('orderCreate.shippingAddressLabel')" prop="shippingAddress">
<a-input
v-model="form.shippingAddress"
type="textarea"
:rows="3"
:placeholder="$t('orderCreate.shippingAddressPlaceholder')"
/>
</a-form-item>
<a-form-item :label="$t('orderCreate.billingAddressLabel')" prop="billingAddress">
<a-input
v-model="form.billingAddress"
type="textarea"
:rows="3"
:placeholder="$t('orderCreate.billingAddressPlaceholder')"
/>
</a-form-item>
</template>
<!-- 订单项 -->
<a-form-item :label="$t('orderCreate.virtualProductsLabel')">
<div class="field-description">
<InfoFilled />
<span>{{ $t('orderCreate.virtualProductsHint') }}</span>
</div>
<div class="order-items">
<div
v-for="(item, index) in form.orderItems"
:key="index"
class="order-item"
>
<a-row :gutter="20">
<a-col :span="8">
<a-input
v-model="item.productName"
:placeholder="$t('orderCreate.productNamePlaceholder')"
@input="calculateSubtotal(index)"
/>
</a-col>
<a-col :span="4">
<a-input-number
v-model="item.unitPrice"
:precision="2"
:min="0"
:placeholder="$t('orderCreate.unitPricePlaceholder')"
@change="calculateSubtotal(index)"
/>
</a-col>
<a-col :span="4">
<a-input-number
v-model="item.quantity"
:min="1"
:placeholder="$t('orderCreate.quantityPlaceholder')"
@change="calculateSubtotal(index)"
/>
</a-col>
<a-col :span="4">
<a-input
v-model="item.subtotal"
readonly
:placeholder="$t('orderCreate.subtotalPlaceholder')"
/>
</a-col>
<a-col :span="4">
<a-button
type="danger"
:icon="Delete"
circle
@click="removeItem(index)"
v-if="form.orderItems.length > 1"
/>
</a-col>
</a-row>
</div>
<a-button
type="primary"
:icon="Plus"
@click="addItem"
class="add-item-btn"
>
添加虚拟商品
</a-button>
</div>
</a-form-item>
<a-form-item>
<div class="total-amount">
<span class="total-label">订单总计</span>
<span class="total-value">{{ form.currency }} {{ totalAmount }}</span>
</div>
</a-form-item>
</a-form>
</a-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 { message } from 'ant-design-vue'
import { PlusOutlined, DeleteOutlined, CheckOutlined, UserOutlined } from '@ant-design/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) {
message.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) {
message.success(t('common.orderCreateSuccess'))
router.push('/admin/orders')
} else {
message.error(response.message || t('common.orderCreateFailed'))
}
} catch (error) {
console.error('Create order error:', error)
message.error(t('common.orderCreateFailed'))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.order-create {
max-width: 1200px;
margin: 0 auto;
}
.form-card {
margin-top: var(--space-5);
}
.order-items {
width: 100%;
}
.order-item {
margin-bottom: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
}
.add-item-btn {
margin-top: var(--space-4);
}
.total-amount {
text-align: right;
padding: var(--space-4);
background-color: var(--bg-elevated);
border-radius: var(--radius-md);
}
.total-label {
font-size: var(--text-base);
color: var(--text-secondary);
}
.field-description {
display: flex;
align-items: flex-start;
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
background-color: var(--primary-glow-subtle);
border: 1px solid var(--primary-glow-medium);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
color: var(--primary-500);
line-height: var(--leading-snug);
}
.field-description .anticon {
margin-right: var(--space-1);
margin-top: 1px;
flex-shrink: 0;
}
.field-description span {
flex: 1;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="order-detail">
<a-page-header @back="$router.go(-1)" :content="$t('orders.orderDetail')">
<template #extra>
<a-space>
<a-button v-if="order?.canPay()" type="primary" @click="handlePayment">
<CreditCardOutlined />
{{ $t('subscription.subscribe') }}
</a-button>
<a-button v-if="order?.canCancel()" danger @click="handleCancel">
<CloseOutlined />
{{ $t('common.cancel') }}
</a-button>
</a-space>
</template>
</a-page-header>
<a-card v-if="order" class="order-card">
<template #header>
<div class="order-header">
<h3>{{ $t('orders.basicInfo') }}</h3>
<a-tag :type="getStatusType(order.status)">
{{ getStatusText(order.status) }}
</a-tag>
</div>
</template>
<a-descriptions :column="2" border>
<a-descriptions-item :label="$t('orders.orderNumber')">{{ order.orderNumber }}</a-descriptions-item>
<a-descriptions-item :label="$t('orders.orderType')">{{ getOrderTypeText(order.orderType) }}</a-descriptions-item>
<a-descriptions-item :label="$t('orders.amount')">
<span class="amount">{{ order.currency }} {{ order.totalAmount }}</span>
</a-descriptions-item>
<a-descriptions-item :label="$t('orders.createTime')">{{ formatDate(order.createdAt) }}</a-descriptions-item>
<a-descriptions-item :label="$t('orders.email')" v-if="order.contactEmail">{{ order.contactEmail }}</a-descriptions-item>
<a-descriptions-item :label="$t('orders.phone')" v-if="order.contactPhone">{{ order.contactPhone }}</a-descriptions-item>
</a-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>
<a-table :data="order.orderItems" border>
<a-table-column prop="productName" :label="$t('orderCreate.productNamePlaceholder').split('')[0]" />
<a-table-column prop="unitPrice" :label="$t('orderCreate.unitPricePlaceholder')" width="120">
<template #default="{ row }">
{{ order.currency }} {{ row.unitPrice }}
</template>
</a-table-column>
<a-table-column prop="quantity" :label="$t('orderCreate.quantityPlaceholder')" width="80" />
<a-table-column prop="subtotal" :label="$t('orderCreate.subtotalPlaceholder')" width="120">
<template #default="{ row }">
{{ order.currency }} {{ row.subtotal }}
</template>
</a-table-column>
</a-table>
</div>
</a-card>
<a-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 { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
CheckOutlined,
CloseOutlined,
DollarOutlined,
CreditCardOutlined,
UserOutlined
} from '@ant-design/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': t('orderStatus.pending'),
'CONFIRMED': t('orderStatus.confirmed'),
'PAID': t('orderStatus.paid'),
'PROCESSING': t('orderStatus.processing'),
'SHIPPED': t('orderStatus.shipped'),
'DELIVERED': t('orderStatus.delivered'),
'COMPLETED': t('orderStatus.completed'),
'CANCELLED': t('orderStatus.cancelled'),
'REFUNDED': t('orderStatus.refunded')
}
return statusMap[status] || status
}
// 获取订单类型文本
const getOrderTypeText = (orderType) => {
const typeMap = {
'PRODUCT': t('orderType.product'),
'SERVICE': t('orderType.service'),
'SUBSCRIPTION': t('orderType.subscription'),
'DIGITAL': t('orderType.digital'),
'PHYSICAL': t('orderType.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 = () => {
message.info(t('common.paymentFeatureInDev'))
}
// 处理取消
const handleCancel = () => {
message.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 {
message.error(response.message || t('common.updateFailed'))
}
}
})
</script>
<style scoped>
.order-detail {
max-width: 1200px;
margin: 0 auto;
}
.order-card {
margin-top: var(--space-5);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.order-header h3 {
margin: 0;
}
.amount {
font-weight: var(--font-semibold);
color: var(--warning-500);
}
.order-description,
.order-items {
margin-top: var(--space-5);
}
.order-description h4,
.order-items h4 {
margin-bottom: var(--space-3);
color: var(--text-primary);
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<div class="payment-create">
<a-page-header @back="$router.go(-1)" :content="$t('paymentCreate.title')">
<template #extra>
<a-button type="primary" @click="handleSubmit" :loading="loading">
<CreditCardOutlined />
{{ $t('paymentCreate.createButton') }}
</a-button>
</template>
</a-page-header>
<a-card class="form-card">
<a-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
@submit.prevent="handleSubmit"
>
<a-form-item :label="$t('paymentCreate.orderIdLabel')" prop="orderId">
<a-input
v-model="form.orderId"
:placeholder="$t('paymentCreate.orderIdPlaceholder')"
clearable
/>
</a-form-item>
<a-form-item :label="$t('paymentCreate.amountLabel')" prop="amount">
<a-input-number
v-model="form.amount"
:precision="2"
:min="0.01"
:placeholder="$t('paymentCreate.amountPlaceholder')"
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="$t('paymentCreate.currencyLabel')" prop="currency">
<a-select v-model:value="form.currency" :placeholder="$t('paymentCreate.currencyPlaceholder')">
<a-select-option value="CNY">{{ $t('paymentCreate.currencyCNY') }}</a-select-option>
<a-select-option value="USD">{{ $t('paymentCreate.currencyUSD') }}</a-select-option>
<a-select-option value="EUR">EUR (Euro)</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('paymentCreate.paymentMethodLabel')" prop="paymentMethod">
<a-radio-group v-model:value="form.paymentMethod">
<a-radio value="ALIPAY">
<CreditCardOutlined />
{{ $t('orders.alipay') }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item :label="$t('paymentCreate.descriptionLabel')" prop="description">
<a-input
v-model="form.description"
type="textarea"
:rows="3"
:placeholder="$t('paymentCreate.descriptionPlaceholder')"
/>
</a-form-item>
<a-form-item :label="$t('paymentCreate.callbackUrlLabel')" prop="callbackUrl">
<a-input
v-model="form.callbackUrl"
:placeholder="$t('paymentCreate.callbackUrlPlaceholder')"
/>
</a-form-item>
<a-form-item :label="$t('paymentCreate.returnUrlLabel')" prop="returnUrl">
<a-input
v-model="form.returnUrl"
:placeholder="$t('paymentCreate.returnUrlPlaceholder')"
/>
</a-form-item>
</a-form>
</a-card>
<!-- 支付方式说明 -->
<a-card class="info-card">
<template #header>
<h4>{{ $t('paymentCreate.paymentMethodLabel') }}</h4>
</template>
<a-row :gutter="20">
<a-col :xs="24" :sm="12" :md="6">
<div class="payment-method-info">
<CreditCardOutlined :style="{ fontSize: 'var(--text-3xl)', color: 'var(--primary-500)' }" />
<h5>{{ $t('orders.alipay') }}</h5>
<p>{{ $t('paymentCreate.alipayDesc') }}</p>
</div>
</a-col>
</a-row>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
DollarOutlined,
CreditCardOutlined,
WalletOutlined,
PlusOutlined,
CheckOutlined,
CloseOutlined,
LeftOutlined,
RightOutlined,
UploadOutlined,
DownloadOutlined
} from '@ant-design/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))
message.success(t('common.paymentCreateSuccess'))
router.push('/payments')
} catch (error) {
console.error('Create payment error:', error)
message.error(t('common.paymentCreateFailed'))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.payment-create {
max-width: 1200px;
margin: 0 auto;
}
.form-card {
margin-top: var(--space-5);
}
.info-card {
margin-top: var(--space-5);
}
.info-card h4 {
margin: 0;
color: var(--text-primary);
}
.payment-method-info {
text-align: center;
padding: var(--space-5);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
transition: var(--transition-all);
}
.payment-method-info:hover {
background-color: var(--bg-elevated);
border-color: var(--primary-500);
}
.payment-method-info h5 {
margin: var(--space-3) 0 var(--space-2) 0;
color: var(--text-primary);
}
.payment-method-info p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
@media (max-width: 768px) {
.payment-method-info {
margin-bottom: var(--space-4);
}
}
</style>

View File

@@ -0,0 +1,751 @@
<template>
<div class="payments">
<!-- 页面标题 -->
<div class="page-header">
<h2>
<CreditCardOutlined />
{{ $t('payments.title') }}
</h2>
</div>
<!-- 筛选和搜索 -->
<a-card class="filter-card">
<a-row :gutter="20">
<a-col :xs="24" :sm="12" :md="8">
<a-select
v-model="filters.status"
:placeholder="$t('payments.statusPlaceholder')"
clearable
@change="handleFilterChange"
>
<a-select-option value="">{{ $t('payments.allStatus') }}</a-select-option>
<a-select-option value="PENDING">{{ $t('payments.pending') }}</a-select-option>
<a-select-option value="SUCCESS">{{ $t('payments.paid') }}</a-select-option>
<a-select-option value="FAILED">{{ $t('payments.failed') }}</a-select-option>
<a-select-option value="CANCELLED">{{ $t('payments.cancelled') }}</a-select-option>
</a-select>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-input
v-model="filters.search"
:placeholder="$t('payments.searchPlaceholder')"
clearable
@input="handleSearch"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-button @click="resetFilters">{{ $t('common.reset') }}</a-button>
<a-button type="success" @click="showSubscriptionDialog('standard')">{{ $t('subscription.standard') }}</a-button>
<a-button type="warning" @click="showSubscriptionDialog('professional')">{{ $t('subscription.professional') }}</a-button>
</a-col>
</a-row>
</a-card>
<!-- 支付记录列表 -->
<a-card class="payments-card">
<a-table
:data="payments"
:spinning="loading"
:empty-text="$t('subscription.noPointsHistory')"
>
<a-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>
</a-table-column>
<a-table-column prop="amount" :label="$t('orders.amount')" width="120">
<template #default="{ row }">
<span class="amount">{{ row.currency }} {{ row.amount }}</span>
</template>
</a-table-column>
<a-table-column prop="paymentMethod" :label="$t('orders.paymentMethod')" width="120">
<template #default="{ row }">
<a-tag :type="getPaymentMethodType(row.paymentMethod)">
{{ getPaymentMethodText(row.paymentMethod) }}
</a-tag>
</template>
</a-table-column>
<a-table-column prop="status" :label="$t('orders.status')" width="120">
<template #default="{ row }">
<a-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</a-tag>
</template>
</a-table-column>
<a-table-column prop="description" :label="$t('orders.description')" min-width="200">
<template #default="{ row }">
<span class="description">{{ row.description }}</span>
</template>
</a-table-column>
<a-table-column prop="createdAt" :label="$t('orders.createTime')" width="160">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</a-table-column>
<a-table-column prop="paidAt" :label="$t('orders.paidTime')" width="160">
<template #default="{ row }">
{{ row.paidAt ? formatDate(row.paidAt) : '-' }}
</template>
</a-table-column>
<a-table-column :label="$t('orders.operation')" width="280" fixed="right">
<template #default="{ row }">
<a-button
size="small"
@click="viewPaymentDetail(row)"
>
{{ $t('common.view') }}
</a-button>
<a-button
v-if="row.status === 'PENDING'"
size="small"
type="success"
@click="testPaymentComplete(row)"
>
{{ $t('common.confirmTest') }}
</a-button>
<a-button
size="small"
type="danger"
@click="handleDeletePayment(row)"
>
{{ $t('common.delete') }}
</a-button>
</template>
</a-table-column>
</a-table>
<!-- 分页 -->
<div class="pagination-container">
<a-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>
</a-card>
<!-- 支付详情对话框 -->
<a-modal
v-model:open="detailDialogVisible"
:title="$t('payments.paymentDetail')"
width="600px"
>
<div v-if="currentPayment">
<a-descriptions :column="2" border>
<a-descriptions-item :label="$t('orders.orderNumber')">{{ currentPayment.orderId }}</a-descriptions-item>
<a-descriptions-item :label="$t('orders.paymentMethod')">
<a-tag :type="getPaymentMethodType(currentPayment.paymentMethod)">
{{ getPaymentMethodText(currentPayment.paymentMethod) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item :label="$t('orders.amount')">
<span class="amount">{{ currentPayment.currency }} {{ currentPayment.amount }}</span>
</a-descriptions-item>
<a-descriptions-item :label="$t('orders.status')">
<a-tag :type="getStatusType(currentPayment.status)">
{{ getStatusText(currentPayment.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item :label="$t('payments.externalTransactionId')" v-if="currentPayment.externalTransactionId">
{{ currentPayment.externalTransactionId }}
</a-descriptions-item>
<a-descriptions-item :label="$t('orders.createTime')">{{ formatDate(currentPayment.createdAt) }}</a-descriptions-item>
<a-descriptions-item :label="$t('orders.paidTime')" v-if="currentPayment.paidAt">
{{ formatDate(currentPayment.paidAt) }}
</a-descriptions-item>
<a-descriptions-item :label="$t('payments.updateTime')">{{ formatDate(currentPayment.updatedAt) }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentPayment.description" class="payment-description">
<h4>{{ $t('orders.description') }}</h4>
<p>{{ currentPayment.description }}</p>
</div>
</div>
</a-modal>
<!-- 订阅对话框 -->
<a-modal
v-model:open="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">
<a-tag type="success">支付完成后可获得 {{ subscriptionInfo.points }} 积分</a-tag>
</div>
<div class="payment-method">
<h4>选择支付方式</h4>
<a-radio-group v-model:value="selectedPaymentMethod" @change="updatePrice">
<a-radio label="ALIPAY">支付宝</a-radio>
<a-radio label="PAYPAL">PayPal</a-radio>
</a-radio-group>
<div class="converted-price" v-if="convertedPrice">
<p>支付金额<span class="price-display">{{ convertedPrice }}</span></p>
</div>
</div>
</div>
<template #footer>
<a-button @click="subscriptionDialogVisible = false">取消</a-button>
<a-button type="primary" @click="createSubscription" :loading="subscriptionLoading">
立即订阅
</a-button>
</template>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
DollarOutlined,
CreditCardOutlined,
WalletOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
EyeOutlined,
ReloadOutlined,
DownloadOutlined,
UploadOutlined,
SettingOutlined,
CheckOutlined,
CloseOutlined,
WarningOutlined
} from '@ant-design/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 {
message.error(response.message || t('common.fetchPaymentsFailed'))
}
} catch (error) {
console.error('Fetch payments error:', error)
message.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) {
message.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) {
message.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')
message.success(t('common.redirectingToAlipay'))
} else {
message.error(alipayResponse.message || t('common.createAlipayFailed'))
}
} catch (error) {
console.error('创建支付宝支付失败:', error)
message.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')
message.success(t('common.redirectingToPaypal'))
} else {
message.error(paypalResponse.message || t('common.createPaypalFailed'))
}
} catch (error) {
console.error('创建PayPal支付失败:', error)
message.error(t('common.createPaypalFailed'))
}
}
subscriptionDialogVisible.value = false
// 刷新支付记录列表
fetchPayments()
} else {
message.error(response.message || t('common.createSubscriptionFailed'))
}
} catch (error) {
console.error('Create subscription error:', error)
message.error(t('common.createSubscriptionFailed'))
} finally {
subscriptionLoading.value = false
}
}
// 测试支付完成
const testPaymentComplete = async (payment) => {
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('common.confirmTest'),
content: t('common.confirmTestPayment', { orderId: payment.orderId }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
const response = await testPaymentCompleteApi(payment.id)
if (response.success) {
message.success(t('common.testPaymentSuccess'))
// 刷新支付记录列表
fetchPayments()
} else {
message.error(response.message || t('common.testPaymentFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('Test payment complete error:', error)
message.error(t('common.testPaymentFailed'))
}
}
}
// 删除支付记录
const handleDeletePayment = async (payment) => {
try {
await new Promise((resolve, reject) => {
Modal.confirm({
title: t('common.confirm'),
content: `确定要删除支付记录 ${payment.orderId} 吗?`,
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk() { resolve() },
onCancel() { reject('cancel') }
})
})
const response = await deletePayment(payment.id)
if (response.data?.success) {
message.success(t('common.deleteSuccess'))
// 刷新支付记录列表
fetchPayments()
} else {
message.error(response.data?.message || t('common.deleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('Delete payment error:', error)
message.error(t('common.deleteFailed'))
}
}
}
onMounted(() => {
fetchPayments()
})
</script>
<style scoped>
.payments {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: var(--space-5);
}
.page-header h2 {
margin: 0;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.filter-card {
margin-bottom: var(--space-5);
}
.payments-card {
margin-bottom: var(--space-5);
}
.order-link {
color: var(--primary-500);
text-decoration: none;
font-weight: var(--font-medium);
}
.order-link:hover {
text-decoration: underline;
}
.amount {
font-weight: var(--font-semibold);
color: var(--warning-500);
}
.description {
color: var(--text-secondary);
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: var(--space-5);
}
.payment-description {
margin-top: var(--space-5);
}
.payment-description h4 {
margin-bottom: var(--space-3);
color: var(--text-primary);
}
.payment-description p {
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.subscription-info {
text-align: center;
}
.subscription-info h3 {
color: var(--primary-500);
margin-bottom: var(--space-2);
}
.subscription-info .price {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
color: var(--error-400);
margin: var(--space-4) 0;
}
.subscription-info .description {
color: var(--text-tertiary);
margin-bottom: var(--space-4);
}
.subscription-info .benefits {
text-align: left;
margin: var(--space-4) 0;
}
.subscription-info .benefits h4 {
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.subscription-info .benefits ul {
list-style: none;
padding: 0;
}
.subscription-info .benefits li {
padding: var(--space-1) 0;
color: var(--text-tertiary);
}
.subscription-info .benefits li:before {
content: "✓ ";
color: var(--success-500);
font-weight: var(--font-bold);
}
.subscription-info .points-info {
margin-top: var(--space-4);
}
.subscription-info .payment-method {
margin-top: var(--space-6);
padding-top: var(--space-4);
border-top: 1px solid var(--border-default);
}
.subscription-info .payment-method h4 {
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.subscription-info .converted-price {
margin-top: var(--space-2);
padding: var(--space-2);
background-color: var(--primary-glow-subtle);
border-radius: var(--radius-sm);
border: 1px solid var(--primary-glow-medium);
}
.subscription-info .price-display {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--primary-500);
}
</style>

View 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: var(--bg-base);
padding: var(--space-10) var(--space-5);
overflow-y: auto;
}
.privacy-container {
max-width: 900px;
max-height: calc(100vh - 80px);
margin: 0 auto;
background: var(--bg-glass);
backdrop-filter: blur(20px);
border-radius: var(--radius-2xl);
padding: var(--space-16) var(--space-20);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-default);
overflow-y: auto;
overflow-x: hidden;
}
/* 自定义滚动条样式 */
.privacy-container::-webkit-scrollbar {
width: 8px;
}
.privacy-container::-webkit-scrollbar-track {
background: var(--bg-glass);
border-radius: var(--radius-full);
}
.privacy-container::-webkit-scrollbar-thumb {
background: var(--primary-glow-medium);
border-radius: var(--radius-full);
transition: background var(--duration-normal) var(--ease-default);
}
.privacy-container::-webkit-scrollbar-thumb:hover {
background: var(--primary-glow-strong);
}
/* Firefox 滚动条样式 */
.privacy-container {
scrollbar-width: thin;
scrollbar-color: var(--primary-glow-medium) var(--bg-glass);
}
.back-button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--primary-500);
font-size: var(--text-base);
cursor: pointer;
margin-bottom: var(--space-8);
transition: var(--transition-all);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
}
.back-button:hover {
background: var(--primary-glow-light);
transform: translateX(-4px);
}
.back-button svg {
width: 20px;
height: 20px;
}
.privacy-content {
color: var(--text-primary);
line-height: var(--leading-relaxed);
}
h1 {
font-size: var(--text-4xl);
font-weight: var(--font-semibold);
color: var(--primary-500);
margin-bottom: var(--space-5);
text-align: center;
}
.english-title {
margin-top: var(--space-16);
}
.update-date {
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
margin-bottom: var(--space-8);
font-style: italic;
}
h2 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-top: var(--space-10);
margin-bottom: var(--space-4);
}
h3 {
font-size: var(--text-xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
h4 {
font-size: var(--text-lg);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-top: var(--space-5);
margin-bottom: var(--space-3);
}
p {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.intro {
font-size: var(--text-base);
line-height: var(--leading-relaxed);
margin-bottom: var(--space-5);
}
section {
margin-bottom: var(--space-8);
}
ul {
list-style: none;
padding-left: 0;
margin: var(--space-4) 0;
}
ul li {
position: relative;
padding-left: var(--space-6);
margin-bottom: var(--space-3);
color: var(--text-secondary);
font-size: var(--text-base);
}
ul li::before {
content: "·";
position: absolute;
left: var(--space-2);
color: var(--primary-500);
font-size: var(--text-xl);
font-weight: var(--font-bold);
}
a {
color: var(--primary-500);
text-decoration: none;
transition: opacity var(--duration-normal) var(--ease-default);
}
a:hover {
opacity: 0.8;
text-decoration: underline;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-default), transparent);
margin: var(--space-16) 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.privacy-container {
padding: var(--space-10) var(--space-8);
}
h1 {
font-size: var(--text-3xl);
}
h2 {
font-size: var(--text-xl);
}
h3 {
font-size: var(--text-lg);
}
h4 {
font-size: var(--text-base);
}
p, ul li {
font-size: var(--text-sm);
}
}
@media (max-width: 480px) {
.privacy-page {
padding: var(--space-5) var(--space-3);
}
.privacy-container {
padding: var(--space-8) var(--space-5);
}
h1 {
font-size: var(--text-2xl);
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-base);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,513 @@
<template>
<div class="register">
<a-row justify="center" align="middle" class="register-container">
<a-col :xs="22" :sm="16" :md="12" :lg="8" :xl="6">
<a-card class="register-card">
<template #header>
<div class="register-header">
<UserOutlined style="font-size: 32px; color: #67C23A" />
<h2>{{ $t('register.title') }}</h2>
</div>
</template>
<a-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-width="80px"
@submit.prevent="handleRegister"
>
<a-form-item :label="$t('profile.username')" prop="username">
<a-input
v-model="registerForm.username"
:placeholder="$t('register.usernamePlaceholder')"
prefix-icon="User"
clearable
@blur="checkUsername"
/>
<div v-if="usernameChecking" class="checking-text">
<LoadingOutlined class="is-loading" />
{{ $t('common.loading') }}
</div>
<div v-if="usernameExists" class="error-text">
<CloseCircleFilled />
{{ $t('register.usernameExists') }}
</div>
<div v-if="usernameAvailable" class="success-text">
<CheckCircleFilled />
{{ $t('register.usernameAvailable') }}
</div>
</a-form-item>
<a-form-item :label="$t('profile.email')" prop="email">
<a-input
v-model="registerForm.email"
:placeholder="$t('register.emailPlaceholder')"
prefix-icon="Message"
clearable
@blur="checkEmail"
/>
<div v-if="emailChecking" class="checking-text">
<LoadingOutlined class="is-loading" />
{{ $t('common.loading') }}
</div>
<div v-if="emailExists" class="error-text">
<CloseCircleFilled />
{{ $t('register.emailExists') }}
</div>
<div v-if="emailAvailable" class="success-text">
<CheckCircleFilled />
{{ $t('register.emailAvailable') }}
</div>
</a-form-item>
<a-form-item :label="$t('login.passwordPlaceholder').replace('请输入', '')" prop="password">
<a-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>
</a-form-item>
<a-form-item :label="$t('common.confirm')" prop="confirmPassword">
<a-input
v-model="registerForm.confirmPassword"
type="password"
:placeholder="$t('register.confirmPasswordPlaceholder')"
prefix-icon="Lock"
show-password
clearable
/>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="agreeTerms">
{{ $t('register.agreement') }}
</a-checkbox>
</a-form-item>
<a-form-item>
<a-button
type="success"
size="large"
:loading="userStore.loading"
:disabled="!canRegister"
@click="handleRegister"
class="register-button"
>
{{ userStore.loading ? $t('register.registering') : $t('register.registerButton') }}
</a-button>
</a-form-item>
</a-form>
<div class="register-footer">
<p>{{ $t('register.haveAccount') }}<router-link to="/login" class="login-link">{{ $t('register.loginNow') }}</router-link></p>
</div>
</a-card>
</a-col>
</a-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 { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import {
UserOutlined,
LockOutlined,
LoadingOutlined,
CloseCircleFilled,
CheckCircleFilled,
MailOutlined,
PhoneOutlined,
CalendarOutlined,
EnvironmentOutlined
} from '@ant-design/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: t('register.usernameRequired'), trigger: 'blur' },
{ min: 3, max: 20, message: t('register.usernameLength'), trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: t('register.usernameFormat'), trigger: 'blur' }
],
email: [
{ required: true, message: t('register.emailRequired'), trigger: 'blur' },
{ type: 'email', message: t('register.emailFormat'), trigger: 'blur' }
],
password: [
{ required: true, message: t('register.passwordRequired'), trigger: 'blur' },
{ min: 6, max: 20, message: t('register.passwordLength'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: t('register.confirmPasswordRequired'), trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error(t('register.passwordMismatch')))
} 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('CheckOutlined 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('CheckOutlined 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) {
message.warning(t('common.pleaseAgreeTerms'))
return
}
const result = await userStore.registerUser(registerForm)
if (result.success) {
message.success(result.message || t('common.success'))
router.push('/login')
} else {
message.error(result.message || t('common.updateFailed'))
}
} catch (error) {
console.error('Register error:', error)
message.error(t('common.registerFailed'))
}
}
</script>
<style scoped>
.register {
min-height: calc(100vh - 120px);
background: linear-gradient(135deg, var(--secondary-500) 0%, var(--secondary-600) 100%);
padding: var(--space-10) 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: var(--shadow-lg);
border-radius: var(--radius-lg);
backdrop-filter: blur(10px);
background: var(--bg-surface);
border: 1px solid var(--border-default);
}
.register-header {
text-align: center;
margin-bottom: var(--space-5);
}
.register-header h2 {
margin: var(--space-3) 0 0 0;
color: var(--text-primary);
font-weight: var(--font-semibold);
}
.register-button {
width: 100%;
height: 45px;
font-size: var(--text-base);
}
.register-footer {
text-align: center;
margin-top: var(--space-5);
}
.register-footer p {
margin: 0;
color: var(--text-secondary);
}
.login-link {
color: var(--success-500);
text-decoration: none;
font-weight: var(--font-medium);
}
.login-link:hover {
text-decoration: underline;
}
.terms-link {
color: var(--primary-500);
text-decoration: none;
}
.terms-link:hover {
text-decoration: underline;
}
.checking-text, .error-text, .success-text {
font-size: var(--text-xs);
margin-top: var(--space-1);
display: flex;
align-items: center;
gap: var(--space-1);
}
.checking-text {
color: var(--text-tertiary);
}
.error-text {
color: var(--error-400);
}
.success-text {
color: var(--success-500);
}
.password-strength {
margin-top: var(--space-2);
}
.strength-bar {
height: 4px;
background-color: var(--bg-elevated);
border-radius: 2px;
overflow: hidden;
margin-bottom: var(--space-1);
}
.strength-fill {
height: 100%;
transition: all var(--duration-slow) var(--ease-default);
}
.strength-fill.weak {
background-color: var(--error-400);
}
.strength-fill.fair {
background-color: var(--warning-500);
}
.strength-fill.good {
background-color: var(--primary-500);
}
.strength-fill.strong {
background-color: var(--success-500);
}
.strength-text {
font-size: var(--text-xs);
color: var(--text-secondary);
}
@media (max-width: 768px) {
.register {
padding: var(--space-5) 0;
}
.register-container {
min-height: calc(100vh - 160px);
}
}
</style>

View 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">
<a-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">
<a-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>
<!-- 确定按钮 -->
<a-button
type="primary"
class="submit-button"
:loading="loading"
@click="handleSubmit"
>
{{ loading ? $t('setPassword.submitting') : $t('setPassword.confirm') }}
</a-button>
<!-- 跳过按钮 -->
<div class="skip-button-wrapper">
<a-button
class="skip-button"
@click="handleSkip"
>
{{ $t('setPassword.skipForNow') }}
</a-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 { message } from 'ant-design-vue'
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) {
message.success(t('common.passwordSetSuccess'))
// 清除首次设置标记
localStorage.removeItem('needSetPassword')
// 跳转到首页或之前的页面
const redirect = route.query.redirect || '/'
router.replace(redirect)
} else {
message.error(result?.message || t('common.setFailed'))
}
} catch (error) {
console.error('设置密码失败:', error)
const errorMsg = error.response?.data?.message || error.message || t('common.setFailed')
message.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: var(--bg-root) url('/images/backgrounds/login_bg.png') center/cover no-repeat;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
font-family: var(--font-sans);
margin: 0;
padding: 0;
z-index: var(--z-base);
}
/* 左上角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: var(--bg-glass);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
border-radius: var(--radius-2xl);
border: 1px solid var(--border-default);
padding: 60px 80px;
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/* 页面标题 */
.page-title {
text-align: center;
font-size: var(--text-4xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin-bottom: 50px;
}
/* 表单 */
.password-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* 输入组 */
.input-group {
margin-bottom: 5px;
}
.password-input {
width: 100%;
}
.password-input :deep(.ant-input) {
background: var(--bg-glass);
border: none;
border-radius: var(--radius-lg);
box-shadow: none;
height: 60px;
transition: all var(--duration-slow) var(--ease-default);
}
.password-input :deep(.ant-input:hover) {
background: var(--bg-glass-hover);
}
.password-input :deep(.ant-input:focus) {
background: var(--bg-active);
box-shadow: none;
}
.password-input :deep(.ant-input) {
color: var(--text-primary);
background: transparent;
font-size: var(--text-base);
}
.password-input :deep(.ant-input::placeholder) {
color: var(--text-tertiary);
}
.input-error {
color: var(--error-400);
font-size: var(--text-xs);
margin-top: var(--space-1);
text-align: left;
}
.input-hint {
color: var(--text-tertiary);
font-size: var(--text-xs);
margin-top: var(--space-1);
text-align: left;
}
/* 确定按钮 */
.submit-button {
width: 100%;
height: 60px;
background: var(--primary-500);
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: var(--text-lg);
font-weight: var(--font-medium);
margin-top: var(--space-5);
transition: all var(--duration-slow) var(--ease-default);
}
.submit-button:hover {
background: var(--primary-400);
transform: translateY(-1px);
}
.submit-button:active {
transform: translateY(0);
}
/* 跳过按钮 */
.skip-button {
width: 100%;
height: 60px;
background: var(--bg-glass);
border: none;
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: var(--text-lg);
font-weight: var(--font-medium);
transition: all var(--duration-slow) var(--ease-default);
}
.skip-button:hover {
background: var(--bg-glass-hover);
}
.skip-button-wrapper {
width: 100%;
}
.skip-button-wrapper .skip-button {
width: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card {
width: 90%;
padding: var(--space-10) var(--space-8);
}
.page-title {
font-size: var(--text-3xl);
}
}
@media (max-width: 480px) {
.login-card {
padding: var(--space-8) var(--space-5);
}
.page-title {
font-size: var(--text-2xl);
}
}
</style>

View 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">
<UserOutlined />
<span>个人主页</span>
</div>
<div class="nav-item" @click="goToSubscription">
<CompassOutlined />
<span>会员订阅</span>
</div>
<div class="nav-item" @click="goToMyWorks">
<FileOutlined />
<span>我的作品</span>
</div>
<div class="nav-divider"></div>
<div class="nav-item" @click="goToTextToVideo">
<PlayCircleOutlined />
<span>文生视频</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToImageToVideo">
<PictureOutlined />
<span>图生视频</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
<VideoCameraOutlined />
<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="$t('dashboard.userAvatar')" 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">
<a-button type="primary">编辑资料</a-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)">
<a-button type="primary" size="small" round>
<PlayCircleOutlined />
{{ $t('works.createSimilar') }}
</a-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">
<a-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">{{ $t('works.createSimilar') }}</a-button>
</div>
<div class="work-director" v-else>
<span>DIRECTED BY VANNOCENT</span>
</div>
</div>
</div>
</div>
</main>
<!-- 作品详情模态框 -->
<a-modal
v-model:open="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>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { message } from 'ant-design-vue'
import { UserOutlined, FileOutlined, PlayCircleOutlined, PictureOutlined, VideoCameraOutlined, CompassOutlined } from '@ant-design/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-LoadingOutlined {
background: linear-gradient(90deg, var(--bg-surface) 25%, var(--bg-elevated) 50%, var(--bg-surface) 75%);
background-size: 200% 100%;
animation: lazy-shimmer 1.5s infinite;
}
.lazy-loaded {
animation: lazy-fade-in 0.3s ease-in;
}
.lazy-error {
background: var(--bg-surface);
}
@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: var(--bg-base);
color: var(--text-primary);
margin: 0;
padding: 0;
}
/* 左侧导航栏 */
.sidebar {
width: 280px !important;
background: var(--bg-base) !important;
padding: 24px 0 !important;
border-right: 1px solid var(--border-subtle) !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: var(--radius-md);
cursor: pointer;
transition: var(--transition-all);
position: relative;
}
.nav-item:hover {
background: var(--bg-hover);
}
.nav-item.active {
background: var(--primary-900);
}
.nav-item .anticon {
margin-right: 14px;
font-size: var(--text-xl);
}
.nav-item span {
font-size: var(--text-sm);
flex: 1;
}
.nav-divider {
height: 1px;
background: var(--border-default);
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: var(--text-primary) !important;
font-weight: var(--font-bold) !important;
font-size: var(--text-xs) !important;
padding: 2px 8px !important;
border-radius: var(--radius-lg) !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: var(--space-5);
}
/* 用户信息卡片 */
.user-info-card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 24px;
display: flex;
align-items: center;
gap: var(--space-5);
}
.user-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--border-default);
overflow: hidden;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-details {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.username {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.profile-prompt {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.user-id {
font-size: var(--text-xs);
color: var(--text-muted);
}
.edit-profile-btn {
margin-left: auto;
}
/* 已发布作品区域 */
.published-works {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.works-tabs {
display: flex;
gap: var(--space-6);
}
.tab {
padding: 8px 0;
color: var(--text-tertiary);
cursor: pointer;
position: relative;
}
.tab.active {
color: var(--text-primary);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -8px;
left: 0;
right: 0;
height: 2px;
background: var(--primary-500);
}
.works-AppstoreOutlined {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.work-item {
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
overflow: hidden;
transition: var(--transition-all);
cursor: pointer;
}
.work-item:hover {
border-color: var(--primary-500);
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: var(--transition-all);
z-index: 10;
}
.work-thumbnail:hover .hover-create-btn {
opacity: 1;
transform: translateY(0);
}
.hover-create-btn .ant-btn {
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 .ant-btn: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: var(--space-1);
}
.work-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-actions {
padding: 0 12px 12px;
opacity: 0;
transition: opacity var(--duration-normal) var(--ease-default);
}
.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: var(--text-xs);
color: var(--text-muted);
font-style: italic;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.sidebar {
width: 260px;
}
.works-AppstoreOutlined {
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-AppstoreOutlined {
grid-template-columns: 1fr;
}
}
/* 模态框样式 */
:deep(.detail-dialog .ant-modal) {
background: var(--bg-base) !important;
border-radius: var(--radius-lg);
border: 1px solid var(--border-default) !important;
box-shadow: var(--shadow-xl);
}
:deep(.detail-dialog .ant-modal-wrap) {
background-color: rgba(0, 0, 0, 0.8) !important;
}
:deep(.detail-dialog .ant-modal-header) {
background: var(--bg-base) !important;
padding: 16px 20px;
border-bottom: 1px solid var(--border-default);
}
:deep(.detail-dialog .ant-modal-title) {
color: var(--text-primary) !important;
font-size: var(--text-lg);
font-weight: var(--font-semibold);
}
:deep(.detail-dialog .ant-modal-close) {
color: var(--text-primary) !important;
}
:deep(.detail-dialog .ant-modal-body) {
background: var(--bg-base) !important;
padding: 0 !important;
}
:deep(.detail-dialog .ant-modal-mask) {
background-color: rgba(0, 0, 0, 0.8) !important;
}
/* 全局覆盖Element Plus默认样式 */
:deep(.ant-modal) {
background: var(--bg-base) !important;
border: 1px solid var(--border-default) !important;
}
:deep(.ant-modal-wrap) {
background-color: rgba(0, 0, 0, 0.8) !important;
}
:deep(.ant-modal-header) {
background: var(--bg-base) !important;
}
:deep(.ant-modal-body) {
background: var(--bg-base) !important;
}
:deep(.ant-modal-mask) {
background-color: rgba(0, 0, 0, 0.8) !important;
}
.detail-content {
display: flex;
height: 50vh;
background: var(--bg-base);
}
.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: var(--radius-md);
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 var(--duration-normal) var(--ease-default);
}
.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: var(--text-2xl);
color: var(--text-inverse);
font-weight: var(--font-bold);
}
.detail-right {
flex: 1;
padding: 20px;
background: var(--bg-base);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.metadata-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.metadata-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.metadata-item:last-child {
border-bottom: none;
}
.label {
font-size: var(--text-sm);
color: var(--text-tertiary);
font-weight: var(--font-medium);
}
.value {
font-size: var(--text-sm);
color: var(--text-primary);
font-weight: var(--font-semibold);
}
.description-section {
flex: 1;
}
.section-title {
font-size: var(--text-base);
color: var(--text-primary);
font-weight: var(--font-semibold);
margin-bottom: 12px;
}
.description-text {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.action-section {
margin-top: auto;
}
.create-similar-btn {
width: 100%;
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: var(--text-primary);
border: none;
padding: 12px 24px;
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: var(--font-semibold);
cursor: pointer;
transition: var(--transition-all);
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: var(--font-medium);
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>

Some files were not shown because too many files have changed in this diff Show More