feat: 实现用户认证系统并优化音频播放器功能

- 新增NextAuth认证系统,支持Google和GitHub登录
- 添加登录模态框组件和用户头像显示
- 重构音频播放器,支持倍速控制和状态同步
- 优化播客卡片显示当前播放状态和生成状态
- 新增API调用追踪工具和防重复调用Hook
- 修复多个API重复调用问题并添加详细文档
- 改进音频文件处理流程,支持MP3格式输出
- 更新类型定义和组件Props以支持新功能
This commit is contained in:
hex2077
2025-08-16 23:03:46 +08:00
parent 719eb14927
commit b63fcb3f6d
32 changed files with 1585 additions and 989 deletions

View File

@@ -9,7 +9,18 @@
"Bash(npm run lint)",
"Bash(timeout:*)",
"Bash(npm run dev)",
"Bash(node:*)"
"Bash(node:*)",
"mcp__serena__check_onboarding_performed",
"mcp__serena__activate_project",
"mcp__serena__onboarding",
"mcp__serena__list_dir",
"mcp__serena__get_symbols_overview",
"mcp__serena__find_symbol",
"mcp__serena__think_about_collected_information",
"mcp__serena__write_memory",
"mcp__serena__think_about_whether_you_are_done",
"Bash(grep:*)",
"mcp__serena__search_for_pattern"
],
"deny": []
}

View File

@@ -1,15 +0,0 @@
# OpenAI API配置
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-3.5-turbo
# Next.js配置
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Python脚本路径相对于web2目录
PYTHON_SCRIPT_PATH=../podcast_generator.py
OUTPUT_DIR=../output
INPUT_FILE=../input.txt
# 开发模式配置
NODE_ENV=development

View File

@@ -0,0 +1,189 @@
# API重复调用问题修复报告
## 问题描述
用户报告访问主页时主页内的接口被调用了两次。这是一个常见的React问题会导致
- 不必要的网络请求
- 服务器负载增加
- 用户体验下降
- 可能的数据不一致
## 问题分析
通过代码分析,发现了以下导致重复调用的原因:
### 1. **多个useEffect调用同一API**
`src/app/page.tsx` 中:
- 第38行的useEffect在组件挂载时调用 `fetchRecentPodcasts()`
- 第86行的useEffect设置定时器每20秒调用 `fetchRecentPodcasts()`
- 这导致页面加载时API被调用两次
### 2. **useEffect依赖项问题**
`src/components/PodcastCreator.tsx` 中:
- useEffect依赖项包含 `selectedConfig``selectedConfigName`
- 当配置变化时可能触发多次API调用
### 3. **ConfigSelector组件的重复调用**
`src/components/ConfigSelector.tsx` 中:
- localStorage变化监听可能导致重复的配置加载
## 修复方案
### 1. **合并useEffect调用**
将两个分离的useEffect合并为一个
```typescript
// 修复前两个独立的useEffect
useEffect(() => {
setCredits(100000);
fetchRecentPodcasts(); // 第一次调用
}, []);
useEffect(() => {
const interval = setInterval(() => {
fetchRecentPodcasts(); // 定时调用
}, 20000);
return () => clearInterval(interval);
}, []);
// 修复后合并为一个useEffect
useEffect(() => {
setCredits(100000);
fetchRecentPodcasts(); // 初始调用
// 设置定时器
const interval = setInterval(() => {
fetchRecentPodcasts();
}, 20000);
return () => clearInterval(interval);
}, []); // 空依赖数组,只在组件挂载时执行一次
```
### 2. **创建防重复调用Hook**
创建了 `src/hooks/useApiCall.ts`
```typescript
export function usePreventDuplicateCall() {
const isCallingRef = useRef<boolean>(false);
const executeOnce = useCallback(async <T>(
apiFunction: () => Promise<T>
): Promise<T | null> => {
if (isCallingRef.current) {
console.log('API call already in progress, skipping...');
return null;
}
try {
isCallingRef.current = true;
const result = await apiFunction();
return result;
} catch (error) {
console.error('API call failed:', error);
return null;
} finally {
isCallingRef.current = false;
}
}, []);
return { executeOnce };
}
```
### 3. **优化useEffect依赖项**
在PodcastCreator组件中
```typescript
// 修复前:多个依赖项可能导致重复调用
useEffect(() => {
fetchVoices();
}, [selectedConfig, selectedConfigName]);
// 修复后:只依赖必要的状态
useEffect(() => {
if (!selectedConfigName) {
setVoices([]);
return;
}
fetchVoices();
}, [selectedConfigName]); // 只依赖配置名称
```
### 4. **添加API调用追踪器**
创建了 `src/utils/apiCallTracker.ts` 用于开发环境下监控API调用
```typescript
// 自动检测重复调用
trackCall(url: string, method: string = 'GET'): string {
const recentCalls = this.calls.filter(
c => c.url === url &&
c.method === method &&
Date.now() - c.timestamp < 5000
);
if (recentCalls.length > 0) {
console.warn(`🚨 检测到重复API调用:`, {
url, method, 重复次数: recentCalls.length + 1
});
}
}
```
## 修复效果
### 修复前:
- 页面加载时 `/api/podcast-status` 被调用2次
- 配置变化时 `/api/tts-voices` 可能被多次调用
- 无法监控和调试重复调用问题
### 修复后:
- 页面加载时 `/api/podcast-status` 只调用1次
- 使用防重复调用机制确保同一时间只有一个请求
- 开发环境下自动检测和警告重复调用
- 优化了useEffect依赖项减少不必要的重新执行
## 验证方法
### 1. **开发环境调试**
打开浏览器开发者工具,在控制台中可以使用:
```javascript
// 查看API调用统计
window.apiDebug.showStats();
// 清空统计数据
window.apiDebug.clearStats();
```
### 2. **网络面板监控**
在浏览器开发者工具的Network面板中
- 刷新页面,观察 `/api/podcast-status` 只被调用一次
- 切换TTS配置观察 `/api/tts-voices` 不会重复调用
### 3. **控制台日志**
开发环境下会自动输出API调用日志
- `📡 API调用:` - 正常调用
- `🚨 检测到重复API调用:` - 重复调用警告
## 最佳实践建议
1. **useEffect合并原则**相关的副作用应该在同一个useEffect中处理
2. **依赖项最小化**:只包含真正需要的依赖项
3. **防重复调用**对于可能重复的API调用使用防重复机制
4. **开发调试工具**:在开发环境中添加监控和调试工具
5. **错误处理**确保API调用失败时不会影响后续调用
## 相关文件
- `src/app/page.tsx` - 主页组件修复
- `src/components/PodcastCreator.tsx` - 播客创建器组件修复
- `src/components/ConfigSelector.tsx` - 配置选择器组件修复
- `src/hooks/useApiCall.ts` - 防重复调用Hook新增
- `src/utils/apiCallTracker.ts` - API调用追踪器新增
## 注意事项
- 修复后的代码保持了原有功能不变
- 所有修改都向后兼容
- 调试工具只在开发环境中启用,不会影响生产环境性能
- 建议在部署前进行充分测试,确保所有功能正常工作

View File

@@ -2,32 +2,24 @@ import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs';
export async function GET(
request: NextRequest,
{ params }: { params: { filename: string } }
) {
export async function GET(request: NextRequest) {
const filename = request.nextUrl.searchParams.get('filename');
// 验证文件名安全性
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return NextResponse.json(
{ error: '无效的文件名' },
{ status: 400 }
);
}
// 构建文件路径
const outputDir = path.join(process.cwd(), '..', 'output');
const filePath = path.join(outputDir, filename);
try {
const filename = params.filename;
// 验证文件名安全性
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return NextResponse.json(
{ error: '无效的文件名' },
{ status: 400 }
);
}
// 构建文件路径
const outputDir = path.join(process.cwd(), '..', 'output');
const filePath = path.join(outputDir, filename);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
return NextResponse.json(
{ error: '文件不存在' },
{ status: 404 }
);
}
// 获取文件统计信息,检查文件是否存在
const stats = fs.statSync(filePath);
// 检查文件类型
const ext = path.extname(filename).toLowerCase();
@@ -40,9 +32,6 @@ export async function GET(
);
}
// 读取文件
const fileBuffer = fs.readFileSync(filePath);
// 设置适当的Content-Type
let contentType = 'audio/wav';
switch (ext) {
@@ -59,12 +48,10 @@ export async function GET(
contentType = 'audio/wav';
}
// 获取文件统计信息
const stats = fs.statSync(filePath);
// 处理Range请求支持音频流播放
const range = request.headers.get('range');
// 检查Range请求头是否存在
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
@@ -83,20 +70,26 @@ export async function GET(
'Cache-Control': 'public, max-age=31536000',
},
});
} else {
// 如果没有Range请求则读取整个文件并返回
const fileBuffer = fs.readFileSync(filePath);
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Content-Length': stats.size.toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=31536000',
'Content-Disposition': `inline; filename="${filename}"`,
},
});
}
} catch (error: any) { // 明确指定 error 类型
if (error.code === 'ENOENT') {
return NextResponse.json(
{ error: '文件不存在' },
{ status: 404 }
);
}
// 返回完整文件
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Content-Length': stats.size.toString(),
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=31536000',
'Content-Disposition': `inline; filename="${filename}"`,
},
});
} catch (error) {
console.error('Error serving audio file:', error);
return NextResponse.json(
{ error: '服务器内部错误' },

View File

@@ -0,0 +1,19 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
secret: process.env.NEXTAUTH_SECRET,
});
export { handler as GET, handler as POST };

View File

@@ -3,6 +3,15 @@ import path from 'path';
import fs from 'fs/promises';
import type { TTSConfig } from '@/types';
const TTS_PROVIDER_ORDER = [
'edge-tts',
'doubao-tts',
'minimax',
'fish-audio',
'gemini-tts',
'index-tts',
];
// 获取配置文件列表
export async function GET() {
try {
@@ -17,6 +26,21 @@ export async function GET() {
path: file,
}));
// 根据预定义顺序排序
configFiles.sort((a, b) => {
const aName = a.name.replace('.json', '');
const bName = b.name.replace('.json', '');
const aIndex = TTS_PROVIDER_ORDER.indexOf(aName);
const bIndex = TTS_PROVIDER_ORDER.indexOf(bName);
// 未知提供商排在已知提供商之后,并保持其相对顺序
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
return NextResponse.json({
success: true,
data: configFiles,

View File

@@ -1,235 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs/promises';
import { generateId } from '@/lib/utils';
import type { PodcastGenerationRequest, PodcastGenerationResponse } from '@/types';
// 存储生成任务的状态
const generationTasks = new Map<string, PodcastGenerationResponse>();
import { startPodcastGenerationTask } from '@/lib/podcastApi';
import type { PodcastGenerationRequest } from '@/types';
export async function POST(request: NextRequest) {
try {
const body: PodcastGenerationRequest = await request.json();
// 验证请求数据
if (!body.topic || !body.topic.trim()) {
const result = await startPodcastGenerationTask(body);
if (result.success) {
return NextResponse.json({
success: true,
data: result.data,
});
} else {
return NextResponse.json(
{ success: false, error: '请提供播客主题' },
{ status: 400 }
{ success: false, error: result.error },
{ status: result.statusCode || 400 } // Use 400 for client-side errors, or 500 for internal server errors
);
}
// 生成任务ID
const taskId = generateId();
// 初始化任务状态
const task: PodcastGenerationResponse = {
id: taskId,
status: 'pending',
progress: 0,
createdAt: new Date().toISOString(),
estimatedTime: getEstimatedTime(body.duration),
};
generationTasks.set(taskId, task);
// 异步启动Python脚本
startPodcastGeneration(taskId, body);
return NextResponse.json({
success: true,
data: task,
});
} catch (error) {
} catch (error: any) {
console.error('Error in generate-podcast API:', error);
const statusCode = error.statusCode || 500; // 假设 HttpError 会有 statusCode 属性
return NextResponse.json(
{ success: false, error: '服务器内部错误' },
{ status: 500 }
{ success: false, error: error.message || '服务器内部错误' },
{ status: statusCode }
);
}
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get('id');
if (!taskId) {
return NextResponse.json(
{ success: false, error: '缺少任务ID' },
{ status: 400 }
);
}
const task = generationTasks.get(taskId);
if (!task) {
return NextResponse.json(
{ success: false, error: '任务不存在' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: task,
});
}
async function startPodcastGeneration(taskId: string, request: PodcastGenerationRequest) {
try {
// 更新任务状态
updateTaskStatus(taskId, 'generating_outline', 10);
// 准备输入文件
const inputContent = prepareInputContent(request);
const inputFilePath = path.join(process.cwd(), '..', 'input.txt');
await fs.writeFile(inputFilePath, inputContent, 'utf-8');
// 如果有TTS配置写入配置文件
let configPath = '';
if (request.ttsConfig) {
// 查找匹配的配置文件
const configDir = path.join(process.cwd(), '..', 'config');
const configFiles = await fs.readdir(configDir);
const matchingConfig = configFiles.find(file =>
file.endsWith('.json') &&
!file.includes('tts_providers') &&
file.includes(request.ttsConfig?.tts_provider || '')
);
if (matchingConfig) {
configPath = path.join(configDir, matchingConfig);
}
}
// 构建Python命令参数
const pythonScriptPath = path.join(process.cwd(), '..', 'podcast_generator.py');
const args = [
pythonScriptPath,
'--threads', '2', // 使用2个线程加速生成
];
// 如果有环境变量中的API配置添加到参数中
if (process.env.OPENAI_API_KEY) {
args.push('--api-key', process.env.OPENAI_API_KEY);
}
if (process.env.OPENAI_BASE_URL) {
args.push('--base-url', process.env.OPENAI_BASE_URL);
}
if (process.env.OPENAI_MODEL) {
args.push('--model', process.env.OPENAI_MODEL);
}
// 启动Python进程
const pythonProcess = spawn('python', args, {
cwd: path.join(process.cwd(), '..'),
stdio: ['pipe', 'pipe', 'pipe'],
});
let outputBuffer = '';
let errorBuffer = '';
pythonProcess.stdout.on('data', (data) => {
outputBuffer += data.toString();
// 解析输出来更新进度
parseProgressFromOutput(taskId, outputBuffer);
});
pythonProcess.stderr.on('data', (data) => {
errorBuffer += data.toString();
console.error('Python stderr:', data.toString());
});
pythonProcess.on('close', async (code) => {
if (code === 0) {
// 生成成功,查找输出文件
try {
const outputDir = path.join(process.cwd(), '..', 'output');
const files = await fs.readdir(outputDir);
const audioFile = files.find(file => file.endsWith('.wav'));
if (audioFile) {
const audioUrl = `/api/audio/${audioFile}`;
updateTaskStatus(taskId, 'completed', 100, undefined, audioUrl);
} else {
updateTaskStatus(taskId, 'error', 100, '未找到生成的音频文件');
}
} catch (error) {
updateTaskStatus(taskId, 'error', 100, '处理输出文件时出错');
}
} else {
updateTaskStatus(taskId, 'error', 100, `生成失败: ${errorBuffer}`);
}
});
pythonProcess.on('error', (error) => {
console.error('Python process error:', error);
updateTaskStatus(taskId, 'error', 100, `进程启动失败: ${error.message}`);
});
} catch (error) {
console.error('Error starting podcast generation:', error);
updateTaskStatus(taskId, 'error', 100, `启动生成任务失败: ${error}`);
}
}
function updateTaskStatus(
taskId: string,
status: PodcastGenerationResponse['status'],
progress: number,
error?: string,
audioUrl?: string
) {
const task = generationTasks.get(taskId);
if (task) {
task.status = status;
task.progress = progress;
if (error) task.error = error;
if (audioUrl) task.audioUrl = audioUrl;
generationTasks.set(taskId, task);
}
}
function parseProgressFromOutput(taskId: string, output: string) {
// 根据Python脚本的输出解析进度
// 这里需要根据实际的Python脚本输出格式来调整
if (output.includes('生成播客大纲')) {
updateTaskStatus(taskId, 'generating_outline', 20);
} else if (output.includes('生成播客脚本')) {
updateTaskStatus(taskId, 'generating_script', 40);
} else if (output.includes('生成音频')) {
updateTaskStatus(taskId, 'generating_audio', 60);
} else if (output.includes('合并音频')) {
updateTaskStatus(taskId, 'merging', 80);
}
}
function prepareInputContent(request: PodcastGenerationRequest): string {
let content = request.topic;
if (request.customInstructions) {
content += '\n\n```custom-begin\n' + request.customInstructions + '\n```custom-end';
}
// 添加其他配置信息
content += '\n\n```config\n';
content += `语言: ${request.language || 'zh-CN'}\n`;
content += `风格: ${request.style || 'casual'}\n`;
content += `时长: ${request.duration || 'medium'}\n`;
content += `说话人数量: ${request.speakers || 2}\n`;
content += '```';
return content;
}
function getEstimatedTime(duration?: string): number {
// 根据时长估算生成时间(秒)
switch (duration) {
case 'short': return 120; // 2分钟
case 'medium': return 300; // 5分钟
case 'long': return 600; // 10分钟
default: return 300;
}
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPodcastStatus } from '@/lib/podcastApi';
export const revalidate = 0; // 等同于 `cache: 'no-store'`
export async function GET(request: NextRequest) {
const result = await getPodcastStatus();
if (result.success) {
return NextResponse.json({
success: true,
...result.data, // 展开 result.data因为它已经是 PodcastStatusResponse 类型
});
} else {
return NextResponse.json(
{ success: false, error: result.error || '获取任务状态失败' },
{ status: result.statusCode || 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
// 获取 tts_providers.json 文件内容
export async function GET() {
try {
const configPath = path.join(process.cwd(), '..', 'config', 'tts_providers.json');
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent);
return NextResponse.json({
success: true,
data: config,
});
} catch (error) {
console.error('Error reading tts_providers.json:', error);
return NextResponse.json(
{ success: false, error: '无法读取TTS提供商配置文件' },
{ status: 500 }
);
}
}

View File

@@ -1,45 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import { promises as fs } from 'fs';
export async function POST(request: NextRequest) {
try {
const { ttsConfigName } = await request.json();
if (!ttsConfigName) {
return NextResponse.json(
{ success: false, error: '缺少 ttsConfigName 参数' },
{ status: 400 }
);
}
const configPath = path.join(process.cwd(), '..', 'config', ttsConfigName);
const configContent = await fs.readFile(configPath, 'utf-8');
const ttsConfig = JSON.parse(configContent);
// 假设 ttsConfig 结构中有一个 `voices` 字段
// 如果没有,可能需要根据 ttsConfig 的 provider 调用不同的逻辑来获取声音列表
if (ttsConfig && ttsConfig.voices) {
// 模拟添加 sample_audio_url
const voicesWithSampleAudio = ttsConfig.voices.map((voice: any) => ({
...voice,
sample_audio_url: `${voice.audio}`, // 假设有一个示例音频路径
}));
return NextResponse.json({
success: true,
data: voicesWithSampleAudio,
});
} else {
return NextResponse.json(
{ success: false, error: '未找到声音配置' },
{ status: 404 }
);
}
} catch (error) {
console.error('Error fetching TTS voices:', error);
return NextResponse.json(
{ success: false, error: '无法获取TTS声音列表' },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import AuthProviders from '@/components/AuthProviders';
const inter = Inter({
subsets: ['latin'],
@@ -39,13 +40,15 @@ export default function RootLayout({
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className={`${inter.className} antialiased`}>
<div id="root" className="min-h-screen bg-white">
{children}
</div>
{/* Toast容器 */}
<div id="toast-root" />
{/* Modal容器 */}
<div id="modal-root" />
<AuthProviders>
<div id="root" className="min-h-screen bg-white">
{children}
</div>
{/* Toast容器 */}
<div id="toast-root" />
{/* Modal容器 */}
<div id="modal-root" />
</AuthProviders>
</body>
</html>
);

View File

@@ -5,95 +5,107 @@ import Sidebar from '@/components/Sidebar';
import PodcastCreator from '@/components/PodcastCreator';
import ContentSection from '@/components/ContentSection';
import AudioPlayer from '@/components/AudioPlayer';
import ProgressModal from '@/components/ProgressModal';
import SettingsForm from '@/components/SettingsForm';
import { ToastContainer, useToast } from '@/components/Toast';
import type { PodcastGenerationRequest, PodcastItem, UIState, PodcastGenerationResponse } from '@/types';
import { usePreventDuplicateCall } from '@/hooks/useApiCall';
import { trackedFetch } from '@/utils/apiCallTracker';
import type { PodcastGenerationRequest, PodcastItem, UIState, PodcastGenerationResponse, SettingsFormData } from '@/types';
import { getTTSProviders } from '@/lib/config';
import { useSession, signOut } from 'next-auth/react'; // 导入 useSession 和 signOut
import LoginModal from '@/components/LoginModal'; // 导入 LoginModal
// 模拟数据
const mockPodcasts: PodcastItem[] = [
{
id: '1',
title: 'AI技术的未来发展趋势',
description: '探讨人工智能在各个领域的应用前景',
thumbnail: '',
author: {
name: 'AI研究员',
avatar: '',
},
duration: 1200, // 20分钟
playCount: 15420,
createdAt: '2024-01-15T10:00:00Z',
audioUrl: '/api/audio/sample1.mp3',
tags: ['AI', '技术', '未来'],
},
{
id: '2',
title: '创业路上的那些坑',
description: '分享创业过程中的经验教训',
thumbnail: '',
author: {
name: '创业导师',
avatar: '',
},
duration: 900, // 15分钟
playCount: 8750,
createdAt: '2024-01-14T15:30:00Z',
audioUrl: '/api/audio/sample2.mp3',
tags: ['创业', '经验', '商业'],
},
{
id: '3',
title: '健康生活方式指南',
description: '如何在忙碌的生活中保持健康',
thumbnail: '',
author: {
name: '健康专家',
avatar: '',
},
duration: 1800, // 30分钟
playCount: 12300,
createdAt: '2024-01-13T09:15:00Z',
audioUrl: '/api/audio/sample3.mp3',
tags: ['健康', '生活', '养生'],
},
];
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
export default function HomePage() {
const { toasts, success, error, warning, info, removeToast } = useToast();
const { executeOnce } = usePreventDuplicateCall();
const [uiState, setUIState] = useState<UIState>({
sidebarCollapsed: false,
sidebarCollapsed: true,
currentView: 'home',
theme: 'light',
});
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); // 控制登录模态框的显示
const [isGenerating, setIsGenerating] = useState(false);
const [libraryPodcasts, setLibraryPodcasts] = useState<PodcastItem[]>(mockPodcasts);
const [explorePodcasts, setExplorePodcasts] = useState<PodcastItem[]>(mockPodcasts);
const [credits, setCredits] = useState(0); // 新增积分状态
const [libraryPodcasts, setLibraryPodcasts] = useState<PodcastItem[]>([]);
const [explorePodcasts, setExplorePodcasts] = useState<PodcastItem[]>([]);
const [credits, setCredits] = useState(0); // 积分状态
const [settings, setSettings] = useState<SettingsFormData | null>(null); // 加载设置的状态
// 音频播放器状态
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// 进度模态框状态
const [progressModal, setProgressModal] = useState<{
isOpen: boolean;
taskId: string | null;
}>({
isOpen: false,
taskId: null,
});
// 模拟从后端获取积分数据
// 模拟从后端获取积分数据和初始化数据加载
useEffect(() => {
// 实际应用中这里会发起API请求获取用户积分
// 例如fetch('/api/user/credits').then(res => res.json()).then(data => setCredits(data.credits));
setCredits(100000); // 模拟初始积分100
// 首次加载时获取播客列表
fetchRecentPodcasts();
// 设置定时器每20秒刷新一次
const interval = setInterval(() => {
fetchRecentPodcasts();
}, 20000);
// 清理定时器
return () => clearInterval(interval);
}, []); // 空依赖数组,只在组件挂载时执行一次
// 加载设置
useEffect(() => {
const loadSettings = async () => {
const savedSettings = await getTTSProviders();
if (savedSettings) {
// 确保从 localStorage 加载的设置中,所有预期的字符串字段都为字符串
const normalizedSettings: SettingsFormData = {
apikey: savedSettings.apikey || '',
model: savedSettings.model || '',
baseurl: savedSettings.baseurl || '',
index: {
api_url: savedSettings.index?.api_url || '',
},
edge: {
api_url: savedSettings.edge?.api_url || '',
},
doubao: {
'X-Api-App-Id': savedSettings.doubao?.['X-Api-App-Id'] || '',
'X-Api-Access-Key': savedSettings.doubao?.['X-Api-Access-Key'] || '',
},
fish: {
api_key: savedSettings.fish?.api_key || '',
},
minimax: {
group_id: savedSettings.minimax?.group_id || '',
api_key: savedSettings.minimax?.api_key || '',
},
gemini: {
api_key: savedSettings.gemini?.api_key || '',
},
};
setSettings(normalizedSettings);
}
};
loadSettings(); // 页面加载时加载一次
if(enableTTSConfigPage){
window.addEventListener('settingsUpdated', loadSettings as EventListener); // 监听设置更新事件
}
// 清理事件监听器
return () => {
if(enableTTSConfigPage){
window.removeEventListener('settingsUpdated', loadSettings as EventListener);
}
};
}, []);
const handleViewChange = (view: string) => {
setUIState(prev => ({ ...prev, currentView: view as UIState['currentView'] }));
};
@@ -110,8 +122,15 @@ export default function HomePage() {
setIsGenerating(true);
try {
info('开始生成播客', '正在处理您的请求...');
// info('开始生成播客', '正在处理您的请求...');
if (!settings || !settings.apikey || !settings.model) {
error('配置错误', 'API Key 或模型未设置,请前往设置页填写。');
setIsGenerating(false);
return;
}
// 直接发送JSON格式的请求体
const response = await fetch('/api/generate-podcast', {
method: 'POST',
headers: {
@@ -121,20 +140,26 @@ export default function HomePage() {
});
if (!response.ok) {
throw new Error('Failed to generate podcast');
if(response.status === 401) {
throw new Error('生成播客失败请检查API Key是否正确');
}
if(response.status === 409) {
throw new Error(`生成播客失败,有正在进行中的任务 (状态码: ${response.status})`);
}
throw new Error(`生成播客失败,请检查后端服务或配置 (状态码: ${response.status})`);
}
const result = await response.json();
const apiResponse: { success: boolean; data?: PodcastGenerationResponse; error?: string } = await response.json();
if (result.success) {
success('任务已创建', '播客生成任务已启动,请查看进度');
// 显示进度模态框
setProgressModal({
isOpen: true,
taskId: result.data.id,
});
if (!apiResponse.success) {
throw new Error(apiResponse.error || '生成播客失败');
}
if (apiResponse.data && apiResponse.data.id) {
success('任务已创建', `播客生成任务已启动任务ID: ${apiResponse.data.id}`);
await fetchRecentPodcasts(); // 刷新最近生成列表
} else {
throw new Error(result.error || 'Generation failed');
throw new Error('生成任务失败未返回任务ID');
}
} catch (err) {
@@ -146,37 +171,78 @@ export default function HomePage() {
};
const handlePlayPodcast = (podcast: PodcastItem) => {
setCurrentPodcast(podcast);
if (currentPodcast?.id === podcast.id) {
setIsPlaying(prev => !prev);
} else {
setCurrentPodcast(podcast);
// 强制设置为 true确保在切换播客时立即播放
setIsPlaying(true);
}
};
const handleProgressComplete = (result: PodcastGenerationResponse) => {
// 生成完成后,可以将新的播客添加到库中
if (result.audioUrl) {
success('播客生成完成!', '您的播客已成功生成并添加到资料库');
const newPodcast: PodcastItem = {
id: result.id,
title: result.script?.title || '新生成的播客',
description: '使用AI生成的播客内容',
thumbnail: '',
author: {
name: '我',
avatar: '',
const handleTogglePlayPause = () => {
setIsPlaying(prev => !prev);
};
// 获取最近播客列表 - 使用防重复调用机制
const fetchRecentPodcasts = async () => {
const result = await executeOnce(async () => {
const response = await trackedFetch('/api/podcast-status', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
duration: result.script?.totalDuration || 0,
playCount: 0,
createdAt: result.createdAt,
audioUrl: result.audioUrl,
tags: ['AI生成'],
};
setLibraryPodcasts(prev => [newPodcast, ...prev]);
// 自动播放新生成的播客
setCurrentPodcast(newPodcast);
} else {
warning('生成完成但无音频', '播客生成过程完成,但未找到音频文件');
});
if (!response.ok) {
throw new Error('Failed to fetch podcast status');
}
return response.json();
});
if (!result) {
return; // 如果是重复调用,直接返回
}
try {
const apiResponse: { success: boolean; tasks?: { message: string; tasks: PodcastGenerationResponse[]; }; error?: string } = result;
if (apiResponse.success && apiResponse.tasks && Array.isArray(apiResponse.tasks)) { // 检查 tasks 属性是否存在且为数组
const newPodcasts: PodcastItem[] = apiResponse.tasks.map((task: any) => ({ // 遍历 tasks 属性
id: task.task_id, // 使用 task_id
title: task.title ? task.title : task.status === 'failed' ? '播客生成失败,请重试' : ' ',
description: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).join(', ') : task.status === 'failed' ? task.error || '待生成的播客标签' : '待生成的播客标签',
thumbnail: task.avatar_base64 ? `data:image/png;base64,${task.avatar_base64}` : '',
author: {
name: '',
avatar: '',
},
duration: parseDurationToSeconds(task.audio_duration || '00:00'),
playCount: 0,
createdAt: task.timestamp ? new Date(task.timestamp * 1000).toISOString() : new Date().toISOString(),
audioUrl: task.audioUrl ? task.audioUrl : '',
tags: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()) : task.status === 'failed' ? [task.error] : ['待生成的播客标签'],
status: task.status,
}));
// 直接倒序,确保最新生成的播客排在前面
const reversedPodcasts = newPodcasts.reverse();
setExplorePodcasts(reversedPodcasts);
// 如果有最新生成的播客,自动播放
}
} catch (err) {
console.error('Error processing podcast data:', err);
error('数据处理失败', err instanceof Error ? err.message : '无法处理播客列表数据');
}
};
// 辅助函数:解析时长字符串为秒数
const parseDurationToSeconds = (durationStr: string): number => {
const parts = durationStr.split(':');
if (parts.length === 2) {
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
} else if (parts.length === 3) { // 支持 HH:MM:SS 格式
return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
}
return 0;
};
const renderMainContent = () => {
@@ -188,24 +254,30 @@ export default function HomePage() {
<PodcastCreator
onGenerate={handlePodcastGeneration}
isGenerating={isGenerating}
credits={credits} // 将积分传递给PodcastCreator
credits={credits}
settings={settings} // 传递 settings
/>
{/* 最近生成 - 紧凑布局 */}
<ContentSection
title="最近生成"
subtitle="数据只保留30分钟请尽快下载保存"
items={explorePodcasts}
onPlayPodcast={handlePlayPodcast}
variant="compact"
layout="grid"
/>
{explorePodcasts.length > 0 && (
<ContentSection
title="最近生成"
subtitle="数据只保留30分钟请尽快下载保存"
items={explorePodcasts}
onPlayPodcast={handlePlayPodcast}
currentPodcast={currentPodcast}
isPlaying={isPlaying}
variant="compact"
layout="grid"
showRefreshButton={true}
onRefresh={fetchRecentPodcasts}
/>
)}
{/* 推荐播客 - 水平滚动 */}
{/* <ContentSection
title="为你推荐"
items={[...libraryPodcasts, ...explorePodcasts].slice(0, 6)}
items={[...explorePodcasts].slice(0, 6)}
onPlayPodcast={handlePlayPodcast}
variant="default"
layout="horizontal"
@@ -242,7 +314,7 @@ export default function HomePage() {
mobileOpen={mobileSidebarOpen} // 传递移动端侧边栏状态
credits={credits} // 将积分传递给Sidebar
/>
{/* 移动端菜单按钮 */}
<button
className="fixed top-4 left-4 z-30 p-2 bg-white border border-neutral-200 rounded-lg shadow-md md:hidden"
@@ -259,7 +331,7 @@ export default function HomePage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 移动端侧边栏遮罩 */}
{mobileSidebarOpen && (
<div
@@ -267,12 +339,12 @@ export default function HomePage() {
onClick={handleToggleMobileSidebar}
></div>
)}
{/* 主内容区域 */}
<main className={`flex-1 transition-all duration-300 ${
uiState.sidebarCollapsed ? 'ml-16' : 'ml-64'
} max-md:ml-0`}>
<div className="py-8 px-4 sm:px-6">
<div className="pb-8 pt-8 sm:pt-32 px-4 sm:px-6">
{renderMainContent()}
</div>
</main>
@@ -281,15 +353,16 @@ export default function HomePage() {
{currentPodcast && (
<AudioPlayer
podcast={currentPodcast}
isPlaying={isPlaying}
onPlayPause={handleTogglePlayPause}
onEnded={() => setIsPlaying(false)}
/>
)}
{/* 进度模态框 */}
<ProgressModal
taskId={progressModal.taskId || ''}
isOpen={progressModal.isOpen}
onClose={() => setProgressModal({ isOpen: false, taskId: null })}
onComplete={handleProgressComplete}
{/* 登录模态框 */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
/>
{/* Toast通知容器 */}

View File

@@ -20,25 +20,32 @@ import type { AudioPlayerState, PodcastItem } from '@/types';
interface AudioPlayerProps {
podcast: PodcastItem;
isPlaying: boolean;
onPlayPause: () => void;
onEnded: () => void;
className?: string;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({
podcast,
className
isPlaying,
onPlayPause,
onEnded,
className,
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const [playerState, setPlayerState] = useState<AudioPlayerState>({
isPlaying: false,
const [playerState, setPlayerState] = useState<Omit<AudioPlayerState, 'isPlaying'>>({
currentTime: 0,
duration: 0,
volume: 1,
playbackRate: 1,
playbackRate: 1, // 将 playbackRate 设回 playerState
});
const [currentPlaybackRate, setCurrentPlaybackRate] = useState<number>(1); // 保持独立倍速状态用于UI显示
const playbackRates = [0.5, 1, 1.25, 1.5, 2.0]; // 定义可选倍速
const [isCollapsed, setIsCollapsed] = useState(false); // 用户控制的折叠状态
const [isCollapsed, setIsCollapsed] = useState(true); // 用户控制的折叠状态
const isSmallScreen = useIsSmallScreen(); // 获取小屏幕状态
// 定义一个“生效的”折叠状态,它会服从 isSmallScreen 的约束
@@ -46,6 +53,8 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
const [isLoading, setIsLoading] = useState(true);
const [isMuted, setIsMuted] = useState(false);
const [canPlay, setCanPlay] = useState(false); // 新增状态,表示音频是否可以播放
useEffect(() => {
const audio = audioRef.current;
@@ -61,11 +70,12 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
};
const handleEnded = () => {
setPlayerState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }));
onEnded();
setPlayerState(prev => ({ ...prev, currentTime: 0 }));
};
const handleLoadStart = () => setIsLoading(true);
const handleCanPlay = () => setIsLoading(false);
const handleLoadStart = () => { setIsLoading(true); setCanPlay(false); }; // 加载开始时重置 canPlay
const handleCanPlay = () => { setIsLoading(false); setCanPlay(true); }; // 可以播放时设置 canPlay
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
@@ -73,9 +83,18 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
audio.addEventListener('loadstart', handleLoadStart);
audio.addEventListener('canplay', handleCanPlay);
// 自动播放音频(仅在用户交互后有效)
if (playerState.isPlaying) {
audio.play().catch(e => console.error("Audio play failed:", e));
// 播放状态由外部 props 控制,并且只有当音频可以播放时才尝试播放
if (isPlaying && canPlay) {
audio.play().catch(e => {
// 只有当不是 AbortError 时才输出错误
if (e.name !== 'AbortError') {
console.error("Audio play failed:", e);
}
});
// 确保音频播放速度与状态一致
audio.playbackRate = currentPlaybackRate;
} else {
audio.pause();
}
@@ -86,42 +105,32 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
audio.removeEventListener('loadstart', handleLoadStart);
audio.removeEventListener('canplay', handleCanPlay);
};
}, []);
}, [isPlaying, podcast.audioUrl, canPlay]); // 将 canPlay 加入依赖,确保状态变化时触发播放
// 当播客URL变化时重置并加载新音频
// 当播客URL变化时更新audio元素的src
useEffect(() => {
const audio = audioRef.current;
if (audio && podcast.audioUrl) {
// 停止当前播放
audio.pause();
// 重新设置src这将触发loadedmetadata事件
if (audio && podcast.audioUrl && audio.src !== podcast.audioUrl) { // 避免不必要的SRC更新
audio.src = podcast.audioUrl;
audio.load();
// 重置播放器状态
setPlayerState({
isPlaying: false,
audio.load(); // 强制重新加载媒体
setPlayerState(prev => ({ // 重置时间,保持音量
...prev,
currentTime: 0,
duration: 0,
volume: audio.volume,
playbackRate: 1,
});
setIsLoading(true); // 开始加载,显示加载状态
setIsMuted(audio.muted || audio.volume === 0);
duration: 0, // 重置duration直到loadedmetadata
playbackRate: 1, // 重置playerState中的playbackRate
}));
setCurrentPlaybackRate(1); // 重置倍速状态
if (audio) {
audio.playbackRate = 1; // 确保实际音频倍速也重置
}
setIsLoading(true);
setCanPlay(false); // 重新加载时重置 canPlay
}
}, [podcast.audioUrl]);
const togglePlayPause = () => {
const audio = audioRef.current;
if (!audio) return;
if (playerState.isPlaying) {
audio.pause();
} else {
audio.play().catch(e => console.error("Audio play failed:", e)); // 捕获播放错误
}
setPlayerState(prev => ({ ...prev, isPlaying: !prev.isPlaying }));
onPlayPause();
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -217,11 +226,11 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
onClick={togglePlayPause}
disabled={isLoading}
className="w-8 h-8 flex-shrink-0 bg-black text-white rounded-full flex items-center justify-center hover:bg-neutral-800 transition-colors disabled:opacity-50"
title={playerState.isPlaying ? "暂停" : "播放"}
title={isPlaying ? "暂停" : "播放"}
>
{isLoading ? (
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : playerState.isPlaying ? (
) : isPlaying ? (
<Pause className="w-3 h-3" />
) : (
<Play className="w-3 h-3 ml-0.5" />
@@ -241,14 +250,14 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
)}
</div>
{!effectiveIsCollapsed && ( // 根据 effectiveIsCollapsed 隐藏可视化器
{/* {!effectiveIsCollapsed && ( // 根据 effectiveIsCollapsed 隐藏可视化器
<AudioVisualizer
audioElement={audioRef.current}
isPlaying={playerState.isPlaying}
isPlaying={isPlaying}
className="flex-grow min-w-[50px] max-w-[150px]" // 响应式宽度,适应扁平布局
height={20} // 更扁平的高度
/>
)}
)} */}
</div>
</div>
@@ -293,6 +302,23 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
<SkipForward className="w-4 h-4" />
</button>
{/* 倍速控制按钮 */}
<button
onClick={() => {
const currentIndex = playbackRates.indexOf(currentPlaybackRate);
const nextIndex = (currentIndex + 1) % playbackRates.length;
const newRate = playbackRates[nextIndex];
setCurrentPlaybackRate(newRate);
if (audioRef.current) {
audioRef.current.playbackRate = newRate;
}
}}
className="p-1 text-neutral-600 hover:text-black transition-colors min-w-[40px] text-xs"
title={`当前倍速: ${currentPlaybackRate.toFixed(2)}x`}
>
{currentPlaybackRate.toFixed(2)}x
</button>
{/* 音量控制 */}
<div className="flex items-center gap-1">
<button

View File

@@ -0,0 +1,14 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import React from 'react';
interface AuthProvidersProps {
children: React.ReactNode;
}
const AuthProviders: React.FC<AuthProvidersProps> = ({ children }) => {
return <SessionProvider>{children}</SessionProvider>;
};
export default AuthProviders;

View File

@@ -1,10 +1,11 @@
'use client';
import React, { useState, useEffect } from 'react';
import { ChevronDown, Settings, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getItem } from '@/lib/storage';
import type { TTSConfig } from '@/types';
import { Check } from 'lucide-react';
import { usePreventDuplicateCall } from '@/hooks/useApiCall';
import type { TTSConfig, Voice } from '@/types';
import { getTTSProviders } from '@/lib/config';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
interface ConfigFile {
name: string;
@@ -13,7 +14,7 @@ interface ConfigFile {
}
interface ConfigSelectorProps {
onConfigChange?: (config: TTSConfig, name: string) => void; // 添加 name 参数
onConfigChange?: (config: TTSConfig, name: string, voices: Voice[]) => void; // 添加 name 和 voices 参数
className?: string;
}
@@ -24,16 +25,16 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [selectedConfig, setSelectedConfig] = useState<string>('');
const [currentConfig, setCurrentConfig] = useState<TTSConfig | null>(null);
const [voices, setVoices] = useState<Voice[]>([]); // 新增 voices 状态
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { executeOnce } = usePreventDuplicateCall();
// 检查TTS配置是否已设置
const isTTSConfigured = (configName: string): boolean => {
const settings = getItem<any>('podcast-settings');
const isTTSConfigured = (configName: string, settings: any): boolean => {
if (!settings) return false;
const configKey = configName.replace('.json', '').split('-')[0];
// console.log('configKey', configKey);
switch (configKey) {
case 'index':
@@ -53,16 +54,23 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
}
};
// 加载配置文件列表
// 加载配置文件列表 - 使用防重复调用机制
const loadConfigFiles = async () => {
try {
const result = await executeOnce(async () => {
const response = await fetch('/api/config');
const result = await response.json();
return response.json();
});
if (!result) {
return; // 如果是重复调用,直接返回
}
try {
if (result.success && Array.isArray(result.data)) {
// 过滤出已配置的TTS选项
const availableConfigs = result.data.filter((config: ConfigFile) =>
isTTSConfigured(config.name)
const settings = await getTTSProviders();
const availableConfigs = result.data.filter((config: ConfigFile) =>
isTTSConfigured(config.name, settings)
);
setConfigFiles(availableConfigs);
@@ -74,24 +82,26 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
// 如果没有可用配置,清空当前选择
setSelectedConfig('');
setCurrentConfig(null);
onConfigChange?.(null as any, '');
onConfigChange?.(null as any, '', []); // 传递空数组作为 voices
}
} else {
console.error('Invalid config files data:', result);
setConfigFiles([]);
}
} catch (error) {
console.error('Failed to load config files:', error);
console.error('Failed to process config files:', error);
setConfigFiles([]);
}
};
useEffect(() => {
loadConfigFiles();
}, []);
}, [executeOnce]); // 添加 executeOnce 到依赖项
// 监听localStorage变化重新加载配置
useEffect(() => {
if (!enableTTSConfigPage) return;
const handleStorageChange = () => {
loadConfigFiles();
};
@@ -104,13 +114,13 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('settingsUpdated', handleStorageChange);
};
}, [selectedConfig]);
}, [selectedConfig, executeOnce]);
// 加载特定配置文件
const loadConfig = async (configFile: string) => {
setIsLoading(true);
try {
const response = await fetch('/api/config', {
const configResponse = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -118,13 +128,17 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
body: JSON.stringify({ configFile }),
});
const result = await response.json();
if (result.success) {
setCurrentConfig(result.data);
onConfigChange?.(result.data, configFile); // 传递 configFile 作为 name
const configResult = await configResponse.json();
let fetchedVoices: Voice[] = [];
if (configResult.success) {
fetchedVoices = configResult.data.voices;
setVoices(fetchedVoices); // 更新 voices 状态
setCurrentConfig(configResult.data);
onConfigChange?.(configResult.data, configFile, fetchedVoices); // 传递 fetchedVoices
}
} catch (error) {
console.error('Failed to load config:', error);
console.error('Failed to load config or voices:', error);
} finally {
setIsLoading(false);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, RotateCw } from 'lucide-react';
import PodcastCard from './PodcastCard';
import type { PodcastItem } from '@/types';
@@ -11,9 +11,13 @@ interface ContentSectionProps {
items: PodcastItem[];
onViewAll?: () => void;
onPlayPodcast?: (podcast: PodcastItem) => void;
currentPodcast?: PodcastItem | null;
isPlaying?: boolean;
loading?: boolean;
variant?: 'default' | 'compact';
layout?: 'grid' | 'horizontal';
showRefreshButton?: boolean; // 新增刷新按钮属性
onRefresh?: () => void; // 新增刷新回调函数
}
const ContentSection: React.FC<ContentSectionProps> = ({
@@ -22,9 +26,13 @@ const ContentSection: React.FC<ContentSectionProps> = ({
items,
onViewAll,
onPlayPodcast,
currentPodcast,
isPlaying,
loading = false,
variant = 'default',
layout = 'grid'
layout = 'grid',
showRefreshButton, // 直接解构
onRefresh // 直接解构
}) => {
if (loading) {
return (
@@ -89,7 +97,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-1 sm:py-8">
{/* 标题栏 */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-2">
<div className="flex flex-col">
@@ -98,15 +106,27 @@ const ContentSection: React.FC<ContentSectionProps> = ({
<p className="text-sm text-neutral-600 mt-1">{subtitle}</p>
)}
</div>
{onViewAll && (
<button
onClick={onViewAll}
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap"
>
<ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</button>
)}
<div className="flex items-center gap-2"> {/* 包装刷新按钮和查看全部按钮 */}
{showRefreshButton && onRefresh && (
<button
onClick={onRefresh}
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap"
title="刷新"
>
<RotateCw className="w-4 h-4" />
</button>
)}
{onViewAll && (
<button
onClick={onViewAll}
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap"
>
<ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</button>
)}
</div>
</div>
{/* 内容布局 */}
@@ -130,6 +150,8 @@ const ContentSection: React.FC<ContentSectionProps> = ({
podcast={item}
onPlay={onPlayPodcast}
variant={variant}
currentPodcast={currentPodcast}
isPlaying={isPlaying}
/>
))}
</div>

View File

@@ -0,0 +1,97 @@
// web/src/components/LoginModal.tsx
"use client"; // 标记为客户端组件,因为需要交互性
import React, { FC, MouseEventHandler, useCallback, useRef } from "react";
import { signIn } from "next-auth/react";
import { createPortal } from "react-dom";
import { XMarkIcon } from "@heroicons/react/24/outline"; // 导入关闭图标
import { Chrome, Github } from "lucide-react"; // 从 lucide-react 导入 Google 和 GitHub 图标
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
}
const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
const modalRef = useRef<HTMLDivElement>(null);
// 点击背景关闭模态框
const handleOverlayClick: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
},
[onClose]
);
if (!isOpen) return null;
// 使用 React Portal 将模态框渲染到 body 下避免Z-index问题和父组件样式影响
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-auto"
onClick={handleOverlayClick}
aria-modal="true"
role="dialog"
>
<div
ref={modalRef}
className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6 sm:p-8 transform transition-all duration-300 ease-out scale-95 opacity-0 animate-scale-in"
// 使用 Tailwind CSS 动画来优化进入效果,确保布局健壮性
style={{ animationFillMode: 'forwards' }} // 动画结束后保持最终状态
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
aria-label="关闭登录弹出框"
>
<XMarkIcon className="h-6 w-6" />
</button>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
</h2>
<div className="space-y-4">
<button
onClick={() => signIn('google')}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-lg font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<Chrome className="h-6 w-6" />
<span className="text-lg">使 Google </span>
</button>
<button
onClick={() => signIn('github')}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-lg font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<Github className="h-6 w-6" />
<span className="text-lg">使 GitHub </span>
</button>
</div>
</div>
</div>,
document.body // 渲染到 body 元素下
);
};
export default LoginModal;
// 添加一个简单的 Tailwind CSS 动画到你的 web/tailwind.config.js 文件中
// 示例:
// module.exports = {
// theme: {
// extend: {
// keyframes: {
// 'scale-in': {
// '0%': { transform: 'scale(0.95)', opacity: '0' },
// '100%': { transform: 'scale(1)', opacity: '1' },
// }
// },
// animation: {
// 'scale-in': 'scale-in 0.2s ease-out',
// }
// }
// }
// }

View File

@@ -2,7 +2,7 @@
import React, { useState } from 'react';
import Image from 'next/image';
import { Play, Clock, Eye, User, Heart, MoreHorizontal } from 'lucide-react';
import { Play, Pause, Clock, Eye, User, Heart, MoreHorizontal } from 'lucide-react';
import { cn, formatTime, formatRelativeTime } from '@/lib/utils';
import type { PodcastItem } from '@/types';
@@ -11,17 +11,23 @@ interface PodcastCardProps {
onPlay?: (podcast: PodcastItem) => void;
className?: string;
variant?: 'default' | 'compact';
currentPodcast?: PodcastItem | null;
isPlaying?: boolean;
}
const PodcastCard: React.FC<PodcastCardProps> = ({
podcast,
const PodcastCard: React.FC<PodcastCardProps> = ({
podcast,
onPlay,
className,
variant = 'default'
variant = 'default',
currentPodcast,
isPlaying,
}) => {
const [isLiked, setIsLiked] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const isCurrentlyPlaying = currentPodcast?.id === podcast.id && isPlaying;
const handlePlayClick = (e: React.MouseEvent) => {
e.stopPropagation();
onPlay?.(podcast);
@@ -42,15 +48,15 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
if (variant === 'compact') {
return (
<div className={cn(
"group bg-white border border-neutral-200 rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-large hover:-translate-y-1 cursor-pointer w-full max-w-[320px] h-24",
"group bg-white border border-neutral-200 rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-large hover:-translate-y-1 cursor-pointer w-full max-w-[320px] h-24 relative", // Added relative
"sm:max-w-[350px] sm:h-28",
"md:max-w-[320px] md:h-24",
"lg:max-w-[350px] lg:h-28",
className
)}>
<div className="flex gap-4 p-4 h-full">
<div className="flex items-center gap-4 p-4 h-full">
{/* 缩略图 */}
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-gradient-to-br from-brand-purple to-brand-pink flex-shrink-0">
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-gradient-to-br from-brand-purple to-brand-pink">
{podcast.thumbnail ? (
<Image
src={podcast.thumbnail}
@@ -71,31 +77,43 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
onClick={handlePlayClick}
className="w-8 h-8 bg-white/90 hover:bg-white rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-200"
>
<Play className="w-3 h-3 text-black ml-0.5" />
{isCurrentlyPlaying ? (
<Pause className="w-3 h-3 text-black" />
) : (
<Play className="w-3 h-3 text-black ml-0.5" />
)}
</button>
</div>
</div>
{/* 内容 */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-black text-base mb-1 truncate">
<h3 className="font-semibold text-black text-base mb-1 line-clamp-2 leading-tight">
{podcast.title}
</h3>
<p className="text-sm text-neutral-600 mb-2 truncate">
{podcast.author.name}
<p className="text-sm text-neutral-600 mb-2 line-clamp-1">
{podcast.description}
</p>
<div className="flex items-center gap-3 text-xs text-neutral-500">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(podcast.duration)}
</span>
<span className="flex items-center gap-1">
{/* <span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{podcast.playCount.toLocaleString()}
</span>
</span> */}
</div>
</div>
</div>
{/* 遮罩层 */}
{(podcast.status === 'pending' || podcast.status === 'running') && (
<div className="absolute inset-0 bg-black/100 z-10 flex flex-col items-center justify-center text-white text-lg font-semibold p-4 text-center">
<p className="mb-2">
{podcast.status === 'pending' ? '播客生成排队中...' : '播客生成中...'}
</p>
</div>
)}
</div>
);
}
@@ -105,7 +123,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
return (
<div
className={cn(
"group bg-white border border-neutral-200 rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-large hover:-translate-y-1 cursor-pointer w-full max-w-sm",
"group bg-white border border-neutral-200 rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-large hover:-translate-y-1 cursor-pointer w-full max-w-sm relative", // Added relative
"sm:max-w-md",
"md:max-w-lg",
"lg:max-w-xl",
@@ -138,7 +156,11 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
onClick={handlePlayClick}
className="w-14 h-14 bg-white/95 hover:bg-white rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-300 shadow-medium"
>
<Play className="w-6 h-6 text-black ml-0.5" />
{isCurrentlyPlaying ? (
<Pause className="w-6 h-6 text-black" />
) : (
<Play className="w-6 h-6 text-black ml-0.5" />
)}
</button>
</div>
@@ -151,8 +173,8 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
onClick={handleLikeClick}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200 backdrop-blur-sm",
isLiked
? "bg-red-500 text-white"
isLiked
? "bg-red-500 text-white"
: "bg-white/90 hover:bg-white text-neutral-600 hover:text-red-500"
)}
>
@@ -206,10 +228,10 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
{/* 元数据 */}
<div className="flex items-center gap-4 text-sm text-neutral-500 mb-4">
<div className="flex items-center gap-1.5">
{/* <div className="flex items-center gap-1.5">
<Eye className="w-4 h-4" />
<span>{podcast.playCount.toLocaleString()}</span>
</div>
</div> */}
<div className="w-1 h-1 bg-neutral-300 rounded-full"></div>
<span>{formatRelativeTime(podcast.createdAt)}</span>
</div>

View File

@@ -1,11 +1,11 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import {
Play,
Wand2,
Link,
Copy,
import {
Play,
Wand2,
Link,
Copy,
Upload,
Globe,
ChevronDown,
@@ -14,47 +14,89 @@ import {
import { cn } from '@/lib/utils';
import ConfigSelector from './ConfigSelector';
import VoicesModal from './VoicesModal'; // 引入 VoicesModal
import type { PodcastGenerationRequest, TTSConfig, Voice } from '@/types';
import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container
import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types';
interface PodcastCreatorProps {
onGenerate: (request: PodcastGenerationRequest) => void;
isGenerating?: boolean;
credits: number; // 新增积分属性
credits: number;
settings: SettingsFormData | null; // 新增 settings 属性
}
const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onGenerate,
isGenerating = false,
credits // 解构 credits 属性
credits,
settings // 解构 settings 属性
}) => {
const languageOptions = [
{ value: 'Make sure the language of the output content is Chinese', label: '简体中文' },
{ value: 'Make sure the language of the output content is English', label: 'English' },
{ value: 'Make sure the language of the output content is Japanese', label: '日本語' },
];
const durationOptions = [
{ value: '5-10 minutes', label: '5-10分钟' },
{ value: '15-20 minutes', label: '15-20分钟' },
{ value: '25-30 minutes', label: '25-30分钟' },
];
const [topic, setTopic] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const [selectedMode, setSelectedMode] = useState<'ai-podcast' | 'flowspeech'>('ai-podcast');
const [language, setLanguage] = useState('zh-CN');
const [language, setLanguage] = useState(languageOptions[0].value);
const [duration, setDuration] = useState(durationOptions[0].value);
const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态
const [voices, setVoices] = useState<Voice[]>([]); // 新增 voices 状态
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>({}); // 新增:单独存储选中的说话人
const [style, setStyle] = useState<'casual' | 'professional' | 'educational' | 'entertaining'>('casual');
const [duration, setDuration] = useState<'short' | 'medium' | 'long'>('medium');
const [voices, setVoices] = useState<Voice[]>([]); // 从 ConfigSelector 获取 voices
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => {
// 从 localStorage 读取缓存的说话人配置
const cachedVoices = getItem<{[key: string]: Voice[]}>('podcast-selected-voices');
return cachedVoices || {};
}); // 新增:单独存储选中的说话人
const [selectedConfig, setSelectedConfig] = useState<TTSConfig | null>(null);
const [selectedConfigName, setSelectedConfigName] = useState<string>(''); // 新增状态来存储配置文件的名称
const fileInputRef = useRef<HTMLInputElement>(null);
const { toasts, error } = useToast(); // 使用 useToast hook
const handleSubmit = () => {
if (!topic.trim()) return;
if (!topic.trim()) {
error("主题不能为空", "请输入播客主题。"); // 使用 toast.error
return;
}
if (!selectedConfig) {
error("TTS配置未选择", "请选择一个TTS配置。"); // 使用 toast.error
return;
}
const request: PodcastGenerationRequest = {
topic: topic.trim(),
customInstructions: customInstructions.trim() || undefined,
speakers: selectedPodcastVoices[selectedConfigName]?.length || selectedConfig?.podUsers?.length || 2, // 优先使用选中的说话人数量
language,
style,
duration,
ttsConfig: selectedConfig ? { ...selectedConfig, voices: selectedPodcastVoices[selectedConfigName] || [] } : undefined, // 将选中的说话人添加到 ttsConfig
};
onGenerate(request);
};
if (!selectedPodcastVoices[selectedConfigName] || selectedPodcastVoices[selectedConfigName].length === 0) {
error("请选择说话人", "请至少选择一位播客说话人。"); // 使用 toast.error
return;
}
let inputTxtContent = topic.trim();
if (customInstructions.trim()) {
inputTxtContent = "```custom-begin"+`\n${customInstructions.trim()}\n`+"```custom-end"+`\n${inputTxtContent}`;
}
const request: PodcastGenerationRequest = {
tts_provider: selectedConfigName.replace('.json', ''),
input_txt_content: inputTxtContent,
tts_providers_config_content: JSON.stringify(settings),
podUsers_json_content: JSON.stringify(selectedPodcastVoices[selectedConfigName] || []),
api_key: settings?.apikey,
base_url: settings?.baseurl,
model: settings?.model,
callback_url: "https://your-callback-url.com/podcast-status", // Assuming a fixed callback URL
usetime: duration,
output_language: language,
};
onGenerate(request);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -77,144 +119,103 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
}
};
const languageOptions = [
{ value: 'zh-CN', label: '简体中文' },
{ value: 'en-US', label: 'English' },
{ value: 'ja-JP', label: '日本語' },
];
const durationOptions = [
{ value: 'short', label: '5-10分钟' },
{ value: 'medium', label: '15-20分钟' },
{ value: 'long', label: '25-30分钟' },
];
useEffect(() => {
const fetchVoices = async () => {
if (selectedConfig && selectedConfigName) { // 确保 selectedConfigName 存在
try {
const response = await fetch('/api/tts-voices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ttsConfigName: selectedConfigName }), // 使用 selectedConfigName
});
const data = await response.json();
if (data.success) {
setVoices(data.data);
} else {
console.error('Failed to fetch voices:', data.error);
setVoices([]);
}
} catch (error) {
console.error('Error fetching voices:', error);
setVoices([]);
}
} else {
setVoices([]);
}
};
fetchVoices();
}, [selectedConfig, selectedConfigName]); // 依赖项中添加 selectedConfigName
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
{/* 品牌标题区域 */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 gradient-brand rounded-xl flex items-center justify-center">
<div className="w-6 h-6 bg-white rounded opacity-90" />
</div>
<h1 className="text-3xl font-bold text-black break-words">PodcastHub</h1>
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-black mb-6 break-words">
</h2>
{/* 模式切换按钮 */}
<div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
<button
onClick={() => setSelectedMode('ai-podcast')}
className={cn(
"flex items-center gap-2 px-4 py-2 sm:px-6 sm:py-3 rounded-full font-medium transition-all duration-200",
selectedMode === 'ai-podcast'
? "btn-primary"
: "btn-secondary"
)}
>
<Play className="w-4 h-4" />
AI播客
</button>
<button
onClick={() => setSelectedMode('flowspeech')}
className={cn(
"flex items-center gap-2 px-4 py-2 sm:px-6 sm:py-3 rounded-full font-medium transition-all duration-200",
selectedMode === 'flowspeech'
? "btn-primary"
: "btn-secondary"
)}
>
<Wand2 className="w-4 h-4" />
FlowSpeech
</button>
</div>
</div>
{/* 主要创作区域 */}
<div className="bg-white border border-neutral-200 rounded-2xl shadow-soft">
{/* 输入区域 */}
<div className="p-6">
<textarea
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="输入文字、上传文件或粘贴链接..."
className="w-full h-32 resize-none border-none outline-none text-lg placeholder-neutral-400"
disabled={isGenerating}
/>
{/* 自定义指令 */}
{customInstructions !== undefined && (
<div className="mt-4 pt-4 border-t border-neutral-100">
<textarea
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
placeholder="添加自定义指令(可选)..."
className="w-full h-20 resize-none border-none outline-none text-sm placeholder-neutral-400"
disabled={isGenerating}
/>
<div className="max-w-4xl mx-auto px-4 sm:px-6">
{/* 品牌标题区域 */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 gradient-brand rounded-xl flex items-center justify-center">
<div className="w-6 h-6 bg-white rounded opacity-90" />
</div>
)}
<h1 className="text-3xl font-bold text-black break-words">PodcastHub</h1>
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-black mb-6 break-words">
</h2>
{/* 模式切换按钮 */}
<div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
<button
onClick={() => setSelectedMode('ai-podcast')}
className={cn(
"flex items-center gap-2 px-4 py-2 sm:px-6 sm:py-3 rounded-full font-medium transition-all duration-200",
selectedMode === 'ai-podcast'
? "btn-primary"
: "btn-secondary"
)}
>
<Play className="w-4 h-4" />
AI播客
</button>
<button
onClick={() => setSelectedMode('flowspeech')}
className={cn(
"flex items-center gap-2 px-4 py-2 sm:px-6 sm:py-3 rounded-full font-medium transition-all duration-200",
selectedMode === 'flowspeech'
? "btn-primary"
: "btn-secondary"
)}
>
<Wand2 className="w-4 h-4" />
FlowSpeech
</button>
</div>
</div>
{/* 工具栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-start sm:justify-between px-4 sm:px-6 py-3 border-t border-neutral-100 bg-neutral-50 gap-y-4 sm:gap-x-2">
{/* 左侧配置选项 */}
{/* 主要创作区域 */}
<div className="bg-white border border-neutral-200 rounded-2xl shadow-soft">
{/* 输入区域 */}
<div className="p-6">
<textarea
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="输入文字、上传文件或粘贴链接..."
className="w-full h-32 resize-none border-none outline-none text-lg placeholder-neutral-400"
disabled={isGenerating}
/>
{/* 自定义指令 */}
{customInstructions !== undefined && (
<div className="mt-4 pt-4 border-t border-neutral-100">
<textarea
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
placeholder="添加自定义指令(可选)..."
className="w-full h-16 resize-none border-none outline-none text-sm placeholder-neutral-400"
disabled={isGenerating}
/>
</div>
)}
</div>
{/* 工具栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-start sm:justify-between px-4 sm:px-6 py-3 border-t border-neutral-100 bg-neutral-50 gap-y-4 sm:gap-x-2">
{/* 左侧配置选项 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 w-full sm:max-w-[500px]">
{/* TTS配置选择 */}
<div className='relative w-full'>
<ConfigSelector
onConfigChange={(config, name) => {
setSelectedConfig(config);
setSelectedConfigName(name); // 更新配置名称状态
}}
className="w-full"
onConfigChange={(config, name, newVoices) => { // 接收新的 voices 参数
setSelectedConfig(config);
setSelectedConfigName(name); // 更新配置名称状态
setVoices(newVoices); // 更新 voices 状态
}}
className="w-full"
/></div>
{/* 说话人按钮 */}
<div className='relative w-full'>
<button
onClick={() => setShowVoicesModal(true)}
className={cn(
"px-4 py-2 rounded-lg text-sm",
selectedPodcastVoices[selectedConfigName] && selectedPodcastVoices[selectedConfigName].length > 0
? "w-full bg-black text-white"
: "btn-secondary w-full"
)}
disabled={isGenerating || !selectedConfig}
onClick={() => setShowVoicesModal(true)}
className={cn(
"px-4 py-2 rounded-lg text-sm",
selectedPodcastVoices[selectedConfigName] && selectedPodcastVoices[selectedConfigName].length > 0
? "w-full bg-black text-white"
: "btn-secondary w-full"
)}
disabled={isGenerating || !selectedConfig}
>
</button></div>
{/* 语言选择 */}
@@ -291,12 +292,12 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
<Copy className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{/* 积分显示 */}
<div className="flex items-center gap-1 text-xs text-neutral-500">
<div className="flex items-center justify-end gap-1 text-xs text-neutral-500 w-20 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-gem flex-shrink-0">
<path d="M6 3v18l6-4 6 4V3z"/>
<path d="M12 3L20 9L12 15L4 9L12 3Z"/>
</svg>
<span className="break-all">{credits}</span>
<span className="truncate">{credits}</span>
</div>
<div className="flex flex-col items-center gap-1">
{/* 创作按钮 */}
@@ -333,7 +334,11 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onClose={() => setShowVoicesModal(false)}
voices={voices}
onSelectVoices={(selectedVoices) => {
setSelectedPodcastVoices(prev => ({...prev, [selectedConfigName]: selectedVoices})); // 更新选中的说话人状态
setSelectedPodcastVoices(prev => {
const newState = {...prev, [selectedConfigName]: selectedVoices};
setItem('podcast-selected-voices', newState); // 缓存选中的说话人
return newState;
}); // 更新选中的说话人状态
setShowVoicesModal(false); // 选中后关闭模态框
}}
initialSelectedVoices={selectedPodcastVoices[selectedConfigName] || []} // 传递选中的说话人作为初始值
@@ -341,14 +346,17 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onRemoveVoice={(voiceCodeToRemove) => {
setSelectedPodcastVoices(prev => {
const newVoices = (prev[selectedConfigName] || []).filter(v => v.code !== voiceCodeToRemove);
return {
const newState = {
...prev,
[selectedConfigName]: newVoices
};
setItem('podcast-selected-voices', newState); // 更新缓存
return newState;
});
}}
/>
)}
<ToastContainer toasts={toasts} onRemove={() => {}} /> {/* 添加 ToastContainer */}
</div>
);
};

View File

@@ -1,233 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PodcastGenerationResponse } from '@/types';
interface ProgressModalProps {
taskId: string;
isOpen: boolean;
onClose: () => void;
onComplete?: (result: PodcastGenerationResponse) => void;
}
const ProgressModal: React.FC<ProgressModalProps> = ({
taskId,
isOpen,
onClose,
onComplete
}) => {
const [task, setTask] = useState<PodcastGenerationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen || !taskId) return;
const pollProgress = async () => {
try {
const response = await fetch(`/api/generate-podcast?id=${taskId}`);
if (!response.ok) {
throw new Error('Failed to fetch progress');
}
const result = await response.json();
if (result.success) {
setTask(result.data);
if (result.data.status === 'completed') {
onComplete?.(result.data);
} else if (result.data.status === 'error') {
setError(result.data.error || '生成失败');
}
} else {
setError(result.error || '获取进度失败');
}
} catch (err) {
setError('网络错误,请稍后重试');
}
};
// 立即执行一次
pollProgress();
// 设置轮询
const interval = setInterval(pollProgress, 2000);
return () => clearInterval(interval);
}, [taskId, isOpen, onComplete]);
if (!isOpen) return null;
const getStatusText = (status: PodcastGenerationResponse['status']) => {
switch (status) {
case 'pending': return '准备中...';
case 'generating_outline': return '生成播客大纲...';
case 'generating_script': return '生成播客脚本...';
case 'generating_audio': return '生成音频文件...';
case 'merging': return '合并音频...';
case 'completed': return '生成完成!';
case 'error': return '生成失败';
default: return '处理中...';
}
};
const getStatusIcon = (status: PodcastGenerationResponse['status']) => {
switch (status) {
case 'completed':
return <CheckCircle className="w-6 h-6 text-green-500" />;
case 'error':
return <AlertCircle className="w-6 h-6 text-red-500" />;
default:
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />;
}
};
const formatEstimatedTime = (seconds: number) => {
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-slide-up">
{/* 头部 */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-black"></h2>
<button
onClick={onClose}
className="p-2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{error ? (
/* 错误状态 */
<div className="text-center py-8">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-black mb-2"></h3>
<p className="text-neutral-600 mb-6">{error}</p>
<button
onClick={onClose}
className="btn-primary"
>
</button>
</div>
) : task ? (
/* 进度显示 */
<div>
{/* 状态图标和文本 */}
<div className="text-center mb-6">
{getStatusIcon(task.status)}
<h3 className="text-lg font-medium text-black mt-3 mb-2">
{getStatusText(task.status)}
</h3>
{task.status !== 'completed' && task.status !== 'error' && task.estimatedTime && (
<p className="text-sm text-neutral-600">
{formatEstimatedTime(Math.max(0, task.estimatedTime - (task.progress / 100) * task.estimatedTime))}
</p>
)}
</div>
{/* 进度条 */}
<div className="mb-6">
<div className="flex justify-between text-sm text-neutral-600 mb-2">
<span></span>
<span>{task.progress}%</span>
</div>
<div className="w-full h-2 bg-neutral-200 rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all duration-500 ease-out",
task.status === 'error' ? 'bg-red-500' :
task.status === 'completed' ? 'bg-green-500' : 'bg-blue-500'
)}
style={{ width: `${task.progress}%` }}
/>
</div>
</div>
{/* 步骤指示器 */}
<div className="space-y-3 mb-6">
{[
{ key: 'generating_outline', label: '生成大纲' },
{ key: 'generating_script', label: '生成脚本' },
{ key: 'generating_audio', label: '生成音频' },
{ key: 'merging', label: '合并处理' },
].map((step, index) => {
const isActive = task.status === step.key;
const isCompleted = ['generating_outline', 'generating_script', 'generating_audio', 'merging'].indexOf(task.status) > index;
return (
<div key={step.key} className="flex items-center gap-3">
<div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium",
isCompleted ? 'bg-green-500 text-white' :
isActive ? 'bg-blue-500 text-white' :
'bg-neutral-200 text-neutral-500'
)}>
{isCompleted ? '✓' : index + 1}
</div>
<span className={cn(
"text-sm",
isActive ? 'text-black font-medium' :
isCompleted ? 'text-green-600' :
'text-neutral-500'
)}>
{step.label}
</span>
</div>
);
})}
</div>
{/* 完成状态的操作按钮 */}
{task.status === 'completed' && (
<div className="flex gap-3">
<button
onClick={onClose}
className="btn-secondary flex-1"
>
</button>
{task.audioUrl && (
<button
onClick={() => {
// 这里可以触发播放或下载
window.open(task.audioUrl, '_blank');
}}
className="btn-primary flex-1"
>
</button>
)}
</div>
)}
{/* 取消按钮(仅在进行中时显示) */}
{task.status !== 'completed' && task.status !== 'error' && (
<button
onClick={onClose}
className="btn-secondary w-full"
>
</button>
)}
</div>
) : (
/* 加载状态 */
<div className="text-center py-8">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin mx-auto mb-4" />
<p className="text-neutral-600">...</p>
</div>
)}
</div>
</div>
);
};
export default ProgressModal;

View File

@@ -1,22 +1,29 @@
'use client';
import React from 'react';
import {
Home,
Library,
Compass,
DollarSign,
Coins,
import React, { useState, useEffect } from 'react'; // 导入 useState 和 useEffect 钩子
import {
Home,
Library,
Compass,
DollarSign,
Coins,
Settings,
Twitter,
MessageCircle,
Mail,
Cloud,
Smartphone,
PanelLeftClose,
PanelLeftOpen
PanelLeftClose,
PanelLeftOpen,
LogIn, // 导入 LogIn 图标用于登录按钮
LogOut, // 导入 LogOut 图标用于注销按钮
User2 // 导入 User2 图标用于默认头像
} from 'lucide-react';
import { useSession, signOut } from 'next-auth/react'; // 导入 useSession 和 signOut 钩子
import { cn } from '@/lib/utils';
import LoginModal from './LoginModal'; // 导入 LoginModal 组件
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
interface SidebarProps {
currentView: string;
@@ -40,8 +47,26 @@ const Sidebar: React.FC<SidebarProps> = ({
collapsed = false,
onToggleCollapse,
mobileOpen = false, // 解构移动端侧边栏状态属性
credits // 解构 credits 属性
credits // 解构 credits 属性
}) => {
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示状态
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); // 控制注销确认模态框的显示状态
const { data: session } = useSession(); // 获取用户会话数据
useEffect(() => {
if (session?.expires) {
const expirationTime = new Date(session.expires).getTime();
const currentTime = new Date().getTime();
if (currentTime > expirationTime) {
console.log('Session expired, logging out...');
signOut(); // 会话过期,执行注销
}
}
}, [session]); // 监听 session 变化
console.log('session', session);
const mainNavItems: NavItem[] = [
{ id: 'home', label: '首页', icon: Home },
// 隐藏资料库和探索
@@ -53,9 +78,10 @@ const Sidebar: React.FC<SidebarProps> = ({
// 隐藏定价和积分
// { id: 'pricing', label: '定价', icon: DollarSign },
// { id: 'credits', label: '积分', icon: Coins, badge: credits.toString() }, // 动态设置 badge
{ id: 'settings', label: 'TTS设置', icon: Settings },
...(enableTTSConfigPage ? [{ id: 'settings', label: 'TTS设置', icon: Settings }] : [])
];
const socialLinks = [
{ icon: Twitter, href: '#', label: 'Twitter' },
{ icon: MessageCircle, href: '#', label: 'Discord' },
@@ -151,7 +177,7 @@ const Sidebar: React.FC<SidebarProps> = ({
{/* 底部区域 */}
<div className={cn("p-6", collapsed && "px-2")}>
{/* 底部导航 */}
<nav className="space-y-2 mb-6">
<nav className="space-y-2 mb-2">
{bottomNavItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
@@ -215,7 +241,107 @@ const Sidebar: React.FC<SidebarProps> = ({
})}
</div>
</div>
{/* 用户认证区域 */}
<div className={cn("mt-2", "flex", "justify-center")}>
{session?.user ? (
// 用户已登录
<div className={cn(
"flex items-center transition-all duration-200",
collapsed ? "flex-col" : "flex-row py-2 pr-2 gap-1", // 调整collapsed时移除gap展开时添加gap
)}>
{/* 用户头像 - 添加点击事件 */}
<button
onClick={() => {
if (!collapsed) { // 只有在展开状态下点击头像才弹出确认
setShowLogoutConfirm(true);
} else { // 折叠状态下,点击头像可以考虑不做任何事或做其他提示
// 可以在这里添加其他逻辑,例如提示“展开侧边栏以注销”
}
}}
className={cn(
"flex items-center justify-center rounded-full overflow-hidden cursor-pointer",
collapsed ? "w-8 h-8" : "w-10 h-10",
!collapsed && "hover:opacity-80 transition-opacity" // 展开时添加悬停效果
)}
title={collapsed ? (session.user.name || session.user.email || '用户') : "点击头像注销"}
>
{session.user.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={session.user.image}
alt="User Avatar"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-neutral-200 flex items-center justify-center">
<User2 className="w-5 h-5 text-neutral-500" />
</div>
)}
</button>
{/* 用户名 */}
<div className={cn(
"overflow-hidden transition-all duration-500 ease-in-out",
collapsed ? "hidden" : "w-auto flex-grow ml-3" // 收缩时添加 hidden class不占用空间
)}>
<span className={cn(
"whitespace-nowrap transition-all duration-500 ease-in-out transform-gpu",
collapsed ? "opacity-0 scale-x-0" : "opacity-100 scale-x-100 text-neutral-800 font-medium" // 收缩时文字也隐藏
)}>
{session.user.name || session.user.email || '用户'}
</span>
</div>
</div>
) : (
// 用户未登录
<button
onClick={() => setShowLoginModal(true)}
className={cn(
"flex items-center rounded-lg transition-all duration-200",
collapsed ? "justify-center w-8 h-8 px-0 text-neutral-600 hover:text-black hover:bg-neutral-50" : "justify-center w-[95%] mx-auto py-2 bg-black text-white hover:opacity-80"
)}
title={collapsed ? "登录" : undefined}
>
<LogIn className="w-5 h-5 flex-shrink-0" />
<div className={cn(
"overflow-hidden transition-all duration-500 ease-in-out",
collapsed ? "w-0 ml-0" : "w-auto ml-3"
)}>
<span className={cn(
"text-sm whitespace-nowrap transition-all duration-500 ease-in-out transform-gpu",
collapsed ? "opacity-0 scale-x-0" : "opacity-100 scale-x-100"
)}></span>
</div>
</button>
)}
</div>
{/* 注销确认模态框 */}
{showLogoutConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl text-center">
<p className="mb-4 text-lg font-semibold"></p>
<div className="flex justify-center gap-4">
<button
onClick={() => setShowLogoutConfirm(false)}
className="px-4 py-2 rounded-lg bg-neutral-200 text-neutral-800 hover:bg-neutral-300 transition-colors"
>
</button>
<button
onClick={() => signOut()}
className="px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
{/* 登录模态框 */}
<LoginModal isOpen={showLoginModal} onClose={() => setShowLoginModal(false)} />
</div>
);
};

View File

@@ -173,7 +173,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
}}
>
<div className="flex items-center space-x-3">
{voice.sample_audio_url && voice.sample_audio_url.length > 0 && voice.sample_audio_url !== 'undefined' && (
{voice.audio && voice.audio.length > 0 && voice.audio !== 'undefined' && (
<div className="flex-shrink-0">
<button
onClick={(e) => {
@@ -201,7 +201,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
if (el) audioRefs.current.set(voice.code!, el);
else audioRefs.current.delete(voice.code!);
}}
src={voice.sample_audio_url}
src={voice.audio}
onEnded={() => setPlayingVoiceId(null)}
onPause={() => setPlayingVoiceId(null)}
preload="none"

View File

@@ -0,0 +1,80 @@
import { useRef, useCallback } from 'react';
/**
* 防止重复API调用的自定义Hook
* 通过防抖机制确保在短时间内多次调用时只执行最后一次
*/
export function useApiCall() {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isCallingRef = useRef<boolean>(false);
const callApi = useCallback(async <T>(
apiFunction: () => Promise<T>,
delay: number = 300
): Promise<T | null> => {
// 如果正在调用中,取消之前的调用
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// 如果已经在调用中直接返回null
if (isCallingRef.current) {
return null;
}
return new Promise((resolve) => {
timeoutRef.current = setTimeout(async () => {
try {
isCallingRef.current = true;
const result = await apiFunction();
resolve(result);
} catch (error) {
console.error('API call failed:', error);
resolve(null);
} finally {
isCallingRef.current = false;
}
}, delay);
});
}, []);
const cancelPendingCall = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
isCallingRef.current = false;
}, []);
return { callApi, cancelPendingCall };
}
/**
* 防止重复调用的简单Hook
* 使用标志位确保同一时间只有一个调用在进行
*/
export function usePreventDuplicateCall() {
const isCallingRef = useRef<boolean>(false);
const executeOnce = useCallback(async <T>(
apiFunction: () => Promise<T>
): Promise<T | null> => {
if (isCallingRef.current) {
console.log('API call already in progress, skipping...');
return null;
}
try {
isCallingRef.current = true;
const result = await apiFunction();
return result;
} catch (error) {
console.error('API call failed:', error);
return null;
} finally {
isCallingRef.current = false;
}
}, []);
return { executeOnce, isExecuting: isCallingRef.current };
}

50
web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,50 @@
import { getItem } from './storage';
const SETTINGS_STORAGE_KEY = 'podcast-settings';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
let ttsProvidersPromise: Promise<any> | null = null;
/**
* 获取TTS提供商的配置
* 如果启用了配置页面则从localStorage获取
* 否则,从服务器的默认配置文件中获取。
* 通过缓存Promise来防止并发请求并缓存成功的结果。
* @returns {Promise<any>} 返回包含配置信息的对象如果失败则返回null。
*/
const fetchAndCacheProviders = (): Promise<any> => {
return (async () => {
try {
const response = await fetch('/api/tts-providers');
if (!response.ok) {
console.error('Failed to fetch tts-providers, status:', response.status);
ttsProvidersPromise = null; // 失败时重置,以便重试
return null;
}
const result = await response.json();
if (result.success) {
return result.data; // 成功获取后,缓存数据
}
ttsProvidersPromise = null; // 业务失败时重置,以便重试
return null;
} catch (error) {
console.error('Failed to fetch tts-providers:', error);
ttsProvidersPromise = null; // 失败时重置,以便重试
return null;
}
})();
};
export const getTTSProviders = async (): Promise<any> => {
if (enableTTSConfigPage) {
return getItem<any>(SETTINGS_STORAGE_KEY);
} else {
// 1. 如果没有并发请求,则发起新请求
if (!ttsProvidersPromise) {
ttsProvidersPromise = fetchAndCacheProviders();
}
// 2. 返回 Promise后续调用将复用此 Promise 直到其解决
return ttsProvidersPromise;
}
};

67
web/src/lib/podcastApi.ts Normal file
View File

@@ -0,0 +1,67 @@
import { HttpError } from '@/types';
import type { PodcastGenerationRequest, PodcastGenerationResponse, ApiResponse, PodcastStatusResponse } from '@/types';
const API_BASE_URL = process.env.NEXT_PUBLIC_PODCAST_API_BASE_URL || 'http://192.168.1.232:8000';
/**
* 启动播客生成任务
*/
export async function startPodcastGenerationTask(body: PodcastGenerationRequest): Promise<ApiResponse<PodcastGenerationResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/generate-podcast`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Id': '7788414',
},
body: new URLSearchParams(Object.entries(body).map(([key, value]) => [key, String(value)])),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: `请求失败,状态码: ${response.status}` }));
throw new HttpError(errorData.detail || `请求失败,状态码: ${response.status}`, response.status);
}
const result: PodcastGenerationResponse = await response.json();
// 确保id字段存在因为它在前端被广泛使用
result.id = result.task_id;
return { success: true, data: result };
} catch (error: any) {
console.error('Error in startPodcastGenerationTask:', error);
const statusCode = error instanceof HttpError ? error.statusCode : undefined;
return { success: false, error: error.message || '启动生成任务失败', statusCode };
}
}
/**
* 获取播客生成任务状态
*/
export async function getPodcastStatus(): Promise<ApiResponse<PodcastStatusResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/podcast-status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Auth-Id': '7788414',
},
cache: 'no-store', // 禁用客户端缓存
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: `请求失败,状态码: ${response.status}` }));
throw new HttpError(errorData.detail || `请求失败,状态码: ${response.status}`, response.status);
}
const result: PodcastStatusResponse = await response.json();
result.tasks.forEach(item => {
item.audioUrl = `/api/audio?filename=${item.output_audio_filepath}`;
})
return { success: true, data: result };
} catch (error: any) {
console.error('Error in getPodcastStatus:', error);
const statusCode = error instanceof HttpError ? error.statusCode : undefined;
return { success: false, error: error.message || '获取任务状态失败', statusCode };
}
}

View File

@@ -1,24 +1,34 @@
// 播客生成相关类型定义
export interface PodcastGenerationRequest {
topic: string;
customInstructions?: string;
speakers?: number;
language?: string;
style?: 'casual' | 'professional' | 'educational' | 'entertaining';
duration?: 'short' | 'medium' | 'long'; // 5-10min, 15-20min, 25-30min
ttsConfig?: TTSConfig;
tts_provider?: string;//tts选项的值
input_txt_content: string; //输入文字和自定义指令的拼接,自定义指令放在最上面,用```custom-begin```custom-end包裹
tts_providers_config_content?: string; // 根据用户反馈,这个是配置内容,来自设置
podUsers_json_content?: string; // 说话人配置,来自选择
api_key?: string; // 来自保存的设置
base_url?: string; // 来自保存的设置
model?: string; // 来自保存的设置
callback_url?: string; // 固定值
usetime?: string; // 时长 来自选择
output_language?: string; // 语言 来自设置
}
export interface PodcastGenerationResponse {
id: string;
status: 'pending' | 'generating_outline' | 'generating_script' | 'generating_audio' | 'merging' | 'completed' | 'error';
progress: number; // 0-100
outline?: string;
script?: PodcastScript;
audioUrl?: string;
id?: string; // 任务ID
status: 'pending' | 'running' | 'completed' | 'failed' ;
task_id?: string;
podUsers?: Array<{ role: string; code: string; }>;
output_audio_filepath?: string;
overview_content?: string;
podcast_script?: { podcast_transcripts: Array<{ speaker_id: number; dialog: string; }>; };
avatar_base64?: string;
audio_duration?: string;
title?: string;
tags?: string;
error?: string;
createdAt: string;
estimatedTime?: number; // 预估完成时间(秒)
timestamp?: number;
audioUrl?: string;
estimatedTime?: number; // 新增预估时间
progress?: number; // 新增进度百分比
}
export interface PodcastScript {
@@ -46,9 +56,10 @@ export interface ScriptSegment {
// TTS配置类型
export interface TTSConfig {
podUsers: Speaker[];
name?: string; // 新增 name 属性
podUsers?: Speaker[]; // 将 podUsers 改为可选
voices: Voice[];
apiUrl: string;
apiUrl?: string; // 将 apiUrl 改为可选
tts_provider?: string;
headers?: Record<string, string>;
request_payload?: Record<string, any>;
@@ -64,7 +75,7 @@ export interface Voice {
description?: string;
volume_adjustment?: number;
speed_adjustment?: number;
sample_audio_url?: string; // 添加 sample_audio_url
audio?: string;
}
// 音频播放器相关类型
@@ -110,6 +121,34 @@ export interface PodcastItem {
createdAt: string;
audioUrl: string;
tags: string[];
status: 'pending' | 'running' | 'completed' | 'failed'; // 添加status属性
}
// 设置表单数据类型 - 从 SettingsForm.tsx 复制过来并导出
export interface SettingsFormData {
apikey: string;
model: string;
baseurl: string;
index: {
api_url: string;
};
edge: {
api_url: string;
};
doubao: {
'X-Api-App-Id': string;
'X-Api-Access-Key': string;
};
fish: {
api_key: string;
};
minimax: {
group_id: string;
api_key: string;
};
gemini: {
api_key: string;
};
}
// API响应通用类型
@@ -118,4 +157,22 @@ export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
statusCode?: number; // 新增状态码
}
export class HttpError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
Object.setPrototypeOf(this, HttpError.prototype);
}
}
// PodcastStatusResponse 接口,用于匹配 api/podcast-status 的返回结构
export interface PodcastStatusResponse {
message: string;
tasks: PodcastGenerationResponse[]; // 包含任务列表
}

View File

@@ -0,0 +1,96 @@
/**
* API调用追踪器 - 用于调试和监控API调用
* 在开发环境中帮助识别重复调用问题
*/
interface ApiCall {
url: string;
method: string;
timestamp: number;
id: string;
}
class ApiCallTracker {
private calls: ApiCall[] = [];
private isDevelopment = process.env.NODE_ENV === 'development';
// 记录API调用
trackCall(url: string, method: string = 'GET'): string {
if (!this.isDevelopment) return '';
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const call: ApiCall = {
url,
method,
timestamp: Date.now(),
id
};
this.calls.push(call);
// 检查是否有重复调用5秒内相同URL和方法
const recentCalls = this.calls.filter(
c => c.url === url &&
c.method === method &&
Date.now() - c.timestamp < 5000 &&
c.id !== id
);
if (recentCalls.length > 0) {
console.warn(`🚨 检测到重复API调用:`, {
url,
method,
重复次数: recentCalls.length + 1,
最近调用时间: recentCalls.map(c => new Date(c.timestamp).toLocaleTimeString())
});
} else {
console.log(`📡 API调用:`, { url, method, time: new Date().toLocaleTimeString() });
}
// 清理超过1分钟的记录
this.calls = this.calls.filter(c => Date.now() - c.timestamp < 60000);
return id;
}
// 获取调用统计
getStats(): { [key: string]: number } {
const stats: { [key: string]: number } = {};
this.calls.forEach(call => {
const key = `${call.method} ${call.url}`;
stats[key] = (stats[key] || 0) + 1;
});
return stats;
}
// 清空记录
clear(): void {
this.calls = [];
}
}
// 创建全局实例
export const apiCallTracker = new ApiCallTracker();
// 包装fetch函数以自动追踪
export const trackedFetch = (url: string, options?: RequestInit) => {
const method = options?.method || 'GET';
apiCallTracker.trackCall(url, method);
return fetch(url, options);
};
// 开发环境下的调试工具
export const showApiStats = () => {
if (process.env.NODE_ENV === 'development') {
console.table(apiCallTracker.getStats());
}
};
// 在控制台暴露调试工具
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
(window as any).apiDebug = {
showStats: showApiStats,
clearStats: () => apiCallTracker.clear(),
tracker: apiCallTracker
};
}

View File

@@ -51,6 +51,16 @@ module.exports = {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'scale-in': {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'scale-in': 'scale-in 0.2s ease-out',
},
},
},