feat: 实现用户认证系统并优化音频播放器功能
- 新增NextAuth认证系统,支持Google和GitHub登录 - 添加登录模态框组件和用户头像显示 - 重构音频播放器,支持倍速控制和状态同步 - 优化播客卡片显示当前播放状态和生成状态 - 新增API调用追踪工具和防重复调用Hook - 修复多个API重复调用问题并添加详细文档 - 改进音频文件处理流程,支持MP3格式输出 - 更新类型定义和组件Props以支持新功能
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
189
web/API_DUPLICATE_CALL_FIX.md
Normal file
189
web/API_DUPLICATE_CALL_FIX.md
Normal 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调用追踪器(新增)
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 修复后的代码保持了原有功能不变
|
||||
- 所有修改都向后兼容
|
||||
- 调试工具只在开发环境中启用,不会影响生产环境性能
|
||||
- 建议在部署前进行充分测试,确保所有功能正常工作
|
||||
@@ -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: '服务器内部错误' },
|
||||
19
web/src/app/api/auth/[...nextauth]/route.ts
Normal file
19
web/src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
19
web/src/app/api/podcast-status/route.ts
Normal file
19
web/src/app/api/podcast-status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
web/src/app/api/tts-providers/route.ts
Normal file
23
web/src/app/api/tts-providers/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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通知容器 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
14
web/src/components/AuthProviders.tsx
Normal file
14
web/src/components/AuthProviders.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
97
web/src/components/LoginModal.tsx
Normal file
97
web/src/components/LoginModal.tsx
Normal 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',
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
80
web/src/hooks/useApiCall.ts
Normal file
80
web/src/hooks/useApiCall.ts
Normal 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
50
web/src/lib/config.ts
Normal 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
67
web/src/lib/podcastApi.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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[]; // 包含任务列表
|
||||
}
|
||||
96
web/src/utils/apiCallTracker.ts
Normal file
96
web/src/utils/apiCallTracker.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user