feat(router): 添加试题讲解视频功能

This commit is contained in:
cc 2026-03-20 16:57:32 +08:00
parent 91620af9b9
commit 6fb13bd12f
6 changed files with 1082 additions and 1 deletions

View File

@ -0,0 +1,141 @@
---
name: 英语试题讲解视频生成页面开发
overview: 开发一个英语试题讲解视频生成页面包含试题文本输入、视频生成按钮、AI模型调用及视频预览区域支持生成进度展示。
design:
architecture:
framework: react
styleKeywords:
- Dark Glassmorphism
- Teal Accent
- Smooth Animations
- Card-based Layout
fontSystem:
fontFamily: Inter, PingFang SC, -apple-system, sans-serif
heading:
size: 1.2rem
weight: 600
subheading:
size: 1rem
weight: 600
body:
size: 0.95rem
weight: 400
colorSystem:
primary:
- "#14b8a6"
- "#0d9488"
background:
- "#0a0a0f"
- rgba(255,255,255,0.03)
text:
- "#ffffff"
- rgba(255,255,255,0.7)
functional:
- "#14b8a6 (生成/播放)"
- "#f87171 (错误)"
- "#fbbf24 (进度)"
todos:
- id: create-video-explanation-page
content: 创建 VideoExplanation.vue 页面组件,包含输入区、生成按钮、进度展示和视频预览区
status: completed
- id: add-router-config
content: 在 router/index.js 添加 /video-explanation 路由配置
status: completed
dependencies:
- create-video-explanation-page
- id: add-homepage-nav-entry
content: 在 HomePage.vue 添加视频讲解功能入口卡片
status: completed
dependencies:
- add-router-config
---
## 产品概述
开发一个英语试题讲解视频生成页面用户输入试题内容后系统调用最优AI模型生成对应的讲解视频。
## 核心功能
1. **试题输入**:提供文本输入框,支持用户粘贴或输入英语试题内容
2. **视频生成**调用AI模型生成讲解视频提供生成进度展示
3. **视频预览**:在页面右侧展示生成的视频内容,支持播放控制
4. **状态管理**:处理空闲、生成中、已完成、错误等多种状态
## 界面布局
- 左侧面板:试题文本输入区 + 生成按钮
- 右侧面板:视频预览区域 + 生成进度
- 顶部导航栏:包含返回按钮和页面标题
## 技术栈
- **前端框架**Vue 3 (Composition API + script setup)
- **路由管理**Vue Router
- **HTTP请求**Axios (支持流式响应)
- **构建工具**Vite
## 实现方案
### 架构设计
- 页面组件VideoExplanation.vue单文件组件
- 路由配置:在 router/index.js 中添加 `/video-explanation` 路由
- 导航入口:在 HomePage.vue 添加功能入口卡片
### 核心模块
1. **输入模块**:文本输入框,支持内容字数统计
2. **生成模块**调用AI视频生成API实时获取生成进度
3. **预览模块**:视频播放器组件,支持播放/暂停控制
4. **状态模块**:统一管理 idle/generating/done/error 状态
### API调用策略
- 使用 Axios 发起 POST 请求调用视频生成接口
- 支持流式响应解析,实时更新生成进度
- 进度信息通过 SSE 或增量文本返回
### 目录结构
```
src/
├── views/
│ └── VideoExplanation.vue # [NEW] 视频讲解页面组件
├── router/
│ └── index.js # [MODIFY] 添加视频讲解路由
└── views/
└── HomePage.vue # [MODIFY] 添加导航入口卡片
```
## 设计风格
继承项目现有的深色半透明玻璃态设计语言,保持一致的视觉体验:
- 深色背景 (#0a0a0f) 配合半透明卡片
- Teal 主色调 (#14b8a6) 用于主要按钮和强调元素
- 圆角设计 (16-20px) 营造现代感
- 毛玻璃效果 (backdrop-filter: blur) 增强层次感
- 柔和阴影和渐变边框提升质感
## 布局设计
### 页面结构
- **顶部导航栏**:返回按钮 + 页面标题(渐变色)
- **主内容区**左右分栏布局420px左侧 + 自适应右侧)
- **左侧面板**:输入区域 + 操作按钮
- **右侧面板**:视频预览 + 进度展示
### 视频预览区
- 视频播放器居中展示
- 支持播放/暂停/进度条控制
- 生成中显示动画进度指示器
- 空闲状态显示引导提示
## 动效设计
- 按钮悬停:上浮 + 阴影增强
- 加载状态旋转spinner + 脉冲动画
- 卡片入场fadeInUp 动画
- 进度展示:动态进度条 + 百分比

View File

@ -5,6 +5,7 @@ import Speaking from '../views/Speaking.vue'
import EssayCorrection from '../views/EssayCorrection.vue'
import ExamAnalysis from '../views/ExamAnalysis.vue'
import SpellPractice from '../views/SpellPractice.vue'
import VideoExplanation from '../views/VideoExplanation.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -38,6 +39,11 @@ const router = createRouter({
path: '/spell-practice',
name: 'spell-practice',
component: SpellPractice
},
{
path: '/video-explanation',
name: 'video-explanation',
component: VideoExplanation
}
]
})

