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 EssayCorrection from '../views/EssayCorrection.vue'
|
||||||
import ExamAnalysis from '../views/ExamAnalysis.vue'
|
import ExamAnalysis from '../views/ExamAnalysis.vue'
|
||||||
import SpellPractice from '../views/SpellPractice.vue'
|
import SpellPractice from '../views/SpellPractice.vue'
|
||||||
|
import VideoExplanation from '../views/VideoExplanation.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -38,6 +39,11 @@ const router = createRouter({
|
||||||
path: '/spell-practice',
|
path: '/spell-practice',
|
||||||
name: 'spell-practice',
|
name: 'spell-practice',
|
||||||
component: SpellPractice
|
component: SpellPractice
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/video-explanation',
|
||||||
|
name: 'video-explanation',
|
||||||
|
component: VideoExplanation
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,14 @@ const features = ref([
|
||||||
icon: "speaker",
|
icon: "speaker",
|
||||||
route: "/pronunciation",
|
route: "/pronunciation",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: "试题讲解视频",
|
||||||
|
desc: "输入英语试题,一键调用 OpenAI Sora 生成专业讲解视频,AI 智能分析考点并生成可视化讲解内容。",
|
||||||
|
class: "card-7",
|
||||||
|
icon: "video",
|
||||||
|
route: "/video-explanation",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Hover effect for glassmorphism glare
|
// Hover effect for glassmorphism glare
|
||||||
|
|
@ -187,6 +195,22 @@ onUnmounted(() => {
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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) -->
|
<!-- Speaker Icon (Megaphone/Speaker) -->
|
||||||
<svg
|
<svg
|
||||||
v-else-if="feature.icon === 'speaker'"
|
v-else-if="feature.icon === 'speaker'"
|
||||||
|
|
@ -374,6 +398,11 @@ h1 {
|
||||||
box-shadow: 0 8px 20px -6px rgba(239, 68, 68, 0.5);
|
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 {
|
.card-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,15 @@ let msgIdCounter = 0;
|
||||||
let currentAudioInstance = null;
|
let currentAudioInstance = null;
|
||||||
const blobUrls = [];
|
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));
|
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-App-Id": DOUBAO_APP_ID,
|
||||||
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
|
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
|
||||||
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
|
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
|
||||||
"X-Api-Request-Id": crypto.randomUUID(),
|
"X-Api-Request-Id": generateUUID(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
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: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/tts-api': {
|
'/tts-api': {
|
||||||
target: 'https://openspeech.bytedance.com',
|
target: 'https://openspeech.bytedance.com',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue