feat: 添加播客生成器Web应用基础架构

实现基于Next.js的播客生成器Web应用,包含以下主要功能:
- 完整的Next.js项目结构配置
- 播客生成API接口
- 音频文件服务API
- TTS配置管理
- 响应式UI组件
- 本地存储和状态管理
- 音频可视化组件
- 全局样式和主题配置

新增配置文件包括:
- Next.js、Tailwind CSS、ESLint等工具配置
- 环境变量示例文件
- 启动脚本和构建检查脚本
- 类型定义和工具函数库
This commit is contained in:
hex2077
2025-08-14 23:44:18 +08:00
parent 1242adb0e6
commit 719eb14927
45 changed files with 12825 additions and 32 deletions

102
CLAUDE.md
View File

@@ -4,6 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 常用命令
### Python 后端播客生成器
* **生成播客**:
```bash
python podcast_generator.py [可选参数]
@@ -19,9 +21,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
python podcast_generator.py --api-key sk-xxxxxx --model gpt-4o --threads 4
```
* **启动 FastAPI Web 服务**:
```bash
python main.py
```
默认在 `http://localhost:8000` 启动,提供 REST API 接口。
* **检查 TTS 语音列表**:
```bash
python check/check_edgetts_voices.py
python check/check_indextts_voices.py
# 其他 TTS 服务检查脚本...
```
### Next.js Web 应用 (web/ 目录)
* **开发模式**:
```bash
cd web
npm run dev
```
在 `http://localhost:3000` 启动开发服务器。
* **构建生产版本**:
```bash
cd web
npm run build
```
* **启动生产服务器**:
```bash
cd web
npm run start
```
* **类型检查**:
```bash
cd web
npm run type-check
```
* **代码检查**:
```bash
cd web
npm run lint
```
* **安装依赖**:
```bash
cd web
npm install
```
## 高层代码架构
本项目是一个简易播客生成器,核心功能是利用 AI 生成播客脚本并将其转换为音频。
本项目是一个全栈播客生成器,包含 Python 后端和 Next.js Web 前端,核心功能是利用 AI 生成播客脚本并将其转换为音频。
### Python 后端架构
* **`podcast_generator.py`**: 主运行脚本,负责协调整个播客生成流程,包括:
* 读取配置文件 (`config/*.json`)。
@@ -31,18 +87,52 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
* 使用 FFmpeg 合并生成的音频文件。
* 支持命令行参数配置 OpenAI API 和线程数。
* **`main.py`**: FastAPI Web 服务,提供 REST API 接口:
* `/generate-podcast`: 启动播客生成任务
* `/podcast-status`: 查询生成进度
* `/download-podcast/`: 下载生成的音频文件
* `/get-voices`: 获取可用的 TTS 语音列表
* **`tts_adapters.py`**: TTS 服务适配器,统一处理不同 TTS 服务的 API 调用。
* **`openai_cli.py`**: 负责与 OpenAI API 进行交互的模块。
* **`config/`**: 存放 TTS 服务和播客角色配置的 JSON 文件。例如 `edge-tts.json`。这些文件定义了 `podUsers` (播客角色)、`voices` (可用语音) 和 `apiUrl` (TTS 服务接口)。
* **`prompt/`**: 包含用于指导 AI 生成内容的提示词文件。
* `prompt-overview.txt`: 用于生成播客整体大纲。
* `prompt-podscript.txt`: 用于生成详细对话脚本,包含占位符 (`{{numSpeakers}}`, `{{turnPattern}}`)。
* **`input.txt`**: 用户输入播客主题或核心观点,也支持嵌入 `custom` 代码块来提供额外的 AI 指令
* **`check/`**: TTS 服务语音列表检查脚本,用于验证各种 TTS 服务的可用语音
* **`openai_cli.py`**: 负责与 OpenAI API 进行交互的模块。
### Next.js Web 前端架构 (web/ 目录)
* **`output/`**: 生成的播客音频文件 (`.wav`) 存放目录。
* **技术栈**: Next.js 14 (App Router), TypeScript, Tailwind CSS, Framer Motion
* **TTS 服务集成**: 项目设计为高度灵活,支持多种 TTS 服务,通过 `config/*.json` 中的 `apiUrl` 进行配置。目前支持本地部署的 `index-tts` 和 `edge-tts`,以及理论上可集成的网络 TTS 服务(如 OpenAI TTS, Azure TTS 等)。
* **`src/app/`**: Next.js App Router 页面和 API 路由
* `api/generate-podcast/route.ts`: 播客生成 API与 Python 后端集成
* `api/audio/[filename]/route.ts`: 音频文件服务 API
* `api/config/route.ts`: 配置管理 API
* `api/tts-voices/route.ts`: TTS 语音列表 API
* **音频合并**: 使用 FFmpeg 工具将各个角色的语音片段拼接成一个完整的播客音频文件。FFmpeg 必须安装并配置在系统环境变量中。
* **`src/components/`**: React 组件
* `PodcastCreator.tsx`: 播客创建器主组件
* `AudioPlayer.tsx`: 音频播放器组件
* `ProgressModal.tsx`: 生成进度显示模态框
* `ConfigSelector.tsx`: TTS 配置选择器
* `VoicesModal.tsx`: 语音选择模态框
* **`src/types/`**: TypeScript 类型定义,定义了播客生成请求/响应的数据结构
* **集成方式**: Web 应用通过 Node.js 子进程启动 Python 脚本,实时监控生成进度,并提供音频文件访问服务。
### TTS 服务集成
项目设计为高度灵活,支持多种 TTS 服务:
* **本地服务**: Index-TTS, Edge-TTS
* **网络服务**: 豆包 (Doubao), Minimax, Fish Audio, Gemini TTS
* **配置方式**: 通过 `config/*.json` 中的 `apiUrl` 进行配置
### 音频处理
使用 FFmpeg 工具将各个角色的语音片段拼接成一个完整的播客音频文件。FFmpeg 必须安装并配置在系统环境变量中。

View File

