feat(router): 添加试题讲解视频功能
This commit is contained in:
parent
91620af9b9
commit
6fb13bd12f
|
|
@ -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 动画
|
||||
- 进度展示:动态进度条 + 百分比
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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="请输入或粘贴英语试题内容,例如: Choose the correct answer: The teacher asked the students _____ they had finished their homework. 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>
|
||||
|
|
@ -11,6 +11,7 @@ export default defineConfig({
|
|||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/tts-api': {
|
||||
target: 'https://openspeech.bytedance.com',
|
||||
|
|
|
|||
Loading…
Reference in New Issue