feat: 添加播客生成器Web应用基础架构
实现基于Next.js的播客生成器Web应用,包含以下主要功能: - 完整的Next.js项目结构配置 - 播客生成API接口 - 音频文件服务API - TTS配置管理 - 响应式UI组件 - 本地存储和状态管理 - 音频可视化组件 - 全局样式和主题配置 新增配置文件包括: - Next.js、Tailwind CSS、ESLint等工具配置 - 环境变量示例文件 - 启动脚本和构建检查脚本 - 类型定义和工具函数库
This commit is contained in:
102
CLAUDE.md
102
CLAUDE.md
@@ -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 必须安装并配置在系统环境变量中。
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"apikey": null,
|
||||
"model": null,
|
||||
"baseurl": null,
|
||||
"index": {
|
||||
"api_url": null
|
||||
},
|
||||
|
||||
118
main.py
118
main.py
@@ -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
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Simple-Podcast-Script",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 text,Make 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
242
web/.claude/agents/coder.md
Normal 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; /* 视觉居中校正 */
|
||||
}
|
||||
16
web/.claude/settings.local.json
Normal file
16
web/.claude/settings.local.json
Normal 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
15
web/.env.example
Normal 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
7
web/.eslintrc.json
Normal 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
45
web/.gitignore
vendored
Normal 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
165
web/README.md
Normal 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
13
web/next.config.js
Normal 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
7236
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
web/package.json
Normal file
46
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
75
web/scripts/setup.js
Normal file
75
web/scripts/setup.js
Normal 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!🎙️');
|
||||
106
web/src/app/api/audio/[filename]/route.ts
Normal file
106
web/src/app/api/audio/[filename]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
web/src/app/api/config/route.ts
Normal file
60
web/src/app/api/config/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
235
web/src/app/api/generate-podcast/route.ts
Normal file
235
web/src/app/api/generate-podcast/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
web/src/app/api/tts-voices/route.ts
Normal file
45
web/src/app/api/tts-voices/route.ts
Normal 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
149
web/src/app/globals.css
Normal 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
52
web/src/app/layout.tsx
Normal 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
302
web/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
web/src/components/AudioPlayer.tsx
Normal file
366
web/src/components/AudioPlayer.tsx
Normal 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;
|
||||
147
web/src/components/AudioVisualizer.tsx
Normal file
147
web/src/components/AudioVisualizer.tsx
Normal 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;
|
||||
198
web/src/components/ConfigSelector.tsx
Normal file
198
web/src/components/ConfigSelector.tsx
Normal 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;
|
||||
238
web/src/components/ContentSection.tsx
Normal file
238
web/src/components/ContentSection.tsx
Normal 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;
|
||||
240
web/src/components/PodcastCard.tsx
Normal file
240
web/src/components/PodcastCard.tsx
Normal 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;
|
||||
357
web/src/components/PodcastCreator.tsx
Normal file
357
web/src/components/PodcastCreator.tsx
Normal 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;
|
||||
|
||||
233
web/src/components/ProgressModal.tsx
Normal file
233
web/src/components/ProgressModal.tsx
Normal 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;
|
||||
563
web/src/components/SettingsForm.tsx
Normal file
563
web/src/components/SettingsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
web/src/components/Sidebar.tsx
Normal file
223
web/src/components/Sidebar.tsx
Normal 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;
|
||||
179
web/src/components/Toast.tsx
Normal file
179
web/src/components/Toast.tsx
Normal 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;
|
||||
282
web/src/components/VoicesModal.tsx
Normal file
282
web/src/components/VoicesModal.tsx
Normal 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;
|
||||
86
web/src/hooks/useLocalStorage.ts
Normal file
86
web/src/hooks/useLocalStorage.ts
Normal 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;
|
||||
49
web/src/hooks/useMediaQuery.ts
Normal file
49
web/src/hooks/useMediaQuery.ts
Normal 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
82
web/src/lib/storage.ts
Normal 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
160
web/src/lib/utils.ts
Normal 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
121
web/src/types/index.ts
Normal 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
28
web/start.bat
Normal 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
60
web/tailwind.config.js
Normal 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
60
web/test-build.js
Normal 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
32
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user