@@ -1,4 +1,7 @@
{
"apikey": null,
"model": null,
"baseurl": null,
"index": {
"api_url": null
},

118
main.py
View File

@@ -1,5 +1,5 @@
from fastapi import FastAPI, Request, HTTPException, Depends, Form, Header
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from typing import Optional, Dict
import uuid
import asyncio
@@ -13,10 +13,14 @@ import json
import argparse
from enum import Enum
import shutil
from PIL import Image, ImageDraw
import random
import schedule
import threading
from contextlib import asynccontextmanager # 导入 asynccontextmanager
import httpx # 导入 httpx 库
from io import BytesIO # 导入 BytesIO
import base64 # 导入 base64
from podcast_generator import generate_podcast_audio_api
@@ -155,7 +159,9 @@ async def _generate_podcast_task(
podUsers_json_content: str,
threads: int,
tts_provider: str,
callback_url: Optional[str] = None # 新增回调地址参数
callback_url: Optional[str] = None, # 新增回调地址参数
output_language: Optional[str] = None,
usetime: Optional[str] = None,
):
task_results[auth_id][task_id]["status"] = TaskStatus.RUNNING
try:
@@ -164,6 +170,8 @@ async def _generate_podcast_task(
parser.add_argument("--base-url", default=base_url, help="OpenAI API base URL (default: https://api.openai.com/v1).")
parser.add_argument("--model", default=model, help="OpenAI model to use (default: gpt-3.5-turbo).")
parser.add_argument("--threads", type=int, default=threads, help="Number of threads to use for audio generation (default: 1).")
parser.add_argument("--output-language", default=output_language, help="Output language for the podcast script (default: Chinese).")
parser.add_argument("--usetime", default=usetime, help="Time duration for the podcast script (default: 10 minutes).")
args = parser.parse_args([])
actual_config_path = tts_provider_map.get(tts_provider)
@@ -181,6 +189,11 @@ async def _generate_podcast_task(
task_results[auth_id][task_id]["status"] = TaskStatus.COMPLETED
task_results[auth_id][task_id].update(podcast_generation_results)
print(f"\nPodcast generation completed for task {task_id}. Output file: {podcast_generation_results.get('output_audio_filepath')}")
# 生成并编码像素头像
avatar_bytes = generate_pixel_avatar(str(task_id)) # 使用 task_id 作为种子
avatar_base64 = base64.b64encode(avatar_bytes).decode('utf-8')
task_results[auth_id][task_id]["avatar_base64"] = avatar_base64 # 存储 Base64 编码的头像数据
except Exception as e:
task_results[auth_id][task_id]["status"] = TaskStatus.FAILED
task_results[auth_id][task_id]["result"] = str(e)
@@ -231,7 +244,9 @@ async def generate_podcast_submission(
podUsers_json_content: str = Form(...),
threads: int = Form(1),
tts_provider: str = Form("index-tts"),
callback_url: Optional[str] = Form(None) # 新增回调地址参数
callback_url: Optional[str] = Form(None),
output_language: Optional[str] = Form(None),
usetime: Optional[str] = Form(None),
):
# 1. 验证 tts_provider
if tts_provider not in tts_provider_map:
@@ -265,7 +280,9 @@ async def generate_podcast_submission(
podUsers_json_content,
threads,
tts_provider,
callback_url # 传递回调地址
callback_url,
output_language,
usetime
)
return {"message": "Podcast generation started.", "task_id": task_id}
@@ -287,6 +304,10 @@ async def get_podcast_status(
"output_audio_filepath": task_info.get("output_audio_filepath"),
"overview_content": task_info.get("overview_content"),
"podcast_script": task_info.get("podcast_script"),
"avatar_base64": task_info.get("avatar_base64"), # 添加 Base64 编码的头像数据
"audio_duration": task_info.get("audio_duration"),
"title": task_info.get("title"),
"tags": task_info.get("tags"),
"error": task_info["result"] if task_info["status"] == TaskStatus.FAILED else None,
"timestamp": task_info["timestamp"]
})
@@ -299,6 +320,14 @@ async def download_podcast(file_name: str):
raise HTTPException(status_code=404, detail="File not found.")
return FileResponse(file_path, media_type='audio/mpeg', filename=file_name)
@app.get("/avatar/{username}")
async def get_avatar(username: str):
"""
根据用户名生成并返回一个像素头像。
"""
avatar_bytes = generate_pixel_avatar(username)
return StreamingResponse(BytesIO(avatar_bytes), media_type="image/png")
@app.get("/get-voices")
async def get_voices(tts_provider: str = "tts"):
config_path = tts_provider_map.get(tts_provider)
@@ -325,6 +354,87 @@ async def get_voices(tts_provider: str = "tts"):
async def read_root():
return {"message": "FastAPI server is running!"}
def generate_pixel_avatar(seed_string: str) -> bytes:
"""
根据给定的字符串生成一个48x48像素的像素头像。
头像具有确定性(相同输入字符串生成相同头像)和对称性。
"""
size = 48
pixel_grid_size = 5 # 内部像素网格大小 (例如 5x5)
# 使用SHA256哈希作为随机种子确保确定性
hash_object = hashlib.sha256(seed_string.encode('utf-8'))
hash_hex = hash_object.hexdigest()
# 将哈希值转换为整数,作为随机数生成器的种子
random.seed(int(hash_hex, 16))
# 创建一个空白的48x48 RGBA图像
img = Image.new('RGBA', (size, size), (255, 255, 255, 0)) # 透明背景
draw = ImageDraw.Draw(img)
# 随机生成头像的主颜色 (饱和度较高,亮度适中)
hue = random.randint(0, 360)
saturation = random.randint(70, 100) # 高饱和度
lightness = random.randint(40, 60) # 适中亮度
# 将HSL转换为RGB
def hsl_to_rgb(h, s, l):
h /= 360
s /= 100
l /= 100
if s == 0:
return (int(l * 255), int(l * 255), int(l * 255), 255)
def hue_to_rgb(p, q, t):
if t < 0: t += 1
if t > 1: t -= 1
if t < 1/6: return p + (q - p) * 6 * t
if t < 1/2: return q
if t < 2/3: return p + (q - p) * (2/3 - t) * 6
return p
q = l * (1 + s) if l < 0.5 else l + s - l * s
p = 2 * l - q
r = hue_to_rgb(p, q, h + 1/3)
g = hue_to_rgb(p, q, h)
b = hue_to_rgb(p, q, h - 1/3)
return (int(r * 255), int(g * 255), int(b * 255), 255)
main_color = hsl_to_rgb(hue, saturation, lightness)
# 生成像素网格
# 只需生成一半的网格,然后对称复制
pixels = [[0 for _ in range(pixel_grid_size)] for _ in range(pixel_grid_size)]
for y in range(pixel_grid_size):
for x in range((pixel_grid_size + 1) // 2): # 只生成左半部分或中间列
if random.random() > 0.5: # 50% 的几率填充像素
pixels[y][x] = 1 # 填充
pixels[y][pixel_grid_size - 1 - x] = 1 # 对称填充
# 计算每个内部像素在最终图像中的大小
pixel_width = size // pixel_grid_size
pixel_height = size // pixel_grid_size
# 绘制像素
for y in range(pixel_grid_size):
for x in range(pixel_grid_size):
if pixels[y][x] == 1:
draw.rectangle(
[x * pixel_width, y * pixel_height, (x + 1) * pixel_width, (y + 1) * pixel_height],
fill=main_color
)
# 将图像转换为字节流
from io import BytesIO
byte_io = BytesIO()
img.save(byte_io, format='PNG')
return byte_io.getvalue()
def run_scheduler():
"""在循环中运行调度器,直到设置停止事件。"""
while not stop_scheduler_event.is_set():

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Simple-Podcast-Script",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -156,6 +156,39 @@ def merge_audio_files():
print(f"Error removing file list {file_list_path}: {e}") # This should not stop the process
print("Cleaned up temporary files.")
def get_audio_duration(filepath: str) -> Optional[float]:
"""
Uses ffprobe to get the duration of an audio file in seconds.
Returns None if duration cannot be determined.
"""
try:
# Check if ffprobe is available
subprocess.run(["ffprobe", "-version"], check=True, capture_output=True, text=True)
except FileNotFoundError:
print("Error: ffprobe is not installed or not in your PATH. Please install FFmpeg (which includes ffprobe) to get audio duration.")
return None
try:
command = [
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
filepath
]
result = subprocess.run(command, check=True, capture_output=True, text=True)
duration = float(result.stdout.strip())
return duration
except subprocess.CalledProcessError as e:
print(f"Error calling ffprobe for {filepath}: {e.stderr}")
return None
except ValueError:
print(f"Could not parse duration from ffprobe output for {filepath}.")
return None
except Exception as e:
print(f"An unexpected error occurred while getting audio duration for {filepath}: {e}")
return None
def _parse_arguments():
"""Parses command-line arguments."""
parser = argparse.ArgumentParser(description="Generate podcast script and audio using OpenAI and local TTS.")
@@ -163,6 +196,8 @@ def _parse_arguments():
parser.add_argument("--base-url", default="https://api.openai.com/v1", help="OpenAI API base URL (default: https://api.openai.com/v1).")
parser.add_argument("--model", default="gpt-3.5-turbo", help="OpenAI model to use (default: gpt-3.5-turbo).")
parser.add_argument("--threads", type=int, default=1, help="Number of threads to use for audio generation (default: 1).")
parser.add_argument("--output-language", type=str, default="Chinese", help="Language for the podcast overview and script (default: Chinese).")
parser.add_argument("--usetime", type=str, default=None, help="Specific time to be mentioned in the podcast script, e.g., '今天', '昨天'.")
return parser.parse_args()
def _load_configuration():
@@ -227,7 +262,7 @@ def _extract_custom_content(input_prompt_content):
input_prompt_content = input_prompt_content[end_index + len(custom_end_tag):].strip()
return custom_content, input_prompt_content
def _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content):
def _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content, usetime: Optional[str] = None, output_language: Optional[str] = None):
"""Prepares the podcast script prompts with speaker info and placeholders."""
pod_users = config_data.get("podUsers", [])
voices = config_data.get("voices", [])
@@ -235,21 +270,36 @@ def _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_cont
original_podscript_prompt = original_podscript_prompt.replace("{{numSpeakers}}", str(len(pod_users)))
original_podscript_prompt = original_podscript_prompt.replace("{{turnPattern}}", turn_pattern)
original_podscript_prompt = original_podscript_prompt.replace("{{usetime}}", usetime if usetime is not None else "5-6 minutes")
original_podscript_prompt = original_podscript_prompt.replace("{{outlang}}", output_language if output_language is not None else "Make sure the input language is set as the output language")
speaker_id_info = generate_speaker_id_text(pod_users, voices)
podscript_prompt = speaker_id_info + "\n\n" + original_podscript_prompt + "\n\n" + custom_content
podscript_prompt = speaker_id_info + "\n\n" + custom_content + "\n\n" + original_podscript_prompt
return podscript_prompt, pod_users, voices, turn_pattern # Return voices for potential future use or consistency
def _generate_overview_content(api_key, base_url, model, overview_prompt, input_prompt):
"""Generates overview content using OpenAI CLI."""
print("\nGenerating overview with OpenAI CLI...")
def _generate_overview_content(api_key, base_url, model, overview_prompt, input_prompt, output_language: Optional[str] = None) -> Tuple[str, str, str]:
"""Generates overview content using OpenAI CLI, and extracts title and tags."""
print(f"\nGenerating overview with OpenAI CLI (Output Language: {output_language})...")
try:
openai_client_overview = OpenAICli(api_key=api_key, base_url=base_url, model=model, system_message=overview_prompt)
# Replace the placeholder with the actual output language
formatted_overview_prompt = overview_prompt.replace("{{outlang}}", output_language if output_language is not None else "Make sure the input language is set as the output language")
openai_client_overview = OpenAICli(api_key=api_key, base_url=base_url, model=model, system_message=formatted_overview_prompt)
overview_response_generator = openai_client_overview.chat_completion(messages=[{"role": "user", "content": input_prompt}])
overview_content = "".join([chunk.choices[0].delta.content for chunk in overview_response_generator if chunk.choices and chunk.choices[0].delta.content])
print("Generated Overview:")
print(overview_content[:100])
return overview_content
# Extract title (first line) and tags (second line)
lines = overview_content.strip().split('\n')
title = lines[0].strip() if len(lines) > 0 else ""
tags = lines[1].strip() if len(lines) > 1 else ""
print(f"Extracted Title: {title}")
print(f"Extracted Tags: {tags}")
return overview_content, title, tags
except Exception as e:
raise RuntimeError(f"Error generating overview: {e}")
@@ -476,15 +526,15 @@ def generate_podcast_audio():
input_prompt_content, overview_prompt, original_podscript_prompt = _read_prompt_files()
custom_content, input_prompt = _extract_custom_content(input_prompt_content)
podscript_prompt, pod_users, voices, turn_pattern = _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content)
podscript_prompt, pod_users, voices, turn_pattern = _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content, args.usetime, args.output_language)
print(f"\nInput Prompt (input.txt):\n{input_prompt[:100]}...")
print(f"\nOverview Prompt (prompt-overview.txt):\n{overview_prompt[:100]}...")
print(f"\nPodscript Prompt (prompt-podscript.txt):\n{podscript_prompt[:1000]}...")
overview_content = _generate_overview_content(api_key, base_url, model, overview_prompt, input_prompt)
overview_content, title, tags = _generate_overview_content(api_key, base_url, model, overview_prompt, input_prompt, args.output_language)
podcast_script = _generate_podcast_script(api_key, base_url, model, podscript_prompt, overview_content)
tts_adapter = _initialize_tts_adapter(config_data) # 初始化 TTS 适配器
audio_files = _generate_all_audio_files(podcast_script, config_data, tts_adapter, args.threads)
@@ -509,6 +559,7 @@ def generate_podcast_audio_api(args, config_path: str, input_txt_content: str, t
threads (int): Number of threads for audio generation.
config_path (str): Path to the configuration JSON file.
input_txt_content (str): Content of the input prompt.
output_language (str): Language for the podcast overview and script (default: Chinese).
Returns:
str: The path to the generated audio file.
@@ -521,13 +572,15 @@ def generate_podcast_audio_api(args, config_path: str, input_txt_content: str, t
final_api_key, final_base_url, final_model = _prepare_openai_settings(args, config_data)
input_prompt, overview_prompt, original_podscript_prompt = _read_prompt_files()
custom_content, input_prompt = _extract_custom_content(input_txt_content)
podscript_prompt, pod_users, voices, turn_pattern = _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content)
# Assuming `output_language` is passed directly to the function
podscript_prompt, pod_users, voices, turn_pattern = _prepare_podcast_prompts(config_data, original_podscript_prompt, custom_content, args.usetime, args.output_language)
print(f"\nInput Prompt (from provided content):\n{input_prompt[:100]}...")
print(f"\nOverview Prompt (prompt-overview.txt):\n{overview_prompt[:100]}...")
print(f"\nPodscript Prompt (prompt-podscript.txt):\n{podscript_prompt[:1000]}...")
overview_content = _generate_overview_content(final_api_key, final_base_url, final_model, overview_prompt, input_prompt)
overview_content, title, tags = _generate_overview_content(final_api_key, final_base_url, final_model, overview_prompt, input_prompt, args.output_language)
podcast_script = _generate_podcast_script(final_api_key, final_base_url, final_model, podscript_prompt, overview_content)
tts_adapter = _initialize_tts_adapter(config_data, tts_providers_config_content) # 初始化 TTS 适配器
@@ -536,11 +589,22 @@ def generate_podcast_audio_api(args, config_path: str, input_txt_content: str, t
_create_ffmpeg_file_list(audio_files)
output_audio_filepath = merge_audio_files()
audio_duration_seconds = get_audio_duration(os.path.join(output_dir, output_audio_filepath))
formatted_duration = "00:00"
if audio_duration_seconds is not None:
minutes = int(audio_duration_seconds // 60)
seconds = int(audio_duration_seconds % 60)
formatted_duration = f"{minutes:02}:{seconds:02}"
task_results = {
"output_audio_filepath": output_audio_filepath,
"overview_content": overview_content,
"podcast_script": podcast_script,
"podUsers": podUsers,
"audio_duration": formatted_duration,
"title": title,
"tags": tags,
}
return task_results

View File

@@ -1,3 +1,9 @@
<Additional Customizations>
- {{outlang}}
- Based on the key content of the document, without using content from titles or subtitles, create a document summary of around 150 characters. Then, based on the summary's content, generate a title and place it on the first line of the output.
- Generate tags based on the document's content, without using the content from titles or subtitles, separated by the # symbol. Generate 3-5 tags. The tags should not be summative; they should be excerpted from words within the document and placed on the second line of the output.
</Additional Customizations>
<INSTRUCTIONS>
<context>
You are an expert document analyst and summarization specialist tasked with distilling complex information into clear,
@@ -60,7 +66,6 @@
- Structure content with clear hierarchy and organization
- Avoid jargon and overly technical language
- Include transition sentences between sections
- Make sure the input language is set as the output language
</style>
</output_format>
@@ -84,6 +89,6 @@
- Adjust level of detail based on information density and importance
- Ensure key concepts receive adequate coverage regardless of length
</length_guidelines>
Now, create a summary of the following document:
</INSTRUCTIONS>

View File

@@ -1,3 +1,6 @@
* **Output Format:** No explanatory text{{outlang}}
* **End Format:** Before concluding, review and summarize the previous speeches, which are concise, concise, powerful and thought-provoking.
<podcast_generation_system>
You are a master podcast scriptwriter, adept at transforming diverse input content into a lively, engaging, and natural-sounding conversation between multiple distinct podcast hosts. Your primary objective is to craft authentic, flowing dialogue that captures the spontaneity and chemistry of a real group discussion, completely avoiding any hint of robotic scripting or stiff formality. Think dynamic group interplay, not just information delivery.
@@ -54,20 +57,13 @@ You are a master podcast scriptwriter, adept at transforming diverse input conte
6. **Length & Pacing:**
* **Target Duration:** Create a transcript that would result in approximately 5-6 minutes of audio (around 800-1000 words total).
* **Target Duration:** Create a transcript that would result in approximately {{usetime}} of audio (around 800-1000 words total).
* **Balanced Speaking Turns:** Aim for a natural conversational flow among speakers rather than extended monologues by one person. Prioritize the most important information from the source content.
7. **Copy & Replacement:**
If a hyphen connects English letters and numbers or letters on both sides, replace it with a space.
If a hyphen has numbers on both sides, replace it with '减'.
If a hyphen has a percent sign or '%' on its left and a number on its right, replace it with '到'.
Replace four-digit Arabic numerals with their Chinese character equivalents, one-to-one.
8. **Personalized & Output:**
* **Output Format:** No explanatory textMake sure the input language is set as the output language
* **End Format:** Before concluding, review and summarize the previous speeches, which are concise, concise, powerful and thought-provoking.
</guidelines>
<examples>

242
web/.claude/agents/coder.md Normal file
View File

@@ -0,0 +1,242 @@
---
name: coder
description: next-js专家
model: sonnet
---
### 角色与目标 (Role & Goal)
你是一位世界顶级的 Next.js 全栈开发专家,对**前端布局健壮性**和**性能优化**有着深刻的理解和丰富的实践经验。你的核心任务是作为我的技术伙伴,以结对编程的方式,指导我从零开始构建一个**性能卓越且视觉上无懈可击的产品展示网站**。
你的所有回答都必须严格遵循以下核心原则与强制性约束。
---
### 核心原则与强制性约束 (Core Principles & Mandatory Constraints)
1. **技术栈 (Tech Stack)**:
* **框架**: Next.js (始终使用最新的稳定版本,并优先采用 App Router 架构)。
* **语言**: **TypeScript**。所有代码必须是类型安全的,并充分利用 TypeScript 的特性。
* **样式**: **Tailwind CSS**。用于所有组件的样式设计遵循其效用优先utility-first的理念。
2. **代码质量与规范 (Code Quality & Style)**:
* **代码风格**: 严格遵循 **Vercel 的代码风格指南**和 Prettier 的默认配置。代码必须整洁、可读性强且易于维护。
* **架构**: 采用清晰的、基于组件的架构。将页面、组件、hooks、工具函数等分离到合理的目录结构中。强调组件的可复用性和单一职责原则。
3. **性能第一 (Performance First)**:
* **核心指标**: 你的首要目标是最大化 **Lighthouse 分数**,并最小化**首次内容绘制 (FCP)** 和**最大内容绘制 (LCP)** 时间。
* **实践**:
* **默认服务端**: 尽可能使用**服务器组件 (Server Components)**。
* **图片优化**: 必须使用 `next/image` 组件处理所有图片。
* **字体优化**: 必须使用 `next/font` 来加载和优化网页字体。
* **动态加载**: 对非关键组件使用 `next/dynamic` 进行代码分割和懒加载。
* **数据获取**: 根据场景选择最优的数据获取策略。
4. **布局与视觉约束 (Layout & Visual Constraints)**:
* **健壮的容器**: **所有元素都严禁超出其父容器的边界**。你的布局设计必须从根本上杜绝水平滚动条的出现。
* **智能文本处理**: 对于文本元素,必须根据上下文做出恰当处理:
* 在空间充足的容器中(如文章正文),文本必须能**自动换行** (`break-words`)。
* 在空间有限的组件中(如卡片标题),必须采用**文本截断**策略,在末尾显示省略号 (`truncate`)。
* **响应式媒体**: 对于图片、视频等非文本资源,必须在其容器内**被完整显示**。它们应能响应式地缩放以适应容器大小,同时保持其原始高宽比,不得出现裁剪或变形(除非是刻意设计的背景图)。
---
### 互动与输出格式 (Interaction & Output Format)
对于我的每一个功能或组件请求,你都必须按照以下结构进行回应:
1. **简要确认**: 首先,简要复述我的请求,确认你的理解。
2. **代码实现**:
* 提供完整、可直接使用的 `.tsx``.ts` 代码块。
* 在代码中加入必要的注释,解释关键逻辑或复杂部分。
3. **解释与最佳实践**:
* 在代码块之后,使用标题 `### 方案解读`
* 清晰地解释你这样设计的原因,特别是要**关联到上述的所有核心原则**(技术栈、性能、代码质量、布局约束等)。
4. **主动建议 (Proactive Suggestions)**:
* 如果我的请求有更优、更高效或性能更好的实现方式,请主动提出并给出你的建议方案。
---
最近生成的小卡片布局和样式如下:
### 一、 整体布局与结构 (Layout & Structure)
该卡片采用**多区域组合布局**,将不同类型的信息有机地组织在一个紧凑的空间内。
1. **外部容器**: 一个白色的圆角矩形,作为所有元素的载体。
2. **上部内容区**: 采用**双栏布局**。
* **左栏**: 放置一个方形的缩略图 (Thumbnail),作为视觉吸引点。
* **右栏**: 垂直排列的文本信息区,包含标题和作者信息。
3. **下部信息/操作区**: 同样是**双栏布局**,与上部内容区通过垂直间距分隔开。
* **左栏**: 显示元数据 (Metadata),如播放量和时长。
* **右栏**: 放置一个主要的交互按钮(播放按钮)。
这种布局方式既高效又符合用户的阅读习惯(从左到右,从上到下),使得信息层级一目了然。
### 二、 颜色与风格 (Color & Style)
色彩搭配既有现代感又不失柔和,体现了专业与创意的结合。
* **主背景色**: **白色 (`#FFFFFF`)**,为卡片提供了干净、明亮的基底。
* **边框色**: **极浅灰色 (`#E5E7EB` 或类似)**用一个1像素的细边框勾勒出卡片的轮廓使其在白色背景上也能清晰可见。
* **缩略图主色调**: 采用**柔和的渐变色**,由**淡紫色 (`#C8B6F2`)**、**薰衣草色 (`#D7BDE2`)** 过渡到**淡粉色 (`#E8D7F1`)**,并带有抽象的、类似极光的模糊波纹效果。这种色彩营造了一种梦幻、创意的氛围。
* **文字颜色**:
* **标题**: **纯黑或深炭灰色 (`#111827`)**,确保了最高的可读性。
* **作者名与元数据**: **中度灰色 (`#6B7280`)**,与标题形成对比,属于次要信息。
* **功能性颜色**:
* **作者头像背景**: **深洋红色/玫瑰色 (`#C72C6A` 或类似)**,这是一个醒目的强调色,用于用户身份标识。
* **播放按钮**: **黑色 (`#111827`)**,作为核心操作,颜色非常突出。
### 三、 组件细节解析 (Component Breakdown)
1. **卡片容器 (Card Container)**:
* **形状**: 圆角矩形,`border-radius` 大约在 `12px``16px` 之间,显得非常圆润友好。
* **边框**: `border: 1px solid #E5E7EB;`
* **内边距 (Padding)**: 卡片内容与边框之间有足够的留白,推测 `padding` 约为 `16px`
2. **缩略图 (Thumbnail)**:
* **形状**: 轻微圆角的正方形,`border-radius``8px`
* **内容**: 上文描述的抽象渐变背景。
* **尺寸**: 在卡片中占据了显著的视觉比重。
3. **标题 (Title)**:
* **文本**: "AI大观竞技、创作与前沿突破的最新图景"。
* **字体**: 无衬线字体,字重为**中粗体 (Semibold, `font-weight: 600`)**,字号较大(推测约 `16px`),保证了标题的突出性。
4. **作者信息 (Author Info)**:
* **布局**: 水平 `flex` 布局,包含头像和用户名,两者之间有一定间距 (`gap: 8px`)。
* **头像 (Avatar)**:
* 一个正圆形容器。
* 背景色为醒目的洋红色。
* 内部是白色的首字母 "d",字体居中。
* **用户名**: "dodo jack",使用中灰色、常规字重的文本。
5. **元数据 (Metadata)**:
* **布局**: 水平 `flex` 布局,`align-items: center`
* **元素**:
* **耳机图标**: 一个线性图标,表示收听量。
* **收听量**: "1"。
* **分隔符**: 一个细长的竖线 `|`,用于区隔不同信息。
* **时长**: "14 分钟"。
* **时钟图标**: 一个线性图标,表示时长。
* **样式**: 图标和文字均为中灰色,字号较小(推测约 `12px``14px`)。
6. **播放按钮 (Play Button)**:
* **形状**: 一个圆形按钮。
* **样式**: 由一个黑色的圆形**边框**和一个居中的实心**三角形播放图标**组成。设计非常简洁,辨识度高。
* **交互**: 可以推测,当鼠标悬停 (hover) 时,按钮可能会有背景色填充、放大或发光等效果。
### 四、 推测的CSS样式
```css
.content-card {
background-color: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px; /* 上下区域的间距 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 320px; /* 示例宽度 */
}
.card-top {
display: flex;
gap: 16px; /* 图片和文字的间距 */
align-items: flex-start;
}
.thumbnail {
width: 72px;
height: 72px;
border-radius: 8px;
background: linear-gradient(135deg, #C8B6F2, #E8D7F1); /* 示例渐变 */
flex-shrink: 0;
}
.text-content {
display: flex;
flex-direction: column;
gap: 8px; /* 标题和作者的间距 */
}
.title {
font-size: 16px;
font-weight: 600;
color: #111827;
line-height: 1.4;
}
.author {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #C72C6A;
color: #FFFFFF;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 500;
}
.author-name {
font-size: 14px;
color: #6B7280;
}
.card-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.metadata {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6B7280;
}
.metadata .icon {
width: 16px;
height: 16px;
}
.metadata .separator {
color: #D1D5DB;
}
.play-button {
width: 32px;
height: 32px;
border: 1.5px solid #111827;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
}
.play-button:hover {
transform: scale(1.1);
background-color: #F3F4F6;
}
.play-icon {
/* 使用SVG或字体图标 */
width: 12px;
height: 12px;
fill: #111827;
margin-left: 2px; /* 视觉居中校正 */
}

View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(npx create-next-app:*)",
"Bash(npm run type-check)",
"Bash(npm run test-build)",
"Bash(npm run build)",
"Bash(npm run lint)",
"Bash(timeout:*)",
"Bash(npm run dev)",
"Bash(node:*)"
],
"deny": []
}
}

15
web/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# 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

7
web/.eslintrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-img-element": "off",
"react/no-unescaped-entities": "off"
}
}

45
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
Thumbs.db

165
web/README.md Normal file
View File

@@ -0,0 +1,165 @@
# PodcastHub Web Application
一个现代化的播客生成Web应用基于Next.js构建集成了现有的Python播客生成器。
## 功能特性
- 🎙️ **AI播客生成**: 使用OpenAI API生成高质量播客内容
- 🎵 **多TTS支持**: 支持多种文本转语音服务
- 🎨 **现代化UI**: 基于Tailwind CSS的响应式设计
- 📱 **移动端友好**: 完全响应式的用户界面
- 🔄 **实时进度**: 实时显示播客生成进度
- 🎧 **内置播放器**: 支持音频播放、下载和分享
- 📚 **内容管理**: 播客库和探索功能
## 技术栈
- **框架**: Next.js 14 (App Router)
- **语言**: TypeScript
- **样式**: Tailwind CSS
- **图标**: Lucide React
- **动画**: Framer Motion
- **后端集成**: Python播客生成器
## 项目结构
```
web2/
├── src/
│ ├── app/ # Next.js App Router页面
│ │ ├── api/ # API路由
│ │ ├── globals.css # 全局样式
│ │ ├── layout.tsx # 根布局
│ │ └── page.tsx # 主页
│ ├── components/ # React组件
│ │ ├── Sidebar.tsx # 侧边栏导航
│ │ ├── PodcastCreator.tsx # 播客创建器
│ │ ├── PodcastCard.tsx # 播客卡片
│ │ ├── ContentSection.tsx # 内容展示区
│ │ ├── AudioPlayer.tsx # 音频播放器
│ │ └── ProgressModal.tsx # 进度模态框
│ ├── lib/ # 工具函数
│ ├── types/ # TypeScript类型定义
│ └── hooks/ # 自定义Hooks
├── public/ # 静态资源
├── package.json # 项目依赖
├── tailwind.config.js # Tailwind配置
├── tsconfig.json # TypeScript配置
└── next.config.js # Next.js配置
```
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 环境配置
复制环境变量模板:
```bash
cp .env.example .env.local
```
编辑 `.env.local` 文件,配置必要的环境变量:
```env
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-3.5-turbo
```
### 3. 启动开发服务器
```bash
npm run dev
```
应用将在 [http://localhost:3000](http://localhost:3000) 启动。
## API集成
### 播客生成API
- **POST** `/api/generate-podcast` - 启动播客生成任务
- **GET** `/api/generate-podcast?id={taskId}` - 查询生成进度
### 音频服务API
- **GET** `/api/audio/{filename}` - 获取音频文件(支持流式播放)
## 与Python后端集成
Web应用通过以下方式与现有Python播客生成器集成
1. **输入处理**: 将用户输入写入 `../input.txt`
2. **进程管理**: 使用Node.js子进程启动Python脚本
3. **进度监控**: 解析Python脚本输出来更新进度
4. **文件服务**: 提供生成的音频文件访问
## 开发指南
### 组件开发
所有组件都使用TypeScript编写遵循以下原则
- 使用函数式组件和Hooks
- 严格的类型定义
- 响应式设计优先
- 性能优化memo、useMemo等
### 样式规范
- 使用Tailwind CSS工具类
- 遵循设计系统的颜色和间距
- 支持深色模式(预留)
- 移动端优先的响应式设计
### 性能优化
- 使用Next.js Image组件优化图片
- 代码分割和懒加载
- 服务端组件优先
- 音频流式播放支持
## 部署
### 开发环境
```bash
npm run dev
```
### 生产构建
```bash
npm run build
npm start
```
### 类型检查
```bash
npm run type-check
```
### 代码检查
```bash
npm run lint
```
## 贡献指南
1. Fork项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开Pull Request
## 许可证
本项目采用MIT许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。

13
web/next.config.js Normal file
View File

@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['localhost'],
formats: ['image/webp', 'image/avif'],
},
// 优化性能
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
};
module.exports = nextConfig;

7236
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "podcasthub-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"setup": "node scripts/setup.js",
"test-build": "node test-build.js"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.3.8",
"lucide-react": "^0.424.0",
"next": "^14.2.5",
"postcss": "^8.4.40",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"socket.io-client": "^4.7.5",
"tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"use-debounce": "^10.0.5"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

75
web/scripts/setup.js Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
console.log('🚀 ListenHub Web应用设置向导\n');
// 检查Node.js版本
const nodeVersion = process.version;
const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]);
if (majorVersion < 18) {
console.error('❌ 需要Node.js 18或更高版本');
console.error(` 当前版本: ${nodeVersion}`);
process.exit(1);
}
console.log('✅ Node.js版本检查通过');
// 检查Python环境
try {
const pythonVersion = execSync('python --version', { encoding: 'utf8' });
console.log(`✅ Python环境: ${pythonVersion.trim()}`);
} catch (error) {
console.error('❌ 未找到Python环境请确保Python已安装并在PATH中');
process.exit(1);
}
// 检查父目录中的Python脚本
const pythonScriptPath = path.join(__dirname, '../../podcast_generator.py');
if (!fs.existsSync(pythonScriptPath)) {
console.error('❌ 未找到podcast_generator.py脚本');
console.error(` 期望路径: ${pythonScriptPath}`);
process.exit(1);
}
console.log('✅ Python播客生成器脚本找到');
// 创建环境变量文件
const envPath = path.join(__dirname, '../.env.local');
if (!fs.existsSync(envPath)) {
const envExample = path.join(__dirname, '../.env.example');
if (fs.existsSync(envExample)) {
fs.copyFileSync(envExample, envPath);
console.log('✅ 已创建.env.local文件');
console.log('⚠️ 请编辑.env.local文件配置您的OpenAI API密钥');
}
} else {
console.log('✅ 环境配置文件已存在');
}
// 安装依赖
console.log('\n📦 安装依赖包...');
try {
execSync('npm install', { stdio: 'inherit', cwd: path.join(__dirname, '..') });
console.log('✅ 依赖安装完成');
} catch (error) {
console.error('❌ 依赖安装失败');
process.exit(1);
}
// 创建输出目录
const outputDir = path.join(__dirname, '../../output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
console.log('✅ 已创建输出目录');
}
console.log('\n🎉 设置完成!');
console.log('\n下一步');
console.log('1. 编辑 .env.local 文件配置您的OpenAI API密钥');
console.log('2. 运行 npm run dev 启动开发服务器');
console.log('3. 在浏览器中打开 http://localhost:3000');
console.log('\n享受使用ListenHub🎙');

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs';
export async function GET(
request: NextRequest,
{ params }: { params: { filename: string } }
) {
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 ext = path.extname(filename).toLowerCase();
const allowedExtensions = ['.wav', '.mp3', '.m4a', '.ogg'];
if (!allowedExtensions.includes(ext)) {
return NextResponse.json(
{ error: '不支持的文件类型' },
{ status: 400 }
);
}
// 读取文件
const fileBuffer = fs.readFileSync(filePath);
// 设置适当的Content-Type
let contentType = 'audio/wav';
switch (ext) {
case '.mp3':
contentType = 'audio/mpeg';
break;
case '.m4a':
contentType = 'audio/mp4';
break;
case '.ogg':
contentType = 'audio/ogg';
break;
default:
contentType = 'audio/wav';
}
// 获取文件统计信息
const stats = fs.statSync(filePath);
// 处理Range请求支持音频流播放
const range = request.headers.get('range');
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
const chunksize = (end - start) + 1;
const fileStream = fs.createReadStream(filePath, { start, end });
return new NextResponse(fileStream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize.toString(),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
},
});
}
// 返回完整文件
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: '服务器内部错误' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
import type { TTSConfig } from '@/types';
// 获取配置文件列表
export async function GET() {
try {
const configDir = path.join(process.cwd(), '..', 'config');
const files = await fs.readdir(configDir);
const configFiles = files
.filter(file => file.endsWith('.json') && !file.includes('tts_providers'))
.map(file => ({
name: file,
displayName: file.replace('.json', ''),
path: file,
}));
return NextResponse.json({
success: true,
data: configFiles,
});
} catch (error) {
console.error('Error reading config directory:', error);
return NextResponse.json(
{ success: false, error: '无法读取配置目录' },
{ status: 500 }
);
}
}
// 获取特定配置文件内容
export async function POST(request: NextRequest) {
try {
const { configFile } = await request.json();
if (!configFile || !configFile.endsWith('.json')) {
return NextResponse.json(
{ success: false, error: '无效的配置文件名' },
{ status: 400 }
);
}
const configPath = path.join(process.cwd(), '..', 'config', configFile);
const configContent = await fs.readFile(configPath, 'utf-8');
const config: TTSConfig = JSON.parse(configContent);
return NextResponse.json({
success: true,
data: config,
});
} catch (error) {
console.error('Error reading config file:', error);
return NextResponse.json(
{ success: false, error: '无法读取配置文件' },
{ status: 500 }
);
}
}

View File

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

View File

@@ -0,0 +1,45 @@
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 }
);
}
}

149
web/src/app/globals.css Normal file
View File

@@ -0,0 +1,149 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* 品牌色彩变量 */
--brand-purple: #A076F9;
--brand-purple-hover: #8b5cf6;
--brand-pink: #E893CF;
--radius: 0.5rem;
}
html,
body {
min-height: 100%;
overflow-x: hidden; /* 防止水平滚动条 */
}
body {
@apply bg-white text-black font-sans;
font-feature-settings: "rlig" 1, "calt" 1;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-neutral-100;
}
::-webkit-scrollbar-thumb {
@apply bg-neutral-300 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-400;
}
/* 音频播放器样式 */
audio::-webkit-media-controls-panel {
background-color: white;
}
/* 渐变背景工具类 */
.gradient-brand {
background: linear-gradient(135deg, var(--brand-purple) 0%, var(--brand-pink) 100%);
}
/* 文本渐变 */
.text-gradient-brand {
background: linear-gradient(135deg, var(--brand-purple) 0%, var(--brand-pink) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 卡片悬浮效果 */
.card-hover {
@apply transition-all duration-200 ease-in-out;
}
.card-hover:hover {
@apply transform -translate-y-1 shadow-large;
}
/* 文本截断工具类 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* 毛玻璃效果 */
.backdrop-blur-sm {
backdrop-filter: blur(4px);
}
.backdrop-blur {
backdrop-filter: blur(8px);
}
}
@layer components {
/* 按钮样式 */
.btn-primary {
@apply bg-black text-white px-6 py-3 rounded-full font-medium transition-all duration-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2;
}
.btn-secondary {
@apply bg-white text-black border border-neutral-200 px-6 py-3 rounded-full font-medium transition-all duration-200 hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-200 focus:ring-offset-2;
}
/* 输入框样式 */
.input-primary {
@apply w-full px-4 py-3 border border-neutral-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent transition-all duration-200;
}
/* 导航项样式 */
.nav-item {
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-neutral-600 hover:text-black hover:bg-neutral-50 transition-all duration-200;
}
.nav-item.active {
@apply bg-white text-black shadow-soft;
}
.container {
@apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl; /* Adjust max-width as needed */
}
}
/* 响应式断点优化 */
@media (max-width: 768px) {
/* No specific rules here anymore, as .container is handled by Tailwind */
}
/* 深色模式支持(预留) */
@media (prefers-color-scheme: dark) {
/* 深色模式样式将在未来版本中添加 */
}

52
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,52 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = {
title: 'PodcastHub - 把你的创意转为播客',
description: '使用AI技术将您的想法和内容转换为高质量的播客音频支持多种语音和风格选择。',
keywords: ['播客', 'AI', '语音合成', 'TTS', '音频生成'],
authors: [{ name: 'PodcastHub Team' }],
viewport: 'width=device-width, initial-scale=1',
themeColor: '#000000',
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png',
},
openGraph: {
title: 'PodcastHub - 把你的创意转为播客',
description: '使用AI技术将您的想法和内容转换为高质量的播客音频',
type: 'website',
locale: 'zh_CN',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={inter.variable}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
</body>
</html>
);
}

302
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,302 @@
'use client';
import React, { useState, useEffect } from 'react';
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';
// 模拟数据
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: ['健康', '生活', '养生'],
},
];
export default function HomePage() {
const { toasts, success, error, warning, info, removeToast } = useToast();
const [uiState, setUIState] = useState<UIState>({
sidebarCollapsed: false,
currentView: 'home',
theme: 'light',
});
const [mobileSidebarOpen, setMobileSidebarOpen] = 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 [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null);
// 进度模态框状态
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
}, []);
const handleViewChange = (view: string) => {
setUIState(prev => ({ ...prev, currentView: view as UIState['currentView'] }));
};
const handleToggleSidebar = () => {
setUIState(prev => ({ ...prev, sidebarCollapsed: !prev.sidebarCollapsed }));
};
const handleToggleMobileSidebar = () => {
setMobileSidebarOpen(prev => !prev);
};
const handlePodcastGeneration = async (request: PodcastGenerationRequest) => {
setIsGenerating(true);
try {
info('开始生成播客', '正在处理您的请求...');
const response = await fetch('/api/generate-podcast', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error('Failed to generate podcast');
}
const result = await response.json();
if (result.success) {
success('任务已创建', '播客生成任务已启动,请查看进度');
// 显示进度模态框
setProgressModal({
isOpen: true,
taskId: result.data.id,
});
} else {
throw new Error(result.error || 'Generation failed');
}
} catch (err) {
console.error('Error generating podcast:', err);
error('生成失败', err instanceof Error ? err.message : '未知错误');
} finally {
setIsGenerating(false);
}
};
const handlePlayPodcast = (podcast: PodcastItem) => {
setCurrentPodcast(podcast);
};
const handleProgressComplete = (result: PodcastGenerationResponse) => {
// 生成完成后,可以将新的播客添加到库中
if (result.audioUrl) {
success('播客生成完成!', '您的播客已成功生成并添加到资料库');
const newPodcast: PodcastItem = {
id: result.id,
title: result.script?.title || '新生成的播客',
description: '使用AI生成的播客内容',
thumbnail: '',
author: {
name: '我',
avatar: '',
},
duration: result.script?.totalDuration || 0,
playCount: 0,
createdAt: result.createdAt,
audioUrl: result.audioUrl,
tags: ['AI生成'],
};
setLibraryPodcasts(prev => [newPodcast, ...prev]);
// 自动播放新生成的播客
setCurrentPodcast(newPodcast);
} else {
warning('生成完成但无音频', '播客生成过程完成,但未找到音频文件');
}
};
const renderMainContent = () => {
switch (uiState.currentView) {
case 'home':
return (
<div className="space-y-12">
{/* 播客创建器 */}
<PodcastCreator
onGenerate={handlePodcastGeneration}
isGenerating={isGenerating}
credits={credits} // 将积分传递给PodcastCreator
/>
{/* 最近生成 - 紧凑布局 */}
<ContentSection
title="最近生成"
subtitle="数据只保留30分钟请尽快下载保存"
items={explorePodcasts}
onPlayPodcast={handlePlayPodcast}
variant="compact"
layout="grid"
/>
{/* 推荐播客 - 水平滚动 */}
{/* <ContentSection
title="为你推荐"
items={[...libraryPodcasts, ...explorePodcasts].slice(0, 6)}
onPlayPodcast={handlePlayPodcast}
variant="default"
layout="horizontal"
/> */}
</div>
);
case 'settings':
return (
<SettingsForm
onSuccess={(message) => success('保存成功', message)}
onError={(message) => error('保存失败', message)}
/>
);
default:
return (
<div className="max-w-4xl mx-auto px-6 text-center py-12">
<h1 className="text-2xl font-bold text-black mb-4"></h1>
<p className="text-neutral-600"></p>
</div>
);
}
};
return (
<div className="flex min-h-screen bg-white">
{/* 侧边栏 */}
<Sidebar
currentView={uiState.currentView}
onViewChange={handleViewChange}
collapsed={uiState.sidebarCollapsed}
onToggleCollapse={handleToggleSidebar}
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"
onClick={handleToggleMobileSidebar}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-black"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 移动端侧边栏遮罩 */}
{mobileSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
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">
{renderMainContent()}
</div>
</main>
{/* 音频播放器 */}
{currentPodcast && (
<AudioPlayer
podcast={currentPodcast}
/>
)}
{/* 进度模态框 */}
<ProgressModal
taskId={progressModal.taskId || ''}
isOpen={progressModal.isOpen}
onClose={() => setProgressModal({ isOpen: false, taskId: null })}
onComplete={handleProgressComplete}
/>
{/* Toast通知容器 */}
<ToastContainer
toasts={toasts}
onRemove={removeToast}
/>
</div>
);
}

View File

@@ -0,0 +1,366 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import {
Play,
Pause,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Download,
Share2,
ChevronDown, // 用于收起播放器
ChevronUp, // 用于展开播放器
} from 'lucide-react';
import { cn, formatTime, downloadFile } from '@/lib/utils';
import AudioVisualizer from './AudioVisualizer';
import { useIsSmallScreen } from '@/hooks/useMediaQuery'; // 导入新的 Hook
import type { AudioPlayerState, PodcastItem } from '@/types';
interface AudioPlayerProps {
podcast: PodcastItem;
className?: string;
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({
podcast,
className
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const [playerState, setPlayerState] = useState<AudioPlayerState>({
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
playbackRate: 1,
});
const [isCollapsed, setIsCollapsed] = useState(false); // 用户控制的折叠状态
const isSmallScreen = useIsSmallScreen(); // 获取小屏幕状态
// 定义一个“生效的”折叠状态,它会服从 isSmallScreen 的约束
const effectiveIsCollapsed = isSmallScreen ? true : isCollapsed;
const [isLoading, setIsLoading] = useState(true);
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleLoadedMetadata = () => {
setPlayerState(prev => ({ ...prev, duration: audio.duration }));
setIsLoading(false);
};
const handleTimeUpdate = () => {
setPlayerState(prev => ({ ...prev, currentTime: audio.currentTime }));
};
const handleEnded = () => {
setPlayerState(prev => ({ ...prev, isPlaying: false, currentTime: 0 }));
};
const handleLoadStart = () => setIsLoading(true);
const handleCanPlay = () => setIsLoading(false);
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('loadstart', handleLoadStart);
audio.addEventListener('canplay', handleCanPlay);
// 自动播放音频(仅在用户交互后有效)
if (playerState.isPlaying) {
audio.play().catch(e => console.error("Audio play failed:", e));
}
return () => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('loadstart', handleLoadStart);
audio.removeEventListener('canplay', handleCanPlay);
};
}, []);
// 当播客URL变化时重置并加载新音频
useEffect(() => {
const audio = audioRef.current;
if (audio && podcast.audioUrl) {
// 停止当前播放
audio.pause();
// 重新设置src这将触发loadedmetadata事件
audio.src = podcast.audioUrl;
audio.load();
// 重置播放器状态
setPlayerState({
isPlaying: false,
currentTime: 0,
duration: 0,
volume: audio.volume,
playbackRate: 1,
});
setIsLoading(true); // 开始加载,显示加载状态
setIsMuted(audio.muted || audio.volume === 0);
}
}, [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 }));
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const audio = audioRef.current;
const progressBar = progressRef.current;
if (!audio || !progressBar || playerState.duration === 0) return; // 确保有duration
const rect = progressBar.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const width = rect.width;
const newTime = (clickX / width) * playerState.duration;
audio.currentTime = newTime;
setPlayerState(prev => ({ ...prev, currentTime: newTime }));
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const newVolume = parseFloat(e.target.value);
audio.volume = newVolume;
setPlayerState(prev => ({ ...prev, volume: newVolume }));
setIsMuted(newVolume === 0);
};
const toggleMute = () => {
const audio = audioRef.current;
if (!audio) return;
if (isMuted) {
// 恢复到上次的音量如果上次是0则恢复到0.5
audio.volume = playerState.volume > 0 ? playerState.volume : 0.5;
setIsMuted(false);
} else {
audio.volume = 0;
setIsMuted(true);
}
};
const skipTime = (seconds: number) => {
const audio = audioRef.current;
if (!audio || playerState.duration === 0) return; // 确保有duration
const newTime = Math.max(0, Math.min(playerState.currentTime + seconds, playerState.duration));
audio.currentTime = newTime;
setPlayerState(prev => ({ ...prev, currentTime: newTime }));
};
const handleDownload = () => {
downloadFile(podcast.audioUrl, `${podcast.title}.wav`);
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: podcast.title,
text: podcast.description,
url: window.location.href,
});
} catch (err) {
console.log('Share cancelled', err);
}
} else {
// 降级到复制链接
await navigator.clipboard.writeText(window.location.href);
// 这里可以显示一个toast提示
alert('链接已复制到剪贴板!'); // 简单替代Toast
}
};
const progressPercentage = playerState.duration > 0
? (playerState.currentTime / playerState.duration) * 100
: 0;
return (
<div className={cn(
"fixed bottom-4 right-4 z-50 bg-white border-t border-neutral-200 p-2 flex items-center justify-between overflow-hidden rounded-xl shadow-large", // 固定定位到右下角
effectiveIsCollapsed ? "w-fit" : "max-w-screen-md", // 根据 effectiveIsCollapsed 调整宽度
"transition-all duration-300 ease-in-out", // 添加过渡效果
className
)}>
<audio
ref={audioRef}
src={podcast.audioUrl}
preload="metadata"
/>
{/* 左侧:播放按钮 & 播客信息 */}
<div className="flex items-center gap-2 flex-shrink-0">
<button
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 ? "暂停" : "播放"}
>
{isLoading ? (
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : playerState.isPlaying ? (
<Pause className="w-3 h-3" />
) : (
<Play className="w-3 h-3 ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0 flex items-center gap-2">
{/* 播客标题和作者 */}
<div className="min-w-0 flex-shrink-0">
<h3 className="font-semibold text-sm text-black truncate max-w-[150px]"> {/* 限制宽度,确保截断 */}
{podcast.title}
</h3>
{!effectiveIsCollapsed && ( // 根据 effectiveIsCollapsed 隐藏作者
<p className="text-xs text-neutral-600 truncate max-w-[150px]"> {/* 限制宽度,确保截断 */}
{podcast.author.name}
</p>
)}
</div>
{!effectiveIsCollapsed && ( // 根据 effectiveIsCollapsed 隐藏可视化器
<AudioVisualizer
audioElement={audioRef.current}
isPlaying={playerState.isPlaying}
className="flex-grow min-w-[50px] max-w-[150px]" // 响应式宽度,适应扁平布局
height={20} // 更扁平的高度
/>
)}
</div>
</div>
{/* 中间:进度条 & 时间 - 根据 effectiveIsCollapsed 隐藏 */}
{!effectiveIsCollapsed && (
<div className="flex flex-col flex-grow mx-4 min-w-[200px] max-w-[400px]"> {/* 占据中间大部分空间,确保宽 */}
<div
ref={progressRef}
onClick={handleProgressClick}
className="w-full h-1 bg-neutral-200 rounded-full cursor-pointer relative group" // 更窄的进度条
>
<div
className="h-full bg-black rounded-full transition-all duration-150 relative"
style={{ width: `${progressPercentage}%` }}
>
<div className="absolute right-0 top-1/2 transform translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-black rounded-full opacity-0 group-hover:opacity-100 transition-opacity" /> {/* 调整把手大小 */}
</div>
</div>
<div className="flex justify-between text-xs text-neutral-500 mt-1"> {/* 调整时间显示间距 */}
<span>{formatTime(playerState.currentTime)}</span>
<span>{formatTime(playerState.duration)}</span>
</div>
</div>
)}
{/* 右侧:控制按钮组 */}
<div className="flex items-center gap-2 flex-shrink-0">
{!effectiveIsCollapsed && ( // 根据 effectiveIsCollapsed 隐藏这些按钮
<>
<button
onClick={() => skipTime(-10)}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="后退10秒"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={() => skipTime(10)}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="前进10秒"
>
<SkipForward className="w-4 h-4" />
</button>
{/* 音量控制 */}
<div className="flex items-center gap-1">
<button
onClick={toggleMute}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title={isMuted ? "取消静音" : "静音"}
>
{isMuted || playerState.volume === 0 ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : playerState.volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-neutral-200 rounded-full appearance-none cursor-pointer" // 窄高的音量条
/>
</div>
{/* 操作按钮 */}
<button
onClick={handleShare}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="分享"
>
<Share2 className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="下载"
>
<Download className="w-4 h-4" />
</button>
</>
)}
{/* 收起/展开按钮 */}
<button
onClick={() => {
// 如果是小屏幕且当前已收起按钮显示ChevronUp试图展开则不执行任何操作。
if (isSmallScreen && effectiveIsCollapsed) {
return;
}
setIsCollapsed(prev => !prev);
}}
className={cn(
"p-1 text-neutral-400 hover:text-neutral-600 transition-colors flex-shrink-0",
{ "opacity-50 cursor-not-allowed": isSmallScreen && effectiveIsCollapsed } // 当 effectiveIsCollapsed 为 true 且是小屏幕时禁用 (因为此时按钮功能是展开,不允许)
)}
title={isSmallScreen && effectiveIsCollapsed ? "小于sm尺寸不可展开" : (effectiveIsCollapsed ? "展开播放器" : "收起播放器")}
disabled={isSmallScreen && effectiveIsCollapsed} // 当 effectiveIsCollapsed 为 true 且是小屏幕时禁用
>
{effectiveIsCollapsed ? ( // 根据 effectiveIsCollapsed 决定显示哪个图标
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
</div>
</div>
);
};
export default AudioPlayer;

View File

@@ -0,0 +1,147 @@
'use client';
import React, { useRef, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
interface AudioVisualizerProps {
audioElement?: HTMLAudioElement | null;
isPlaying?: boolean;
className?: string;
barCount?: number;
height?: number;
}
const AudioVisualizer: React.FC<AudioVisualizerProps> = ({
audioElement,
isPlaying = false,
className,
barCount = 20,
height = 40,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>();
const analyserRef = useRef<AnalyserNode | null>(null);
const dataArrayRef = useRef<Uint8Array | null>(null);
const [audioContext, setAudioContext] = useState<AudioContext | null>(null);
useEffect(() => {
if (!audioElement || !canvasRef.current) return;
// 创建音频上下文和分析器
const initAudioContext = async () => {
try {
const context = new (window.AudioContext || (window as any).webkitAudioContext)();
const analyser = context.createAnalyser();
const source = context.createMediaElementSource(audioElement);
source.connect(analyser);
analyser.connect(context.destination);
analyser.fftSize = 64;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
setAudioContext(context);
analyserRef.current = analyser;
dataArrayRef.current = dataArray;
} catch (error) {
console.warn('Audio visualization not supported:', error);
}
};
initAudioContext();
return () => {
if (audioContext) {
audioContext.close();
}
};
}, [audioElement]);
useEffect(() => {
if (!isPlaying || !analyserRef.current || !dataArrayRef.current || !canvasRef.current) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
return;
}
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const draw = () => {
if (!analyserRef.current || !dataArrayRef.current) return;
// @ts-ignore - Web Audio API类型兼容性问题
analyserRef.current.getByteFrequencyData(dataArrayRef.current);
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / barCount;
let x = 0;
// 绘制频谱条
for (let i = 0; i < barCount; i++) {
const barHeight = (dataArrayRef.current[i] / 255) * canvas.height;
// 创建渐变
const gradient = ctx.createLinearGradient(0, canvas.height, 0, canvas.height - barHeight);
gradient.addColorStop(0, '#A076F9');
gradient.addColorStop(1, '#E893CF');
ctx.fillStyle = gradient;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 2, barHeight);
x += barWidth;
}
animationRef.current = requestAnimationFrame(draw);
};
draw();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, barCount]);
// 静态波形(当没有播放时)
const renderStaticWave = () => {
const bars = [];
for (let i = 0; i < barCount; i++) {
const height = Math.random() * 0.7 + 0.1; // 10% - 80% 高度
bars.push(
<div
key={i}
className="bg-gradient-to-t from-brand-purple to-brand-pink rounded-sm opacity-30"
style={{
height: `${height * 100}%`,
width: `${100 / barCount - 1}%`,
}}
/>
);
}
return bars;
};
return (
<div className={cn("flex items-end justify-center gap-0.5", className)} style={{ height }}>
{analyserRef.current && isPlaying ? (
<canvas
ref={canvasRef}
width={200}
height={height}
className="w-full h-full"
/>
) : (
renderStaticWave()
)}
</div>
);
};
export default AudioVisualizer;

View File

@@ -0,0 +1,198 @@
'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';
interface ConfigFile {
name: string;
displayName: string;
path: string;
}
interface ConfigSelectorProps {
onConfigChange?: (config: TTSConfig, name: string) => void; // 添加 name 参数
className?: string;
}
const ConfigSelector: React.FC<ConfigSelectorProps> = ({
onConfigChange,
className
}) => {
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [selectedConfig, setSelectedConfig] = useState<string>('');
const [currentConfig, setCurrentConfig] = useState<TTSConfig | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// 检查TTS配置是否已设置
const isTTSConfigured = (configName: string): boolean => {
const settings = getItem<any>('podcast-settings');
if (!settings) return false;
const configKey = configName.replace('.json', '').split('-')[0];
// console.log('configKey', configKey);
switch (configKey) {
case 'index':
return !!(settings.index?.api_url);
case 'edge':
return !!(settings.edge?.api_url);
case 'doubao':
return !!(settings.doubao?.['X-Api-App-Id'] && settings.doubao?.['X-Api-Access-Key']);
case 'fish':
return !!(settings.fish?.api_key);
case 'minimax':
return !!(settings.minimax?.group_id && settings.minimax?.api_key);
case 'gemini':
return !!(settings.gemini?.api_key);
default:
return false;
}
};
// 加载配置文件列表
const loadConfigFiles = async () => {
try {
const response = await fetch('/api/config');
const result = await response.json();
if (result.success && Array.isArray(result.data)) {
// 过滤出已配置的TTS选项
const availableConfigs = result.data.filter((config: ConfigFile) =>
isTTSConfigured(config.name)
);
setConfigFiles(availableConfigs);
// 默认选择第一个可用配置
if (availableConfigs.length > 0 && !selectedConfig) {
setSelectedConfig(availableConfigs[0].name);
loadConfig(availableConfigs[0].name);
} else if (availableConfigs.length === 0) {
// 如果没有可用配置,清空当前选择
setSelectedConfig('');
setCurrentConfig(null);
onConfigChange?.(null as any, '');
}
} else {
console.error('Invalid config files data:', result);
setConfigFiles([]);
}
} catch (error) {
console.error('Failed to load config files:', error);
setConfigFiles([]);
}
};
useEffect(() => {
loadConfigFiles();
}, []);
// 监听localStorage变化重新加载配置
useEffect(() => {
const handleStorageChange = () => {
loadConfigFiles();
};
window.addEventListener('storage', handleStorageChange);
// 也监听自定义事件,用于同一页面内的设置更新
window.addEventListener('settingsUpdated', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('settingsUpdated', handleStorageChange);
};
}, [selectedConfig]);
// 加载特定配置文件
const loadConfig = async (configFile: string) => {
setIsLoading(true);
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ configFile }),
});
const result = await response.json();
if (result.success) {
setCurrentConfig(result.data);
onConfigChange?.(result.data, configFile); // 传递 configFile 作为 name
}
} catch (error) {
console.error('Failed to load config:', error);
} finally {
setIsLoading(false);
}
};
const handleConfigSelect = (configFile: string) => {
setSelectedConfig(configFile);
setIsOpen(false);
loadConfig(configFile);
};
const selectedConfigFile = Array.isArray(configFiles) ? configFiles.find(f => f.name === selectedConfig) : null;
return (
<div>
{/* 配置选择器 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="px-4 py-2 rounded-lg text-sm btn-secondary w-full"
disabled={isLoading}
>
{/* <Settings className="w-4 h-4 text-neutral-500" /> */}
<span className="flex-1 text-left text-sm">
{isLoading ? '加载中...' : selectedConfigFile?.displayName || (configFiles.length === 0 ? '请先配置TTS' : '选择TTS配置')}
</span>
{/* <ChevronDown className={cn(
"w-4 h-4 text-neutral-400 transition-transform",
isOpen && "rotate-180"
)} /> */}
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className="absolute top-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-large z-50 max-h-60 overflow-y-auto">
{Array.isArray(configFiles) && configFiles.length > 0 ? configFiles.map((config) => (
<button
key={config.name}
onClick={() => handleConfigSelect(config.name)}
className="flex items-center gap-3 w-full px-4 py-3 text-left hover:bg-neutral-50 transition-colors"
>
<div className="flex-1">
<div className="font-medium text-sm text-black">
{config.displayName}
</div>
</div>
{selectedConfig === config.name && (
<Check className="w-4 h-4 text-green-500" />
)}
</button>
)) : (
<div className="px-4 py-3 text-sm text-neutral-500 text-center">
<div className="mb-1">TTS配置</div>
<div className="text-xs">TTS服务</div>
</div>
)}
</div>
)}
{/* 点击外部关闭下拉菜单 */}
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</div>
);
};
export default ConfigSelector;

View File

@@ -0,0 +1,238 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { ChevronRight } from 'lucide-react';
import PodcastCard from './PodcastCard';
import type { PodcastItem } from '@/types';
interface ContentSectionProps {
title: string;
subtitle?: string;
items: PodcastItem[];
onViewAll?: () => void;
onPlayPodcast?: (podcast: PodcastItem) => void;
loading?: boolean;
variant?: 'default' | 'compact';
layout?: 'grid' | 'horizontal';
}
const ContentSection: React.FC<ContentSectionProps> = ({
title,
subtitle,
items,
onViewAll,
onPlayPodcast,
loading = false,
variant = 'default',
layout = 'grid'
}) => {
if (loading) {
return (
<div className="max-w-6xl mx-auto px-6">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col">
<div className="h-8 w-32 bg-neutral-200 rounded animate-pulse" />
{subtitle && (
<div className="h-4 w-24 bg-neutral-200 rounded animate-pulse mt-1" />
)}
</div>
<div className="h-6 w-20 bg-neutral-200 rounded animate-pulse" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="bg-white border border-neutral-200 rounded-xl overflow-hidden">
<div className="aspect-square bg-neutral-200 animate-pulse" />
<div className="p-4 space-y-3">
<div className="h-5 bg-neutral-200 rounded animate-pulse" />
<div className="h-4 w-3/4 bg-neutral-200 rounded animate-pulse" />
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-neutral-200 rounded-full animate-pulse" />
<div className="h-4 w-20 bg-neutral-200 rounded animate-pulse" />
</div>
<div className="flex justify-between">
<div className="h-3 w-16 bg-neutral-200 rounded animate-pulse" />
<div className="h-3 w-12 bg-neutral-200 rounded animate-pulse" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="max-w-6xl mx-auto px-6">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col">
<h2 className="text-2xl font-bold text-black">{title}</h2>
{subtitle && (
<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"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
<div className="text-center py-12 text-neutral-500">
<p></p>
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* 标题栏 */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-2">
<div className="flex flex-col">
<h2 className="text-xl sm:text-2xl font-bold text-black">{title}</h2>
{subtitle && (
<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>
{/* 内容布局 */}
{layout === 'horizontal' ? (
// 水平滚动布局 - 隐藏滚动条并自动循环
<HorizontalScrollSection
items={items}
onPlayPodcast={onPlayPodcast}
variant={variant}
/>
) : (
// 网格布局
<div className={`grid justify-items-center ${ // 添加 justify-items-center 使网格项水平居中
variant === 'compact'
? 'gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
: 'gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3'
}`}>
{items.map((item) => (
<PodcastCard
key={item.id}
podcast={item}
onPlay={onPlayPodcast}
variant={variant}
/>
))}
</div>
)}
</div>
);
};
// 水平滚动组件 - 隐藏滚动条并自动循环
interface HorizontalScrollSectionProps {
items: PodcastItem[];
onPlayPodcast?: (podcast: PodcastItem) => void;
variant?: 'default' | 'compact';
}
const HorizontalScrollSection: React.FC<HorizontalScrollSectionProps> = ({
items,
onPlayPodcast,
variant = 'default'
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer || items.length === 0) return;
let scrollAmount = 0;
const cardWidth = variant === 'compact' ? 320 : 288; // w-80 = 320px, w-72 = 288px
const gap = 24; // gap-6 = 24px
const totalWidth = (cardWidth + gap) * items.length;
const scroll = () => {
scrollAmount += 0.5; // 减慢滚动速度
// 当滚动到末尾时重置到开始
if (scrollAmount >= totalWidth) {
scrollAmount = 0;
}
scrollContainer.scrollLeft = scrollAmount;
};
// 开始自动滚动
const startScrolling = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(scroll, 10); // 每30ms滚动0.5px
};
// 停止自动滚动
const stopScrolling = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// 鼠标悬停时暂停滚动
const handleMouseEnter = () => stopScrolling();
const handleMouseLeave = () => startScrolling();
scrollContainer.addEventListener('mouseenter', handleMouseEnter);
scrollContainer.addEventListener('mouseleave', handleMouseLeave);
// 开始滚动
startScrolling();
return () => {
stopScrolling();
if (scrollContainer) {
scrollContainer.removeEventListener('mouseenter', handleMouseEnter);
scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
}
};
}, [items.length, variant]);
// 复制items数组以实现无缝循环
const duplicatedItems = [...items, ...items];
return (
<div
ref={scrollRef}
className="overflow-x-hidden scrollbar-hide"
style={{
scrollbarWidth: 'none', // Firefox
msOverflowStyle: 'none', // IE/Edge
}}
>
<div className="flex gap-6 pb-4" style={{ width: 'max-content' }}>
{duplicatedItems.map((item, index) => (
<PodcastCard
key={`${item.id}-${index}`}
podcast={item}
onPlay={onPlayPodcast}
variant={variant}
className={`flex-shrink-0 ${
variant === 'compact' ? 'w-80' : 'w-72'
}`}
/>
))}
</div>
</div>
);
};
export default ContentSection;

View File

@@ -0,0 +1,240 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { Play, Clock, Eye, User, Heart, MoreHorizontal } from 'lucide-react';
import { cn, formatTime, formatRelativeTime } from '@/lib/utils';
import type { PodcastItem } from '@/types';
interface PodcastCardProps {
podcast: PodcastItem;
onPlay?: (podcast: PodcastItem) => void;
className?: string;
variant?: 'default' | 'compact';
}
const PodcastCard: React.FC<PodcastCardProps> = ({
podcast,
onPlay,
className,
variant = 'default'
}) => {
const [isLiked, setIsLiked] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const handlePlayClick = (e: React.MouseEvent) => {
e.stopPropagation();
onPlay?.(podcast);
};
const handleLikeClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsLiked(!isLiked);
};
const handleMoreClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 更多操作菜单
};
// 根据变体返回不同的布局
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",
"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="relative w-16 h-16 rounded-xl overflow-hidden bg-gradient-to-br from-brand-purple to-brand-pink flex-shrink-0">
{podcast.thumbnail ? (
<Image
src={podcast.thumbnail}
alt={podcast.title}
fill
className="object-cover"
sizes="64px"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-6 h-6 text-white" />
</div>
)}
{/* 播放按钮覆盖层 */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
<button
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" />
</button>
</div>
</div>
{/* 内容 */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-black text-base mb-1 truncate">
{podcast.title}
</h3>
<p className="text-sm text-neutral-600 mb-2 truncate">
{podcast.author.name}
</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">
<Eye className="w-3 h-3" />
{podcast.playCount.toLocaleString()}
</span>
</div>
</div>
</div>
</div>
);
}
// 默认变体
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",
"sm:max-w-md",
"md:max-w-lg",
"lg:max-w-xl",
className
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 缩略图区域 */}
<div className="relative aspect-square bg-gradient-to-br from-brand-purple to-brand-pink">
{podcast.thumbnail ? (
<Image
src={podcast.thumbnail}
alt={podcast.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
<Play className="w-8 h-8 text-white" />
</div>
</div>
)}
{/* 播放按钮覆盖层 */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300 flex items-center justify-center opacity-0 group-hover:opacity-100">
<button
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" />
</button>
</div>
{/* 右上角操作按钮 */}
<div className={cn(
"absolute top-3 right-3 flex gap-2 transition-all duration-300",
isHovered ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
)}>
<button
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"
: "bg-white/90 hover:bg-white text-neutral-600 hover:text-red-500"
)}
>
<Heart className={cn("w-4 h-4", isLiked && "fill-current")} />
</button>
<button
onClick={handleMoreClick}
className="w-8 h-8 bg-white/90 hover:bg-white rounded-full flex items-center justify-center text-neutral-600 hover:text-black transition-all duration-200 backdrop-blur-sm"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
{/* 时长标签 */}
<div className="absolute bottom-3 right-3 px-2 py-1 bg-black/70 text-white text-xs rounded-md backdrop-blur-sm">
{formatTime(podcast.duration)}
</div>
</div>
{/* 内容区域 */}
<div className="p-5">
{/* 标题 */}
<h3 className="font-semibold text-black text-lg mb-3 line-clamp-2 leading-tight group-hover:text-brand-purple transition-colors duration-200
sm:text-xl sm:mb-4">
{podcast.title}
</h3>
{/* 作者信息 */}
<div className="flex items-center gap-3 mb-4">
<div className="w-7 h-7 bg-neutral-200 rounded-full overflow-hidden flex-shrink-0">
{podcast.author.avatar ? (
<Image
src={podcast.author.avatar}
alt={podcast.author.name}
width={28}
height={28}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-neutral-300 flex items-center justify-center">
<User className="w-3.5 h-3.5 text-neutral-500" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-700 font-medium truncate">
{podcast.author.name}
</p>
</div>
</div>
{/* 元数据 */}
<div className="flex items-center gap-4 text-sm text-neutral-500 mb-4">
<div className="flex items-center gap-1.5">
<Eye className="w-4 h-4" />
<span>{podcast.playCount.toLocaleString()}</span>
</div>
<div className="w-1 h-1 bg-neutral-300 rounded-full"></div>
<span>{formatRelativeTime(podcast.createdAt)}</span>
</div>
{/* 标签 */}
{podcast.tags && podcast.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{podcast.tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="px-2.5 py-1 bg-neutral-100 hover:bg-neutral-200 text-neutral-700 text-xs rounded-full transition-colors cursor-pointer"
>
{tag}
</span>
))}
{podcast.tags.length > 2 && (
<span className="px-2.5 py-1 bg-neutral-100 text-neutral-600 text-xs rounded-full">
+{podcast.tags.length - 2}
</span>
)}
</div>
)}
</div>
</div>
);
};
export default PodcastCard;

View File

@@ -0,0 +1,357 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import {
Play,
Wand2,
Link,
Copy,
Upload,
Globe,
ChevronDown,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import ConfigSelector from './ConfigSelector';
import VoicesModal from './VoicesModal'; // 引入 VoicesModal
import type { PodcastGenerationRequest, TTSConfig, Voice } from '@/types';
interface PodcastCreatorProps {
onGenerate: (request: PodcastGenerationRequest) => void;
isGenerating?: boolean;
credits: number; // 新增积分属性
}
const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onGenerate,
isGenerating = false,
credits // 解构 credits 属性
}) => {
const [topic, setTopic] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const [selectedMode, setSelectedMode] = useState<'ai-podcast' | 'flowspeech'>('ai-podcast');
const [language, setLanguage] = useState('zh-CN');
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 [selectedConfig, setSelectedConfig] = useState<TTSConfig | null>(null);
const [selectedConfigName, setSelectedConfigName] = useState<string>(''); // 新增状态来存储配置文件的名称
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
if (!topic.trim()) 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);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
setTopic(prev => prev + (prev ? '\n\n' : '') + content);
};
reader.readAsText(file);
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
setTopic(prev => prev + (prev ? '\n\n' : '') + text);
} catch (err) {
console.error('Failed to read clipboard:', err);
}
};
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>
)}
</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"
/></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}
>
</button></div>
{/* 语言选择 */}
<div className="relative w-full">
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="appearance-none bg-white border border-neutral-200 rounded-lg px-3 py-2 sm:px-3 sm:py-2 pr-6 sm:pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-black w-full text-center"
disabled={isGenerating}
>
{languageOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-1 sm:right-2 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 text-neutral-400 pointer-events-none" />
</div>
{/* 时长选择 */}
<div className="relative w-full">
<select
value={duration}
onChange={(e) => setDuration(e.target.value as any)}
className="appearance-none bg-white border border-neutral-200 rounded-lg px-3 py-2 sm:px-3 sm:py-2 pr-6 sm:pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-black w-full text-center"
disabled={isGenerating}
>
{durationOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-1 sm:right-2 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 text-neutral-400 pointer-events-none" />
</div>
</div>
{/* 右侧操作按钮 */}
<div className="flex items-center gap-6 sm:gap-1 flex-wrap justify-center sm:justify-right w-full sm:w-auto">
{/* 文件上传 */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="上传文件"
disabled={isGenerating}
>
<Upload className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.doc,.docx"
onChange={handleFileUpload}
className="hidden"
/>
{/* 粘贴链接 */}
<button
onClick={handlePaste}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="粘贴内容"
disabled={isGenerating}
>
<Link className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{/* 复制 */}
<button
onClick={() => navigator.clipboard.writeText(topic)}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="复制内容"
disabled={isGenerating || !topic}
>
<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">
<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>
</div>
<div className="flex flex-col items-center gap-1">
{/* 创作按钮 */}
<button
onClick={handleSubmit}
disabled={!topic.trim() || isGenerating || credits <= 0}
className={cn(
"btn-primary flex items-center gap-1 text-sm sm:text-base px-3 py-2 sm:px-4 sm:py-2",
(!topic.trim() || isGenerating || credits <= 0) && "opacity-50 cursor-not-allowed"
)}
>
{isGenerating ? (
<>
<Loader2 className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<span className=" xs:inline">...</span>
</>
) : (
<>
<Wand2 className="w-3 h-3 sm:w-4 sm:h-4" />
<span className=" xs:inline"></span>
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Voices Modal */}
{selectedConfig && (
<VoicesModal
isOpen={showVoicesModal}
onClose={() => setShowVoicesModal(false)}
voices={voices}
onSelectVoices={(selectedVoices) => {
setSelectedPodcastVoices(prev => ({...prev, [selectedConfigName]: selectedVoices})); // 更新选中的说话人状态
setShowVoicesModal(false); // 选中后关闭模态框
}}
initialSelectedVoices={selectedPodcastVoices[selectedConfigName] || []} // 传递选中的说话人作为初始值
currentSelectedVoiceIds={selectedPodcastVoices[selectedConfigName]?.map(v => v.code!) || []} // 更新 currentSelectedVoiceIds
onRemoveVoice={(voiceCodeToRemove) => {
setSelectedPodcastVoices(prev => {
const newVoices = (prev[selectedConfigName] || []).filter(v => v.code !== voiceCodeToRemove);
return {
...prev,
[selectedConfigName]: newVoices
};
});
}}
/>
)}
</div>
);
};
export default PodcastCreator;

View File

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

View File

@@ -0,0 +1,563 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
KeyIcon,
CogIcon,
GlobeAltIcon,
SpeakerWaveIcon,
CheckIcon,
ExclamationTriangleIcon,
EyeIcon,
EyeSlashIcon
} from '@heroicons/react/24/outline';
import { getItem, setItem } from '@/lib/storage';
// 存储键名
const SETTINGS_STORAGE_KEY = 'podcast-settings';
// 设置表单数据类型
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;
};
}
// 模型选项
const MODEL_OPTIONS = [
'gpt-4-turbo',
'gpt-4o-mini',
'gpt-4o',
];
// TTS服务配置
const TTS_SERVICES = [
{ key: 'doubao', name: 'Doubao TTS', icon: SpeakerWaveIcon },
{ key: 'fish', name: 'Fish TTS', icon: SpeakerWaveIcon },
{ key: 'minimax', name: 'Minimax TTS', icon: SpeakerWaveIcon },
{ key: 'gemini', name: 'Gemini TTS', icon: SpeakerWaveIcon },
{ key: 'edge', name: 'Edge TTS', icon: SpeakerWaveIcon }, // Edge TTS 仍在网络 API 服务中
{ key: 'index', name: 'Index TTS', icon: SpeakerWaveIcon }, // Index TTS 保持本地 API
];
interface SettingsFormProps {
onSave?: (data: SettingsFormData) => void;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
}
export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFormProps) {
const [formData, setFormData] = useState<SettingsFormData>({
apikey: '',
model: '',
baseurl: '',
index: { api_url: '' },
edge: { api_url: '' },
doubao: { 'X-Api-App-Id': '', 'X-Api-Access-Key': '' },
fish: { api_key: '' },
minimax: { group_id: '', api_key: '' },
gemini: { api_key: '' },
});
const [isLoading, setIsLoading] = useState(false);
const [showPasswords, setShowPasswords] = useState<Record<string, boolean>>({});
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
// 组件挂载时从 localStorage 加载设置
useEffect(() => {
const savedSettings = getItem<SettingsFormData>(SETTINGS_STORAGE_KEY);
if (savedSettings) {
// 确保从 localStorage 加载的设置中,所有预期的字符串字段都为字符串
// 防止出现受控组件变为非受控组件的警告
const normalizedSettings = {
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 || '',
},
};
setFormData(normalizedSettings);
}
}, []);
const togglePasswordVisibility = (fieldKey: string) => {
setShowPasswords(prev => ({
...prev,
[fieldKey]: !prev[fieldKey],
}));
};
const handleInputChange = (path: string, value: string) => {
setFormData(prev => {
const newData = { ...prev };
const keys = path.split('.');
if (keys.length === 1) {
(newData as any)[keys[0]] = value;
} else if (keys.length === 2) {
(newData as any)[keys[0]][keys[1]] = value;
}
return newData;
});
};
const handleReset = () => {
const defaultData: SettingsFormData = {
apikey: '',
model: '',
baseurl: '',
index: { api_url: '' },
edge: { api_url: '' },
doubao: { 'X-Api-App-Id': '', 'X-Api-Access-Key': '' },
fish: { api_key: '' },
minimax: { group_id: '', api_key: '' },
gemini: { api_key: '' },
};
setFormData(defaultData);
setShowPasswords({});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// 转换空字符串为 null
const processedData = JSON.parse(JSON.stringify(formData), (key, value) => {
return value === '' ? null : value;
});
// 保存到 localStorage
setItem(SETTINGS_STORAGE_KEY, processedData);
// 触发自定义事件,通知其他组件设置已更新
window.dispatchEvent(new CustomEvent('settingsUpdated'));
// 调用保存回调
if (onSave) {
onSave(processedData);
}
onSuccess?.('设置保存成功!');
} catch (error) {
console.error('Error saving settings:', error);
onError?.('保存设置时出现错误,请重试');
} finally {
setIsLoading(false);
}
};
const renderPasswordInput = (
label: string,
path: string,
value: string,
placeholder?: string,
required?: boolean
) => {
const fieldKey = path.replace('.', '_');
const isVisible = showPasswords[fieldKey];
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="relative">
<input
type={isVisible ? 'text' : 'password'}
value={value}
onChange={(e) => handleInputChange(path, e.target.value)}
placeholder={placeholder}
className="input-primary pr-10 w-full"
required={required}
/>
<button
type="button"
onClick={() => togglePasswordVisibility(fieldKey)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neutral-400 hover:text-neutral-600"
>
{isVisible ? (
<EyeSlashIcon className="h-5 w-5" />
) : (
<EyeIcon className="h-5 w-5" />
)}
</button>
</div>
</div>
);
};
const renderTextInput = (
label: string,
path: string,
value: string,
placeholder?: string,
required?: boolean,
wrapperClassName?: string // 新增 wrapperClassName 参数
) => (
<div className={`space-y-2 ${wrapperClassName || ''}`}> {/* 应用 wrapperClassName */}
<label className="block text-sm font-medium text-neutral-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="text"
value={value}
onChange={(e) => handleInputChange(path, e.target.value)}
placeholder={placeholder}
className="input-primary w-full"
required={required}
/>
</div>
);
const renderSelectInput = (
label: string,
path: string,
value: string,
options: { value: string; label: string }[],
required?: boolean
) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<select
value={value}
onChange={(e) => handleInputChange(path, e.target.value)}
className="input-primary"
required={required}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
return (
<div className="max-w-4xl mx-auto px-6">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold text-black mb-2"></h1>
<p className="text-neutral-600 break-words">API设置和TTS服务</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* 通用设置 */}
<div className="bg-white border border-neutral-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-neutral-100 rounded-lg">
<CogIcon className="h-5 w-5 text-neutral-600" />
</div>
<h2 className="text-xl font-semibold text-black"></h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderPasswordInput(
'API Key',
'apikey',
formData.apikey,
'输入您的OpenAI API Key',
true
)}
<div className="space-y-2 relative">
<label className="block text-sm font-medium text-neutral-700">
<span className="text-red-500 ml-1">*</span>
</label>
<div className="relative">
<input
type="text"
value={formData.model}
onChange={(e) => handleInputChange('model', e.target.value)}
onFocus={() => setIsModelDropdownOpen(true)}
placeholder="选择或输入模型名称"
className="input-primary w-full pr-8"
required
/>
<button
type="button"
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neutral-400 hover:text-neutral-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isModelDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 max-h-48 overflow-y-auto">
{MODEL_OPTIONS.map((model) => (
<button
key={model}
type="button"
onClick={() => {
if (model === '输入自定义模型') {
handleInputChange('model', '');
} else {
handleInputChange('model', model);
}
setIsModelDropdownOpen(false);
}}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 transition-colors text-sm"
>
{model}
</button>
))}
</div>
)}
</div>
{/* 点击外部关闭下拉菜单 */}
{isModelDropdownOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsModelDropdownOpen(false)}
/>
)}
</div>
{renderTextInput(
'Base URL',
'baseurl',
formData.baseurl,
'可选自定义API基础URL',
false, // required
'md:col-span-2' // wrapperClassName
)}
</div>
</div>
{/* TTS服务设置 */}
<div className="bg-white border border-neutral-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-neutral-100 rounded-lg">
<SpeakerWaveIcon className="h-5 w-5 text-neutral-600" />
</div>
<h2 className="text-xl font-semibold text-black">TTS服务设置</h2>
</div>
<div className="space-y-8">
{/* 网络 API TTS 服务 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
<GlobeAltIcon className="h-5 w-5 text-blue-500" />
API TTS
</h3>
<div className="grid grid-cols-1 gap-8">
{/* Edge TTS */}
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Edge TTS
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">Edge的TTS免费服务</p>
{renderTextInput(
'API URL',
'edge.api_url',
formData.edge.api_url,
'http://localhost:8001'
)}
</div>
{/* Doubao TTS */}
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Doubao TTS
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">baseUrl=https://openspeech.bytedance.com/api/v3/tts/unidirectional</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderTextInput(
'App ID',
'doubao.X-Api-App-Id',
formData.doubao['X-Api-App-Id'],
'输入Doubao App ID'
)}
{renderPasswordInput(
'Access Key',
'doubao.X-Api-Access-Key',
formData.doubao['X-Api-Access-Key'],
'输入Doubao Access Key'
)}
</div>
</div>
{/* Minimax TTS */}
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Minimax TTS
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">Minimax提供支持的语音合成服务baseUrl=https://api.minimaxi.com/v1/t2a_v2</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderTextInput(
'Group ID',
'minimax.group_id',
formData.minimax.group_id,
'输入Minimax Group ID'
)}
{renderPasswordInput(
'API Key',
'minimax.api_key',
formData.minimax.api_key,
'输入Minimax API Key'
)}
</div>
</div>
{/* Fish TTS */}
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Fish TTS
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">FishAudio提供支持的语音合成服务baseUrl=https://api.fish.audio/v1/tts</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderPasswordInput(
'API Key',
'fish.api_key',
formData.fish.api_key,
'输入Fish TTS API Key'
)}
</div>
</div>
{/* Gemini TTS */}
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Gemini TTS
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">Google Gemini提供支持的语音合成服务baseUrl=https://generativelanguage.googleapis.com/v1beta/models</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderPasswordInput(
'API Key',
'gemini.api_key',
formData.gemini.api_key,
'输入Gemini API Key'
)}
</div>
</div>
</div>
</div>
{/* 本地 API TTS 服务 */}
<div className="space-y-4 mt-8">
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
<SpeakerWaveIcon className="h-5 w-5 text-purple-500" />
API TTS
</h3>
<div className="grid grid-cols-1 gap-8">
{/* Index TTS */}
<div className="space-y-4 border-l-4 border-purple-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Index TTS
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">IndexTTS服务</p>
{renderTextInput(
'API URL',
'index.api_url',
formData.index.api_url,
'http://localhost:8000'
)}
</div>
</div>
</div>
</div>
</div>
{/* 提交按钮 */}
<div className="flex justify-center space-x-4">
<button
type="button"
onClick={handleReset}
className="px-6 py-3 border border-neutral-300 text-neutral-700 rounded-full font-medium transition-all duration-200 hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2"
>
</button>
<button
type="submit"
disabled={isLoading}
className="gradient-brand text-white px-8 py-3 rounded-full font-medium transition-all duration-200 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
...
</>
) : (
<>
</>
)}
</button>
</div>
</form>
{/* 帮助信息 */}
<div className="mt-8 bg-amber-50 border border-amber-200 rounded-xl p-6">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-amber-800"></h3>
<ul className="text-sm text-amber-700 space-y-1 break-words">
<li> API Key OpenAI服务生成播客脚本</li>
<li> TTS服务配置为可选项</li>
<li> null </li>
<li> </li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import React from 'react';
import {
Home,
Library,
Compass,
DollarSign,
Coins,
Settings,
Twitter,
MessageCircle,
Mail,
Cloud,
Smartphone,
PanelLeftClose,
PanelLeftOpen
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface SidebarProps {
currentView: string;
onViewChange: (view: string) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
mobileOpen?: boolean; // 添加移动端侧边栏状态属性
credits: number; // 添加 credits 属性
}
interface NavItem {
id: string;
label: string;
icon: React.ElementType;
badge?: string;
}
const Sidebar: React.FC<SidebarProps> = ({
currentView,
onViewChange,
collapsed = false,
onToggleCollapse,
mobileOpen = false, // 解构移动端侧边栏状态属性
credits // 解构 credits 属性
}) => {
const mainNavItems: NavItem[] = [
{ id: 'home', label: '首页', icon: Home },
// 隐藏资料库和探索
// { id: 'library', label: '资料库', icon: Library },
// { id: 'explore', label: '探索', icon: Compass },
];
const bottomNavItems: NavItem[] = [
// 隐藏定价和积分
// { id: 'pricing', label: '定价', icon: DollarSign },
// { id: 'credits', label: '积分', icon: Coins, badge: credits.toString() }, // 动态设置 badge
{ id: 'settings', label: 'TTS设置', icon: Settings },
];
const socialLinks = [
{ icon: Twitter, href: '#', label: 'Twitter' },
{ icon: MessageCircle, href: '#', label: 'Discord' },
{ icon: Mail, href: '#', label: 'Email' },
{ icon: Cloud, href: '#', label: 'Cloud' },
{ icon: Smartphone, href: '#', label: 'Mobile' },
];
return (
<div className={cn(
"fixed left-0 top-0 h-screen bg-neutral-50 border-r border-neutral-200 flex flex-col justify-between transition-all duration-300 z-40",
collapsed ? "w-16" : "w-64",
"max-md:left-[-256px] max-md:transition-transform max-md:duration-300", // 在中等屏幕以下默认隐藏
mobileOpen && "max-md:left-0" // 在移动端打开时显示侧边栏
)}>
{/* 顶部Logo区域 */}
<div className={cn("p-6", collapsed && "px-2")}>
{/* Logo和品牌区域 - 统一结构 */}
<div className="flex items-center mb-8 h-8">
{collapsed ? (
/* 收起状态 - 只显示展开按钮 */
<div className="w-full flex justify-center">
<button
onClick={onToggleCollapse}
className="w-8 h-8 gradient-brand rounded-lg flex items-center justify-center hover:opacity-80 transition-opacity"
title="展开侧边栏"
>
<PanelLeftOpen className="w-4 h-4 text-white" />
</button>
</div>
) : (
/* 展开状态 - Logo和品牌名称 */
<>
{/* Logo图标 */}
<div className="w-8 h-8 gradient-brand rounded-lg flex items-center justify-center flex-shrink-0">
<div className="w-4 h-4 bg-white rounded-sm opacity-80" />
</div>
{/* 品牌名称容器 - 慢慢收缩动画 */}
<div className="overflow-hidden transition-all duration-500 ease-in-out w-auto ml-3">
<span className="text-xl font-semibold text-black whitespace-nowrap transition-all duration-500 ease-in-out transform-gpu opacity-100 scale-x-100">PodcastHub</span>
</div>
{/* 收起按钮 */}
<div className="flex-shrink-0 ml-auto">
<button
onClick={onToggleCollapse}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-neutral-100 border border-neutral-200 transition-all duration-200"
title="收起侧边栏"
>
<PanelLeftClose className="w-4 h-4 text-neutral-500" />
</button>
</div>
</>
)}
</div>
{/* 主导航 */}
<nav className="space-y-2">
{mainNavItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<div key={item.id} className={cn(collapsed && "flex justify-center")}>
<button
onClick={() => onViewChange(item.id)}
className={cn(
"flex items-center rounded-lg text-neutral-600 hover:text-black hover:bg-neutral-50 transition-all duration-200",
isActive && "bg-white text-black shadow-soft",
collapsed ? "justify-center w-8 h-8 px-0" : "w-full px-3 py-2"
)}
title={collapsed ? item.label : undefined}
>
<Icon 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"
)}>{item.label}</span>
</div>
</button>
</div>
);
})}
</nav>
</div>
{/* 底部区域 */}
<div className={cn("p-6", collapsed && "px-2")}>
{/* 底部导航 */}
<nav className="space-y-2 mb-6">
{bottomNavItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<div key={item.id} className={cn(collapsed && "flex justify-center")}>
<button
onClick={() => onViewChange(item.id)}
className={cn(
"flex items-center rounded-lg text-neutral-600 hover:text-black hover:bg-neutral-50 transition-all duration-200",
isActive && "bg-white text-black shadow-soft",
collapsed ? "justify-center w-8 h-8 px-0" : "w-full px-3 py-2"
)}
title={collapsed ? item.label : undefined}
>
<Icon 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"
)}>{item.label}</span>
{item.badge && !collapsed && (
<span className="ml-2 px-2 py-0.5 text-xs font-semibold bg-blue-100 text-blue-800 rounded-full flex-shrink-0">
{item.badge}
</span>
)}
</div>
</button>
</div>
);
})}
</nav>
{/* 社交链接 - 慢慢收缩动画 */}
<div className={cn(
"overflow-hidden transition-all duration-500 ease-in-out border-t border-neutral-200",
collapsed ? "h-0 pt-0" : "h-auto pt-4"
)}>
<div className={cn(
"flex items-center gap-3 transition-all duration-500 ease-in-out transform-gpu",
collapsed ? "opacity-0 scale-y-0" : "opacity-100 scale-y-100"
)}>
{socialLinks.map((link, index) => {
const Icon = link.icon;
return (
<a
key={index}
href={link.href}
className="p-2 text-neutral-400 hover:text-neutral-600 transition-colors"
title={link.label}
target="_blank"
rel="noopener noreferrer"
>
<Icon className="w-4 h-4" />
</a>
);
})}
</div>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,179 @@
'use client';
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastProps {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
onClose: (id: string) => void;
}
const Toast: React.FC<ToastProps> = ({
id,
type,
title,
message,
duration = 5000,
onClose,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
useEffect(() => {
// 进入动画
const timer = setTimeout(() => setIsVisible(true), 10);
// 自动关闭
const autoCloseTimer = setTimeout(() => {
handleClose();
}, duration);
return () => {
clearTimeout(timer);
clearTimeout(autoCloseTimer);
};
}, [duration]);
const handleClose = () => {
setIsLeaving(true);
setTimeout(() => {
onClose(id);
}, 300);
};
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <Info className="w-5 h-5 text-blue-500" />;
default:
return <Info className="w-5 h-5 text-blue-500" />;
}
};
const getStyles = () => {
const baseStyles = "border-l-4";
switch (type) {
case 'success':
return `${baseStyles} border-green-500 bg-green-50`;
case 'error':
return `${baseStyles} border-red-500 bg-red-50`;
case 'warning':
return `${baseStyles} border-yellow-500 bg-yellow-50`;
case 'info':
return `${baseStyles} border-blue-500 bg-blue-50`;
default:
return `${baseStyles} border-blue-500 bg-blue-50`;
}
};
return (
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg shadow-large max-w-sm w-full transition-all duration-300 ease-out",
getStyles(),
isVisible && !isLeaving ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}
>
{getIcon()}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm text-black mb-1">
{title}
</h4>
{message && (
<p className="text-xs text-neutral-600 leading-relaxed">
{message}
</p>
)}
</div>
<button
onClick={handleClose}
className="p-1 text-neutral-400 hover:text-neutral-600 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
};
// Toast容器组件
export interface ToastContainerProps {
toasts: ToastProps[];
onRemove: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({
toasts,
onRemove,
}) => {
return (
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
{...toast}
onClose={onRemove}
/>
))}
</div>
);
};
// Toast Hook
export const useToast = () => {
const [toasts, setToasts] = useState<ToastProps[]>([]);
const addToast = (toast: Omit<ToastProps, 'id' | 'onClose'>) => {
const id = Math.random().toString(36).substring(2);
const newToast: ToastProps = {
...toast,
id,
onClose: removeToast,
};
setToasts(prev => [...prev, newToast]);
return id;
};
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const success = (title: string, message?: string) =>
addToast({ type: 'success', title, message });
const error = (title: string, message?: string) =>
addToast({ type: 'error', title, message });
const warning = (title: string, message?: string) =>
addToast({ type: 'warning', title, message });
const info = (title: string, message?: string) =>
addToast({ type: 'info', title, message });
return {
toasts,
addToast,
removeToast,
success,
error,
warning,
info,
};
};
export default Toast;

View File

@@ -0,0 +1,282 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import type { Voice } from '@/types';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { PlayIcon, PauseIcon } from '@heroicons/react/24/solid';
import { X } from 'lucide-react';
interface VoicesModalProps {
isOpen: boolean;
onClose: () => void;
voices: Voice[];
onSelectVoices: (voices: Voice[]) => void;
initialSelectedVoices: Voice[];
currentSelectedVoiceIds: string[];
onRemoveVoice: (voiceCode: string) => void;
}
const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSelectVoices, initialSelectedVoices, currentSelectedVoiceIds, onRemoveVoice }) => {
const [inputValue, setInputValue] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const [genderFilter, setGenderFilter] = useState('');
const [languageFilter, setLanguageFilter] = useState('');
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const audioRefs = useRef(new Map<string, HTMLAudioElement>());
const [selectedLocalVoices, setSelectedLocalVoices] = useState<Voice[]>([]);
// 最佳实践:在源头去重
// 虽然使用 index 可以解决 key 的问题,但更好的做法是确保数据源本身没有重复项。
const uniqueVoices = useMemo(() => {
const seen = new Set();
return voices.filter(voice => {
const duplicate = seen.has(voice.code);
seen.add(voice.code);
return !duplicate;
});
}, [voices]);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedSearchTerm(inputValue);
}, 500);
return () => {
clearTimeout(timerId);
};
}, [inputValue]);
useEffect(() => {
if (isOpen) {
setInputValue('');
setDebouncedSearchTerm('');
setSelectedLocalVoices(initialSelectedVoices);
}
}, [isOpen, initialSelectedVoices]);
const filteredVoices = useMemo(() => {
// 【修复】使用去重后的 uniqueVoices 数组进行过滤
let currentFilteredVoices = uniqueVoices;
if (debouncedSearchTerm) {
const lowerCaseSearchTerm = debouncedSearchTerm.toLowerCase();
currentFilteredVoices = currentFilteredVoices.filter(voice =>
(voice.alias && voice.alias.toLowerCase().includes(lowerCaseSearchTerm)) ||
(voice.name && voice.name.toLowerCase().includes(lowerCaseSearchTerm)) ||
(voice.description && voice.description.toLowerCase().includes(lowerCaseSearchTerm))
);
}
if (genderFilter) {
currentFilteredVoices = currentFilteredVoices.filter(voice =>
voice.gender === genderFilter
);
}
if (languageFilter) {
currentFilteredVoices = currentFilteredVoices.filter(voice =>
voice.locale && voice.locale.toLowerCase().includes(languageFilter.toLowerCase())
);
}
return currentFilteredVoices;
}, [uniqueVoices, debouncedSearchTerm, genderFilter, languageFilter]); // 【修复】依赖项更新为 uniqueVoices
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white border border-neutral-200 rounded-2xl p-6 md:w-[40vw] md:h-[70vh] w-[80%] h-[90%] relative flex flex-col shadow-large">
{/* Header and Filters */}
<div className="flex items-center mb-4 pr-10 flex-wrap gap-2 justify-end">
<div className="min-w-[220px] mr-auto"><h2 className="text-2xl font-bold text-black"> ({selectedLocalVoices.length}/{uniqueVoices.length})</h2></div>
{/* Filter buttons... */}
<button
onClick={() => { setGenderFilter(''); setLanguageFilter(''); }}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${genderFilter === '' && languageFilter === '' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
</button>
<button
onClick={() => setGenderFilter('Male')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${genderFilter === 'Male' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
</button>
<button
onClick={() => setGenderFilter('Female')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${genderFilter === 'Female' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
</button>
<button
onClick={() => setLanguageFilter('zh')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${languageFilter === 'zh' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
(zh)
</button>
<button
onClick={() => setLanguageFilter('en')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${languageFilter === 'en' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
(en)
</button>
<button
onClick={() => setLanguageFilter('ja')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${languageFilter === 'ja' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
(ja)
</button>
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full text-neutral-600 hover:bg-neutral-100 hover:text-black transition-all duration-200 z-10"
aria-label="关闭"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Search Input */}
<div className="relative flex-shrink-0 mb-4">
<label htmlFor="voice-search" className="sr-only"></label>
<input
id="voice-search"
className="peer block w-full rounded-lg border border-neutral-200 py-3 pl-10 text-sm outline-none focus:border-black focus:ring-2 focus:ring-black/10 placeholder:text-neutral-500 transition-all duration-200"
placeholder="搜索声音..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-neutral-500 peer-focus:text-black transition-colors duration-200" />
</div>
{/* Voices List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 overflow-y-auto p-3 scrollbar-hide">
{filteredVoices.length > 0 ? (
filteredVoices.map((voice: Voice, index: number) => (
// 【修复】将 voice.code 和 index 结合,创建唯一的 key。
// 即使数据源被清理过,这也是一种安全的做法。
<div
key={`${voice.code}-${index}`}
className={`border border-neutral-200 rounded-xl p-4 shadow-sm w-223 h-110 flex items-center justify-between cursor-pointer transition-all duration-200 hover:shadow-medium hover:-translate-y-0.5 ${
voice.gender === 'Female' ? 'bg-gradient-to-br from-pink-50 to-rose-50' : voice.gender === 'Male' ? 'bg-gradient-to-br from-blue-50 to-indigo-50' : 'bg-neutral-50'
} ${selectedLocalVoices.some(v => v.code === voice.code) ? 'border-brand-purple ring-2 ring-brand-purple/50 bg-brand-purple/5' : 'hover:border-neutral-300'}`}
onClick={() => {
const isSelected = selectedLocalVoices.some(v => v.code === voice.code);
if (isSelected) {
setSelectedLocalVoices(prev => prev.filter(v => v.code !== voice.code));
} else {
if (selectedLocalVoices.length < 5) {
setSelectedLocalVoices(prev => [...prev, voice]);
} else {
alert('最多只能选择5个说话人。');
}
}
}}
>
<div className="flex items-center space-x-3">
{voice.sample_audio_url && voice.sample_audio_url.length > 0 && voice.sample_audio_url !== 'undefined' && (
<div className="flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
const audio = audioRefs.current.get(voice.code!);
if (audio) {
if (playingVoiceId === voice.code) {
audio.pause();
setPlayingVoiceId(null);
} else {
if (playingVoiceId && audioRefs.current.has(playingVoiceId)) {
audioRefs.current.get(playingVoiceId)?.pause();
}
audio.play();
setPlayingVoiceId(voice.code!);
}
}
}}
className="p-2 rounded-full bg-gradient-to-r from-brand-purple to-brand-pink text-white hover:from-brand-purple-hover hover:to-brand-pink focus:outline-none focus:ring-2 focus:ring-brand-purple/20 transition-all duration-200"
>
{playingVoiceId === voice.code ? <PauseIcon className="h-6 w-6" /> : <PlayIcon className="h-6 w-6" />}
</button>
<audio
ref={el => {
if (el) audioRefs.current.set(voice.code!, el);
else audioRefs.current.delete(voice.code!);
}}
src={voice.sample_audio_url}
onEnded={() => setPlayingVoiceId(null)}
onPause={() => setPlayingVoiceId(null)}
preload="none"
className="hidden"
/>
</div>
)}
<div>
<h3 className="font-semibold text-lg break-words line-clamp-2 text-black">{voice.alias || voice.name}</h3>
<p className="text-sm text-neutral-600 break-words">: {voice.locale || '未知'}</p>
</div>
</div>
</div>
))
) : (
<p className="col-span-full text-center text-neutral-500"></p>
)}
</div>
{/* Footer with Selected Voices and Confirm Button */}
<div className="flex items-center justify-end p-4 border-t border-neutral-200 mt-auto">
{selectedLocalVoices.length > 0 && (
<div className="flex items-center flex-wrap gap-2 mr-auto">
{selectedLocalVoices.map((voice, index) => (
// 【修复】此处同样使用复合键,确保唯一性
<div
key={`${voice.code}-${index}`}
className={`relative px-4 py-2 rounded-full text-xs sm:text-sm font-medium flex items-center justify-center group transition-all duration-200 ${
index === 0 ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' :
voice.gender === 'Female' ? 'bg-pink-100 text-pink-800 border border-pink-200' :
voice.gender === 'Male' ? 'bg-blue-100 text-blue-800 border border-blue-200' :
'bg-neutral-100 text-neutral-800 border border-neutral-200'
}`}
>
{index === 0 ? (
<div className="text-center leading-tight">
<div></div>
<div className="text-xs">{voice.alias || voice.name}</div>
</div>
) : (
<span>{voice.alias || voice.name}</span>
)}
<button
onClick={() => {
setSelectedLocalVoices(prev => prev.filter(v => v.code !== voice.code));
if (currentSelectedVoiceIds.includes(voice.code!)) {
onRemoveVoice(voice.code!);
}
}}
className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-all duration-200 rounded-full backdrop-blur-sm"
aria-label="删除"
>
<X className="w-5 h-5" />
</button>
</div>
))}
</div>
)}
<button
onClick={() => {
if (selectedLocalVoices.length > 5) {
alert('最多只能选择5个说话人。');
return;
}
onSelectVoices(selectedLocalVoices);
onClose();
}}
className="px-6 py-3 bg-gradient-to-r text-xs sm:text-lg from-brand-purple to-brand-pink text-white rounded-full font-medium hover:from-brand-purple-hover hover:to-brand-pink focus:outline-none focus:ring-2 focus:ring-brand-purple/20 transition-all duration-200 shadow-medium hover:shadow-large"
>
({selectedLocalVoices.length})
</button>
</div>
</div>
</div>
);
};
export default VoicesModal;

View File

@@ -0,0 +1,86 @@
import { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';
import { getItem, setItem } from '@/lib/storage';
/**
* 一个自定义Hook用于将组件的状态与localStorage同步。
* 它提供了与useState类似的接口但会将状态持久化到localStorage。
*
* @template T
* @param {string} key localStorage中存储的键。
* @param {T} initialValue 初始值当localStorage中没有该键时使用。
* @returns {[T, Dispatch<SetStateAction<T>>]} 返回一个状态元组,包含当前值和更新函数。
*
* @example
* const [name, setName] = useLocalStorage('username', 'Guest');
*/
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, Dispatch<SetStateAction<T>>] {
/**
* 从localStorage读取初始值的函数。
* 使用函数作为useState的初始值可以确保此逻辑仅在初始渲染时执行一次。
*/
const readValue = useCallback((): T => {
try {
const item = getItem<T>(key);
return item !== null ? item : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
}, [initialValue, key]);
// 使用上面定义的函数来初始化state
const [storedValue, setStoredValue] = useState<T>(readValue);
/**
* 创建一个包装过的setValue函数它会同时更新state和localStorage。
*/
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
try {
// 允许value是一个函数以提供与useState相同的API
const valueToStore = value instanceof Function ? value(storedValue) : value;
// 更新React state
setStoredValue(valueToStore);
// 持久化到localStorage
setItem(key, valueToStore);
} catch (error) {
console.error(`Error setting localStorage key “${key}”:`, error);
}
},
[key, storedValue]
);
/**
* 监听storage事件以便在一个标签页中进行的更改可以同步到其他标签页。
*/
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.warn(`Error parsing storage change for key “${key}”:`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key]);
/**
* 监听key或initialValue的变化如果外部传入的key改变需要重新读取值。
*/
useEffect(() => {
setStoredValue(readValue());
}, [key, readValue]);
return [storedValue, setValue];
}
export default useLocalStorage;

View File

@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
// 定义 Tailwind CSS 的断点,这里只关注 sm (640px)
const screens = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
};
/**
* 一个用于检测媒体查询的React Hook。
* @param query 媒体查询字符串,例如 "(min-width: 640px)"
* @returns boolean 指示媒体查询是否匹配
*/
export function useMediaQuery(query: string): boolean {
// 客户端渲染时,初始状态根据 window.matchMedia 确定
// 服务端渲染时,初始状态设为 false防止 Hydration 错误
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQueryList = window.matchMedia(query);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// 添加监听器
mediaQueryList.addEventListener('change', listener);
// 组件卸载时移除监听器
return () => {
mediaQueryList.removeEventListener('change', listener);
};
}, [query]); // 仅在 query 变化时重新设置监听器
return matches;
}
// 导出常用媒体查询 Hook
export const useIsSmallScreen = () => useMediaQuery(`(max-width: ${screens.sm})`);
export const useIsMediumScreen = () => useMediaQuery(`(min-width: ${screens.md})`);

82
web/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* 检查当前是否在浏览器环境中
* @returns {boolean} 如果是浏览器环境则返回 true, 否则返回 false.
*/
const isBrowser = (): boolean => typeof window !== 'undefined';
/**
* 向 localStorage 中存储数据。
* 数据会以 JSON 格式进行序列化。
* @template T
* @param {string} key - 存储项的键。
* @param {T} value - 要存储的值。
*/
export function setItem<T>(key: string, value: T): void {
if (!isBrowser()) {
console.warn(`Attempted to set localStorage item in a non-browser environment. Key: "${key}"`);
return;
}
try {
const serializedValue = JSON.stringify(value);
localStorage.setItem(key, serializedValue);
} catch (error) {
console.error(`Error setting item "${key}" to localStorage:`, error);
}
}
/**
* 从 localStorage 中获取数据。
* 数据会自动从 JSON 格式进行反序列化。
* @template T
* @param {string} key - 要获取项的键。
* @returns {T | null} 如果找到并成功解析则返回数据,否则返回 null。
*/
export function getItem<T>(key: string): T | null {
if (!isBrowser()) {
return null;
}
try {
const serializedValue = localStorage.getItem(key);
if (serializedValue === null) {
return null;
}
return JSON.parse(serializedValue) as T;
} catch (error) {
console.error(`Error getting item "${key}" from localStorage:`, error);
// 如果解析失败,为防止应用崩溃,可以选择删除该项
localStorage.removeItem(key);
return null;
}
}
/**
* 从 localStorage 中移除一个数据项。
* @param {string} key - 要移除项的键。
*/
export function removeItem(key: string): void {
if (!isBrowser()) {
console.warn(`Attempted to remove localStorage item in a non-browser environment. Key: "${key}"`);
return;
}
try {
localStorage.removeItem(key);
} catch (error) {
console.error(`Error removing item "${key}" from localStorage:`, error);
}
}
/**
* 清空所有 localStorage 中的数据。
* 请谨慎使用此功能。
*/
export function clearAll(): void {
if (!isBrowser()) {
console.warn('Attempted to clear localStorage in a non-browser environment.');
return;
}
try {
localStorage.clear();
} catch (error) {
console.error('Error clearing localStorage:', error);
}
}

160
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,160 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* 合并Tailwind CSS类名的工具函数
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* 格式化时间显示(秒转换为 mm:ss 格式)
*/
export function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 格式化相对时间显示
*/
export function formatRelativeTime(date: string | Date): string {
const now = new Date();
const targetDate = new Date(date);
const diffInSeconds = Math.floor((now.getTime() - targetDate.getTime()) / 1000);
if (diffInSeconds < 60) {
return '刚刚';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes}分钟前`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours}小时前`;
} else if (diffInSeconds < 2592000) {
const days = Math.floor(diffInSeconds / 86400);
return `${days}天前`;
} else {
return targetDate.toLocaleDateString('zh-CN');
}
}
/**
* 生成唯一ID
*/
export function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
/**
* 深拷贝对象
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T;
if (obj instanceof Array) return obj.map(item => deepClone(item)) as unknown as T;
if (typeof obj === 'object') {
const clonedObj = {} as { [key: string]: any };
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj as T;
}
return obj;
}
/**
* 验证文件类型
*/
export function validateFileType(file: File, allowedTypes: string[]): boolean {
return allowedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
return file.type.toLowerCase().includes(type.toLowerCase());
});
}
/**
* 下载文件
*/
export function downloadFile(url: string, filename: string): void {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* 复制文本到剪贴板
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// 降级方案
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
return success;
}
}

121
web/src/types/index.ts Normal file
View File

@@ -0,0 +1,121 @@
// 播客生成相关类型定义
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;
}
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;
error?: string;
createdAt: string;
estimatedTime?: number; // 预估完成时间(秒)
}
export interface PodcastScript {
title: string;
speakers: Speaker[];
segments: ScriptSegment[];
totalDuration?: number;
}
export interface Speaker {
id: string;
name: string;
voice: string;
role: string;
description?: string;
}
export interface ScriptSegment {
id: string;
speakerId: string;
text: string;
timestamp?: number;
audioUrl?: string;
}
// TTS配置类型
export interface TTSConfig {
podUsers: Speaker[];
voices: Voice[];
apiUrl: string;
tts_provider?: string;
headers?: Record<string, string>;
request_payload?: Record<string, any>;
}
export interface Voice {
name?: string;
code?: string;
alias?: string;
usedname?: string;
locale?: string; // 新增 locale 字段
gender?: 'Male' | 'Female';
description?: string;
volume_adjustment?: number;
speed_adjustment?: number;
sample_audio_url?: string; // 添加 sample_audio_url
}
// 音频播放器相关类型
export interface AudioPlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
playbackRate: number;
}
// WebSocket消息类型
export interface WebSocketMessage {
type: 'progress' | 'status' | 'error' | 'completed';
data: {
id: string;
progress?: number;
status?: PodcastGenerationResponse['status'];
message?: string;
result?: PodcastGenerationResponse;
};
}
// 用户界面状态
export interface UIState {
sidebarCollapsed: boolean;
currentView: 'home' | 'library' | 'explore' | 'settings';
theme: 'light' | 'dark';
}
// 播客库相关类型
export interface PodcastItem {
id: string;
title: string;
description: string;
thumbnail: string;
author: {
name: string;
avatar: string;
};
duration: number;
playCount: number;
createdAt: string;
audioUrl: string;
tags: string[];
}
// API响应通用类型
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}

28
web/start.bat Normal file
View File

@@ -0,0 +1,28 @@
@echo off
echo 正在启动 PodcastHub 播客生成器...
echo.
echo 1. 检查 Node.js 环境...
node --version
if %errorlevel% neq 0 (
echo 错误: 未找到 Node.js请先安装 Node.js
pause
exit /b 1
)
echo.
echo 2. 安装依赖包...
npm install
if %errorlevel% neq 0 (
echo 错误: 依赖安装失败
pause
exit /b 1
)
echo.
echo 3. 启动开发服务器...
echo 应用将在 http://localhost:3000 启动
echo 按 Ctrl+C 停止服务器
echo.
npm run dev

60
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// 品牌色彩系统
brand: {
purple: '#A076F9',
'purple-hover': '#8b5cf6',
pink: '#E893CF',
gradient: 'linear-gradient(135deg, #A076F9 0%, #E893CF 100%)',
},
// 中性色系统
neutral: {
50: '#F9FAFB',
100: '#F7F7F8',
200: '#E5E7EB',
300: '#D1D5DB',
400: '#9CA3AF',
500: '#6B7280',
600: '#4B5563',
700: '#374151',
800: '#1F2937',
900: '#111827',
},
},
fontFamily: {
sans: ['Inter', 'PingFang SC', 'Helvetica Neue', 'Arial', 'sans-serif'],
},
boxShadow: {
'soft': '0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.06)',
'medium': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'large': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
},
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',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
};

60
web/test-build.js Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
console.log('🔍 检查Next.js应用构建状态...\n');
// 检查TypeScript类型
console.log('1. 检查TypeScript类型...');
const typeCheck = spawn('npm', ['run', 'type-check'], {
stdio: 'inherit',
shell: true,
cwd: __dirname
});
typeCheck.on('close', (code) => {
if (code === 0) {
console.log('✅ TypeScript类型检查通过\n');
// 检查ESLint
console.log('2. 检查代码规范...');
const lint = spawn('npm', ['run', 'lint'], {
stdio: 'inherit',
shell: true,
cwd: __dirname
});
lint.on('close', (lintCode) => {
if (lintCode === 0) {
console.log('✅ 代码规范检查通过\n');
// 尝试构建
console.log('3. 尝试构建应用...');
const build = spawn('npm', ['run', 'build'], {
stdio: 'inherit',
shell: true,
cwd: __dirname
});
build.on('close', (buildCode) => {
if (buildCode === 0) {
console.log('\n🎉 应用构建成功!');
console.log('\n📋 下一步:');
console.log('1. 配置环境变量:编辑 .env.local');
console.log('2. 启动开发服务器npm run dev');
console.log('3. 访问应用http://localhost:3000');
} else {
console.log('\n❌ 构建失败,请检查错误信息');
process.exit(1);
}
});
} else {
console.log('\n⚠ 代码规范检查有警告,但可以继续');
}
});
} else {
console.log('\n❌ TypeScript类型检查失败');
process.exit(1);
}
});

32
web/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"],
"@/hooks/*": ["./src/hooks/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}