View File

@ -53,6 +53,14 @@ const features = ref([
icon: "speaker",
route: "/pronunciation",
},
{
id: 7,
title: "试题讲解视频",
desc: "输入英语试题,一键调用 OpenAI Sora 生成专业讲解视频AI 智能分析考点并生成可视化讲解内容。",
class: "card-7",
icon: "video",
route: "/video-explanation",
},
]);
// Hover effect for glassmorphism glare
@ -187,6 +195,22 @@ onUnmounted(() => {
/>
</svg>
<!-- Video Icon (Play Button) -->
<svg
v-else-if="feature.icon === 'video'"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
/>
</svg>
<!-- Speaker Icon (Megaphone/Speaker) -->
<svg
v-else-if="feature.icon === 'speaker'"
@ -374,6 +398,11 @@ h1 {
box-shadow: 0 8px 20px -6px rgba(239, 68, 68, 0.5);
}
.card-7 .icon-wrapper {
background: linear-gradient(135deg, #f472b6, #db2777);
box-shadow: 0 8px 20px -6px rgba(244, 114, 182, 0.5);
}
.card-title {
font-size: 1.5rem;
font-weight: 600;

View File

@ -110,6 +110,15 @@ let msgIdCounter = 0;
let currentAudioInstance = null;
const blobUrls = [];
// UUID
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
const currentScene = computed(() => scenes.find((s) => s.id === activeScene.value));
//
@ -173,7 +182,7 @@ const synthesizeAndPlay = async (text, msgId) => {
"X-Api-App-Id": DOUBAO_APP_ID,
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
"X-Api-Request-Id": crypto.randomUUID(),
"X-Api-Request-Id": generateUUID(),
},
body: JSON.stringify(payload),
});

View File

@ -0,0 +1,895 @@
<script setup>
import { ref, computed, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import axios from "axios";
import { GRS_API_KEY, ESSAY_API_URL } from "@/config";
//
const status = ref("idle"); // idle | generating | done | error
const textInput = ref("");
const errorMsg = ref("");
const videoUrl = ref("");
const progress = ref(0);
const progressText = ref("");
const cancelTokenSource = ref(null);
// API (nano-banana)
const API_KEY = GRS_API_KEY;
const API_URL = ESSAY_API_URL;
// Router
const router = useRouter();
const canGenerate = computed(() => {
if (status.value === "generating") return false;
return textInput.value.trim().length > 0;
});
const goBack = () => router.back();
const generateVideo = async () => {
if (!canGenerate.value) return;
if (!API_KEY) {
errorMsg.value = "请先在设置中配置 API Key";
status.value = "error";
return;
}
status.value = "generating";
errorMsg.value = "";
videoUrl.value = "";
progress.value = 0;
progressText.value = "正在连接 AI 模型...";
cancelTokenSource.value = axios.CancelToken.source();
// API
const progressSteps = [
{ progress: 10, text: "正在分析试题内容..." },
{ progress: 25, text: "正在生成讲解脚本..." },
{ progress: 45, text: "正在调用 nano-banana 生成视频..." },
{ progress: 70, text: "视频渲染中..." },
{ progress: 90, text: "视频处理完成..." },
];
let stepIndex = 0;
const progressInterval = setInterval(() => {
if (stepIndex < progressSteps.length && status.value === "generating") {
progress.value = progressSteps[stepIndex].progress;
progressText.value = progressSteps[stepIndex].text;
stepIndex++;
}
}, 1500);
try {
// nano-banana API
const response = await axios.post(
API_URL,
{
model: "nano-banana-pro",
prompt: `请生成一个英语试题讲解视频。试题内容:${textInput.value}`,
aspectRatio: "16:9",
duration: 30,
webHook: "",
shutProgress: true,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
cancelToken: cancelTokenSource.value.token,
timeout: 300000, // 5
}
);
clearInterval(progressInterval);
// API
let responseData = response.data;
// SSE (data: {...})
if (typeof responseData === "string") {
const jsonStr = responseData.replace(/^data:\s*/m, "").trim();
try {
responseData = JSON.parse(jsonStr);
} catch {
// 使
}
}
//
let generatedVideoUrl = "";
if (responseData?.data?.video_url) {
// 1: { data: { video_url: "..." } }
generatedVideoUrl = responseData.data.video_url;
} else if (responseData?.data?.url) {
// 2: { data: { url: "..." } }
generatedVideoUrl = responseData.data.url;
} else if (responseData?.video_url) {
// 3: { video_url: "..." }
generatedVideoUrl = responseData.video_url;
} else if (responseData?.url) {
// 4: { url: "..." }
generatedVideoUrl = responseData.url;
} else if (responseData?.results?.[0]?.url) {
// 5: { results: [{ url: "..." }] }
generatedVideoUrl = responseData.results[0].url;
} else if (responseData?.output) {
// 6: { output: "..." }
generatedVideoUrl = responseData.output;
} else {
throw new Error("无法解析视频 URL请检查 API 响应格式");
}
progress.value = 100;
progressText.value = "视频生成完成!";
videoUrl.value = generatedVideoUrl;
status.value = "done";
} catch (err) {
clearInterval(progressInterval);
if (axios.isCancel(err)) return;
console.error("视频生成失败:", err);
errorMsg.value =
err?.response?.data?.error?.message ||
err?.response?.data?.message ||
err?.response?.data?.error ||
err.message ||
"视频生成失败,请稍后重试";
status.value = "error";
}
};
const cancelGeneration = () => {
if (cancelTokenSource.value) {
cancelTokenSource.value.cancel("用户取消生成");
}
status.value = "idle";
progress.value = 0;
progressText.value = "";
};
const resetAll = () => {
status.value = "idle";
textInput.value = "";
videoUrl.value = "";
errorMsg.value = "";
progress.value = 0;
progressText.value = "";
};
onUnmounted(() => {
if (cancelTokenSource.value) {
cancelTokenSource.value.cancel("组件卸载,取消生成");
}
});
</script>
<template>
<div class="page-container">
<!-- Nav Bar -->
<div class="nav-bar">
<button class="back-btn" @click="goBack">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
<span>返回</span>
</button>
<h1 class="nav-title">英语试题讲解视频</h1>
<div style="width: 72px"></div>
</div>
<!-- Error Banner -->
<div v-if="errorMsg" class="error-banner">
<span>{{ errorMsg }}</span>
<button class="error-close" @click="errorMsg = ''"></button>
</div>
<!-- Main Layout -->
<div class="main-layout">
<!-- Left Panel: Input -->
<div class="left-panel">
<!-- Input Card -->
<div class="input-card">
<div class="input-header">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
<span>试题内容</span>
</div>
<textarea
v-model="textInput"
class="text-area"
placeholder="请输入或粘贴英语试题内容,例如:&#10;&#10;Choose the correct answer:&#10;The teacher asked the students _____ they had finished their homework.&#10;A. that B. if C. what D. whether"
:disabled="status === 'generating'"
></textarea>
<div class="char-count">{{ textInput.length }} 字符</div>
</div>
<!-- Generate Button -->
<div class="btn-wrap">
<button
v-if="status === 'idle' || status === 'error'"
class="generate-btn"
:disabled="!canGenerate"
@click="generateVideo"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
<span>生成讲解视频</span>
</button>
<button
v-else-if="status === 'generating'"
class="cancel-btn"
@click="cancelGeneration"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>取消生成</span>
</button>
<button v-if="status === 'done'" class="reset-btn" @click="resetAll">
重新生成
</button>
</div>
</div>
<!-- Right Panel: Video Preview -->
<div class="right-panel">
<!-- Idle State -->
<div v-if="status === 'idle'" class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
</svg>
</div>
<p class="empty-title">视频预览区</p>
<p class="empty-hint">
输入试题内容并点击生成讲解视频AI 将自动生成专业的讲解视频
</p>
</div>
<!-- Generating State -->
<div v-else-if="status === 'generating'" class="generating-state">
<div class="generating-card">
<div class="generating-icon">
<div class="video-icon-wrapper">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
</div>
<p class="generating-title">AI 正在生成视频</p>
<p class="generating-model">nano-banana</p>
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: progress + '%' }"
></div>
</div>
<div class="progress-info">
<span class="progress-percent">{{ progress }}%</span>
<span class="progress-text">{{ progressText }}</span>
</div>
</div>
</div>
</div>
<!-- Done State -->
<div v-else-if="status === 'done'" class="video-done">
<div class="video-wrapper">
<video :src="videoUrl" controls autoplay class="video-player">
您的浏览器不支持视频播放
</video>
</div>
<div class="video-info">
<div class="video-badge">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<span>视频生成完成</span>
</div>
<p class="video-hint">视频已准备就绪可以播放查看讲解效果</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="status === 'error'" class="empty-state error-state">
<div class="empty-icon error-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="empty-title">生成失败</p>
<p class="empty-hint">请检查网络连接或 API 配置后重试</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-container {
height: 100vh;
overflow: hidden;
background: var(--bg);
display: flex;
flex-direction: column;
font-family: "Inter", "PingFang SC", -apple-system, sans-serif;
}
/* Nav */
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--card-border);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.95rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
transition: all 0.2s;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text-primary);
}
.nav-title {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
background: linear-gradient(135deg, #f472b6, #db2777);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Main Layout */
.main-layout {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
/* Left Panel */
.left-panel {
width: 420px;
min-width: 320px;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
border-right: 1px solid var(--card-border);
overflow-y: auto;
flex-shrink: 0;
}
/* Right Panel */
.right-panel {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
/* Input Card */
.input-card {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--card-border);
border-radius: 20px;
overflow: hidden;
backdrop-filter: blur(12px);
}
.input-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--card-border);
color: var(--text-secondary);
font-size: 0.9rem;
}
.input-header svg {
color: #f472b6;
}
.text-area {
width: 100%;
flex: 1;
min-height: 260px;
background: rgba(0, 0, 0, 0.2);
border: none;
border-radius: 0;
color: var(--text-primary);
font-size: 0.95rem;
line-height: 1.7;
padding: 1.25rem;
resize: none;
outline: none;
transition: background 0.2s;
box-sizing: border-box;
font-family: inherit;
}
.text-area::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.text-area:focus {
background: rgba(0, 0, 0, 0.25);
}
.text-area:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 滚动条美化 */
.text-area::-webkit-scrollbar {
width: 6px;
}
.text-area::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.text-area::-webkit-scrollbar-thumb {
background: rgba(244, 114, 182, 0.4);
border-radius: 3px;
}
.text-area::-webkit-scrollbar-thumb:hover {
background: rgba(244, 114, 182, 0.6);
}
.char-count {
text-align: right;
font-size: 0.8rem;
color: var(--text-secondary);
padding: 0.5rem 1.25rem;
opacity: 0.6;
border-top: 1px solid var(--card-border);
}
/* Buttons */
.btn-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.generate-btn {
display: flex;
align-items: center;
gap: 0.6rem;
background: linear-gradient(135deg, #f472b6, #db2777);
border: none;
color: white;
font-size: 1rem;
font-weight: 600;
padding: 0.875rem 2.5rem;
border-radius: 50px;
cursor: pointer;
transition: all 0.25s;
box-shadow: 0 4px 20px rgba(244, 114, 182, 0.35);
}
.generate-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(244, 114, 182, 0.5);
}
.generate-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.cancel-btn {
display: flex;
align-items: center;
gap: 0.6rem;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
font-size: 0.95rem;
font-weight: 500;
padding: 0.75rem 1.75rem;
border-radius: 50px;
cursor: pointer;
transition: all 0.25s;
}
.cancel-btn:hover {
background: rgba(239, 68, 68, 0.25);
}
.reset-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-secondary);
font-size: 0.9rem;
padding: 0.875rem 1.5rem;
border-radius: 50px;
cursor: pointer;
transition: all 0.2s;
}
.reset-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
/* Empty State */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--text-secondary);
opacity: 0.5;
}
.empty-icon {
color: var(--text-secondary);
opacity: 0.4;
margin-bottom: 0.5rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.empty-hint {
font-size: 0.9rem;
margin: 0;
text-align: center;
max-width: 320px;
line-height: 1.6;
}
.error-state {
opacity: 1;
}
.error-icon {
color: #f87171;
opacity: 1;
}
.error-state .empty-title {
color: #fca5a5;
}
/* Generating State */
.generating-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.generating-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 3rem 4rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--card-border);
border-radius: 24px;
animation: fadeInUp 0.4s ease both;
}
.generating-icon {
position: relative;
}
.video-icon-wrapper {
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(
135deg,
rgba(244, 114, 182, 0.2),
rgba(219, 39, 119, 0.2)
);
border: 2px solid rgba(244, 114, 182, 0.3);
display: flex;
align-items: center;
justify-content: center;
color: #f472b6;
animation: pulse-ring 2s ease-in-out infinite;
}
.video-icon-wrapper svg {
animation: pulse 1.5s ease-in-out infinite;
}
.generating-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.generating-model {
font-size: 0.9rem;
color: #f472b6;
margin: -0.75rem 0 0 0;
opacity: 0.8;
}
/* Progress Bar */
.progress-container {
width: 100%;
min-width: 280px;
}
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #f472b6, #db2777);
border-radius: 4px;
transition: width 0.5s ease;
position: relative;
}
.progress-fill::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 1.5s infinite;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.progress-percent {
color: #f472b6;
font-weight: 600;
}
.progress-text {
color: var(--text-secondary);
}
/* Video Done State */
.video-done {
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: fadeInUp 0.4s ease both;
}
.video-wrapper {
background: rgba(0, 0, 0, 0.3);
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--card-border);
}
.video-player {
width: 100%;
display: block;
max-height: 480px;
}
.video-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.video-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(20, 184, 166, 0.12);
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: 50px;
color: #14b8a6;
font-size: 0.9rem;
font-weight: 500;
width: fit-content;
}
.video-hint {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0;
}
/* Error Banner */
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.75rem 1.5rem;
color: #fca5a5;
font-size: 0.9rem;
animation: fadeInUp 0.2s ease;
flex-shrink: 0;
}
.error-close {
background: none;
border: none;
color: #fca5a5;
cursor: pointer;
font-size: 1rem;
opacity: 0.7;
transition: opacity 0.2s;
}
.error-close:hover {
opacity: 1;
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(0.95);
}
}
@keyframes pulse-ring {
0%,
100% {
box-shadow: 0 0 0 0 rgba(244, 114, 182, 0.4);
}
50% {
box-shadow: 0 0 0 20px rgba(244, 114, 182, 0);
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Responsive */
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
height: auto;
overflow: visible;
}
.left-panel {
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid var(--card-border);
overflow-y: visible;
}
.right-panel {
padding: 1.25rem 1rem;
min-height: 400px;
}
.generating-card {
padding: 2rem 1.5rem;
}
}
</style>

View File

@ -11,6 +11,7 @@ export default defineConfig({
}
},
server: {
host: '0.0.0.0',
proxy: {
'/tts-api': {
target: 'https://openspeech.bytedance.com',