feat: 新增播客详情页及相关功能组件

实现播客详情页功能,包括:
1. 新增 PodcastContent 组件展示播客详情
2. 添加 AudioPlayerControls 和 PodcastTabs 组件
3. 实现分享功能组件 ShareButton
4. 优化音频文件命名规则和缓存机制
5. 完善类型定义和 API 接口
6. 调整 UI 布局和响应式设计
7. 修复积分不足状态码问题
This commit is contained in:
hex2077
2025-08-18 23:42:36 +08:00
parent e479ffb789
commit 47668b8a74
26 changed files with 1943 additions and 276 deletions

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ output/
excalidraw.log
config/tts_providers-local.json
.claude
.serena
.serena
/node_modules

73
main.py
View File

@@ -76,13 +76,31 @@ stop_scheduler_event = threading.Event()
output_dir = "output"
time_after = 30
# 内存中存储任务结果
# {task_id: {"auth_id": auth_id, "status": TaskStatus, "result": any, "timestamp": float}}
task_results: Dict[str, Dict[UUID, Dict]] = {}
# 新增字典对象key为音频文件名value为task_results[auth_id][task_id]的值
audio_file_mapping: Dict[str, Dict] = {}
# 签名验证配置
SECRET_KEY = os.getenv("PODCAST_API_SECRET_KEY", "your-super-secret-key") # 在生产环境中请务必修改!
# 定义从 tts_provider 名称到其配置文件路径的映射
tts_provider_map = {
"index-tts": "config/index-tts.json",
"doubao-tts": "config/doubao-tts.json",
"edge-tts": "config/edge-tts.json",
"fish-audio": "config/fish-audio.json",
"gemini-tts": "config/gemini-tts.json",
"minimax": "config/minimax.json",
}
# 定义一个函数来清理输出目录
def clean_output_directory():
"""Removes files from the output directory that are older than 30 minutes."""
print(f"Cleaning output directory: {output_dir}")
now = time.time()
# 30 minutes in seconds
threshold = time_after * 60
threshold = time_after * 60
# 存储需要删除的 task_results 中的任务,避免在迭代时修改
tasks_to_remove_from_memory = []
@@ -105,7 +123,7 @@ def clean_output_directory():
# 只有 COMPLETED 的任务才应该被清理PENDING/RUNNING 任务的输出文件可能还未生成或正在使用
task_output_filename = task_info.get("output_audio_filepath")
if task_output_filename == filename and task_info["status"] == TaskStatus.COMPLETED:
tasks_to_remove_from_memory.append((auth_id, task_id))
tasks_to_remove_from_memory.append((auth_id, task_id, task_info))
elif os.path.isdir(file_path):
# 可选地,递归删除旧的子目录或其中的文件
# 目前只跳过目录
@@ -114,30 +132,20 @@ def clean_output_directory():
print(f"Failed to delete {file_path}. Reason: {e}")
# 在文件删除循环结束后统一处理 task_results 的删除
for auth_id, task_id in tasks_to_remove_from_memory:
for auth_id, task_id, task_info_to_remove in tasks_to_remove_from_memory:
if auth_id in task_results and task_id in task_results[auth_id]:
del task_results[auth_id][task_id]
print(f"Removed task {task_id} for auth_id {auth_id} from task_results.")
# 从 audio_file_mapping 中删除对应的条目
task_output_filename = task_info_to_remove.get("output_audio_filepath")
if task_output_filename and task_output_filename in audio_file_mapping:
del audio_file_mapping[task_output_filename]
print(f"Removed audio_file_mapping entry for {task_output_filename}.")
# 如果该 auth_id 下没有其他任务,则删除 auth_id 的整个条目
if not task_results[auth_id]:
del task_results[auth_id]
print(f"Removed empty auth_id {auth_id} from task_results.")
# 内存中存储任务结果
# {task_id: {"auth_id": auth_id, "status": TaskStatus, "result": any, "timestamp": float}}
task_results: Dict[str, Dict[UUID, Dict]] = {}
# 签名验证配置
SECRET_KEY = os.getenv("PODCAST_API_SECRET_KEY", "your-super-secret-key") # 在生产环境中请务必修改!
# 定义从 tts_provider 名称到其配置文件路径的映射
tts_provider_map = {
"index-tts": "config/index-tts.json",
"doubao-tts": "config/doubao-tts.json",
"edge-tts": "config/edge-tts.json",
"fish-audio": "config/fish-audio.json",
"gemini-tts": "config/gemini-tts.json",
"minimax": "config/minimax.json",
}
async def get_auth_id(x_auth_id: str = Header(..., alias="X-Auth-Id")):
"""
@@ -214,6 +222,14 @@ 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')}")
# 更新 audio_file_mapping
output_audio_filepath = podcast_generation_results.get('output_audio_filepath')
if output_audio_filepath:
# 从完整路径中提取文件名
filename = os.path.basename(output_audio_filepath)
filename = filename.split(".")[0]
# 将任务信息添加到 audio_file_mapping
audio_file_mapping[filename] = task_results[auth_id][task_id]
# 生成并编码像素头像
avatar_bytes = generate_pixel_avatar(str(task_id)) # 使用 task_id 作为种子
@@ -230,7 +246,8 @@ async def _generate_podcast_task(
"task_id": str(task_id),
"auth_id": auth_id,
"task_results": task_results[auth_id][task_id],
"timestamp": int(time.time()), # 确保发送整数秒级时间戳
"timestamp": int(time.time()),
"status": task_results[auth_id][task_id]["status"],
}
MAX_RETRIES = 3 # 定义最大重试次数
@@ -290,7 +307,8 @@ async def generate_podcast_submission(
"status": TaskStatus.PENDING,
"result": None,
"timestamp": time.time(),
"callback_url": callback_url # 存储回调地址
"callback_url": callback_url, # 存储回调地址
"auth_id": auth_id, # 存储 auth_id
}
background_tasks.add_task(
@@ -345,6 +363,21 @@ 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("/get-audio-info/")
async def get_audio_info(file_name: str):
"""
根据文件名从 audio_file_mapping 中获取对应的任务信息。
"""
# 移除文件扩展名(如果存在),因为 audio_file_mapping 的键是文件名(不含扩展名)
base_file_name = os.path.splitext(file_name)[0]
audio_info = audio_file_mapping.get(base_file_name)
if audio_info:
# 返回任务信息的副本,避免直接暴露内部字典引用
return JSONResponse(content={k: str(v) if isinstance(v, UUID) else v for k, v in audio_info.items()})
else:
raise HTTPException(status_code=404, detail="Audio file information not found.")
@app.get("/avatar/{username}")
async def get_avatar(username: str):
"""

View File

@@ -112,10 +112,14 @@ def generate_speaker_id_text(pod_users, voices_list):
return "".join(speaker_info) + ""
def merge_audio_files():
# 生成一个唯一的UUID
unique_id = str(uuid.uuid4())
# 获取当前时间戳
timestamp = int(time.time())
output_audio_filename_wav = f"podcast_{timestamp}.wav"
# 组合UUID和时间戳作为文件名去掉 'podcast_' 前缀
output_audio_filename_wav = f"{unique_id}{timestamp}.wav"
output_audio_filepath_wav = os.path.join(output_dir, output_audio_filename_wav)
output_audio_filename_mp3 = f"podcast_{timestamp}.mp3"
output_audio_filename_mp3 = f"{unique_id}{timestamp}.mp3"
output_audio_filepath_mp3 = os.path.join(output_dir, output_audio_filename_mp3)
# Use ffmpeg to concatenate audio files
@@ -221,7 +225,7 @@ 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("--output-language", type=str, default=None, 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()

1079
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,14 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"remark-parse": "^11.0.0",
"socket.io-client": "^4.7.5",
"tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"unified": "^11.0.5",
"use-debounce": "^10.0.5"
},
"devDependencies": {

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAudioInfo, getUserInfo } from '@/lib/podcastApi';
/**
* 处理 GET 请求,用于代理查询后端 FastAPI 应用的 /get-audio-info 接口。
* 查询参数file_name
*/
export async function GET(req: NextRequest) {
// 从请求 URL 中获取查询参数
const { searchParams } = new URL(req.url);
const fileName = searchParams.get('file_name');
// 检查是否提供了 fileName 参数
if (!fileName) {
return NextResponse.json(
{ success: false, error: '缺少 file_name 查询参数' },
{ status: 400 }
);
}
try {
// 调用前端的 podcastApi 模块中的 getAudioInfo 函数
const result = await getAudioInfo(fileName);
if (!result.success) {
// 转发 getAudioInfo 返回的错误信息和状态码
return NextResponse.json(
{ success: false, error: result.error },
{ status: result.statusCode || 500 }
);
}
const authId = result.data?.auth_id; // 确保 auth_id 存在且安全访问
let userInfoData = null;
if (authId) {
const userInfo = await getUserInfo(authId);
if (userInfo.success && userInfo.data) {
userInfoData = {
name: userInfo.data.name,
email: userInfo.data.email,
image: userInfo.data.image,
};
}
}
// 合并 result.data 和 userInfoData
const { auth_id, callback_url, ...restData } = result.data || {}; // 保留解构,但确保是来自 result.data
const responseData = {
...restData,
user: userInfoData // 将用户信息作为嵌套对象添加
};
return NextResponse.json({ success: true, data: responseData }, { status: 200 });
} catch (error) {
console.error('代理 /api/audio-info 失败:', error);
return NextResponse.json(
{ success: false, error: '内部服务器错误或无法连接到后端服务' },
{ status: 500 }
);
}
}

View File

@@ -3,6 +3,27 @@ import path from 'path';
import fs from 'fs/promises';
import type { TTSConfig } from '@/types';
// 缓存对象,存储响应数据和时间戳
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 30 * 60 * 1000; // 30 分钟
function getCache(key: string) {
const entry = cache.get(key);
if (!entry) {
return null;
}
// 检查缓存是否过期
if (Date.now() - entry.timestamp > CACHE_TTL) {
cache.delete(key); // 缓存过期,删除
return null;
}
return entry.data;
}
function setCache(key: string, data: any) {
cache.set(key, { data, timestamp: Date.now() });
}
const TTS_PROVIDER_ORDER = [
'edge-tts',
'doubao-tts',
@@ -14,6 +35,17 @@ const TTS_PROVIDER_ORDER = [
// 获取配置文件列表
export async function GET() {
const cacheKey = 'config_files_list';
const cachedData = getCache(cacheKey);
if (cachedData) {
console.log('Returning config files list from cache.');
return NextResponse.json({
success: true,
data: cachedData,
});
}
try {
const configDir = path.join(process.cwd(), '..', 'config');
const files = await fs.readdir(configDir);
@@ -41,6 +73,8 @@ export async function GET() {
return aIndex - bIndex;
});
setCache(cacheKey, configFiles); // 存储到缓存
return NextResponse.json({
success: true,
data: configFiles,
@@ -56,9 +90,19 @@ export async function GET() {
// 获取特定配置文件内容
export async function POST(request: NextRequest) {
const { configFile } = await request.json();
const cacheKey = `config_file_${configFile}`;
const cachedData = getCache(cacheKey);
if (cachedData) {
console.log(`Returning config file "${configFile}" from cache.`);
return NextResponse.json({
success: true,
data: cachedData,
});
}
try {
const { configFile } = await request.json();
if (!configFile || !configFile.endsWith('.json')) {
return NextResponse.json(
{ success: false, error: '无效的配置文件名' },
@@ -70,6 +114,8 @@ export async function POST(request: NextRequest) {
const configContent = await fs.readFile(configPath, 'utf-8');
const config: TTSConfig = JSON.parse(configContent);
setCache(cacheKey, config); // 存储到缓存
return NextResponse.json({
success: true,
data: config,

View File

@@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
if (currentPoints === null || currentPoints < POINTS_PER_PODCAST) {
return NextResponse.json(
{ success: false, error: `积分不足,生成一个播客需要 ${POINTS_PER_PODCAST} 积分,您当前只有 ${currentPoints || 0} 积分。` },
{ status: 403 } // 403 Forbidden - 权限不足,因为积分不足
{ status: 402 } // 402 Forbidden - 权限不足,因为积分不足
);
}

View File

@@ -26,8 +26,11 @@ export async function GET() {
}
export async function PUT(request: NextRequest) {
const { task_id, auth_id, timestamp, status } = await request.json();
try {
const { task_id, auth_id, timestamp } = await request.json();
if(status !== 'completed') {
return NextResponse.json({ success: false, error: "Invalid status" }, { status: 400 });
}
// 1. 参数校验
if (!task_id || !auth_id || typeof timestamp !== 'number') {
@@ -67,7 +70,8 @@ export async function PUT(request: NextRequest) {
if (error instanceof Error) {
// 区分积分不足的错误
if (error.message.includes("积分不足")) {
return NextResponse.json({ success: false, error: error.message }, { status: 403 }); // Forbidden
console.error("积分不足错误: %s %s %s", auth_id, task_id, error);
return NextResponse.json({ success: false, error: error.message }, { status: 402 }); // Forbidden
}
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}

View File

@@ -2,13 +2,35 @@ import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
// 定义缓存变量和缓存过期时间
let ttsProvidersCache: any = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟单位毫秒
// 获取 tts_providers.json 文件内容
export async function GET() {
try {
const now = Date.now();
// 检查缓存是否有效
if (ttsProvidersCache && (now - cacheTimestamp < CACHE_DURATION)) {
console.log('从缓存中返回 tts_providers.json 数据');
return NextResponse.json({
success: true,
data: ttsProvidersCache,
});
}
// 缓存无效或不存在,读取文件并更新缓存
const configPath = path.join(process.cwd(), '..', 'config', 'tts_providers.json');
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent);
// 更新缓存
ttsProvidersCache = config;
cacheTimestamp = now;
console.log('重新加载并缓存 tts_providers.json 数据');
return NextResponse.json({
success: true,
data: config,

View File

@@ -1,25 +1,77 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import PodcastCreator from '@/components/PodcastCreator';
import ContentSection from '@/components/ContentSection';
import AudioPlayer from '@/components/AudioPlayer';
import SettingsForm from '@/components/SettingsForm';
import PointsOverview from '@/components/PointsOverview'; // 导入 PointsOverview
import LoginModal from '@/components/LoginModal'; // 导入 LoginModal
import { ToastContainer, useToast } from '@/components/Toast';
import { usePreventDuplicateCall } from '@/hooks/useApiCall';
import { trackedFetch } from '@/utils/apiCallTracker';
import type { PodcastGenerationRequest, PodcastItem, UIState, PodcastGenerationResponse, SettingsFormData } from '@/types';
import { getTTSProviders } from '@/lib/config';
import LoginModal from '@/components/LoginModal'; // 导入 LoginModal
import { getSessionData } from '@/lib/server-actions';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
// 辅助函数:规范化设置数据
const normalizeSettings = (savedSettings: any): SettingsFormData => {
return {
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 || '',
},
};
};
export default function HomePage() {
const { toasts, success, error, warning, info, removeToast } = useToast();
const { executeOnce } = usePreventDuplicateCall();
const router = useRouter(); // Initialize useRouter
// 辅助函数:将 API 响应映射为 PodcastItem 数组
const mapApiResponseToPodcasts = (tasks: PodcastGenerationResponse[]): PodcastItem[] => {
return tasks.map((task: any) => ({
id: task.task_id,
title: task.title ? task.title : task.status === 'failed' ? '播客生成失败,请重试' : ' ',
description: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag).join(', ') : task.status === 'failed' ? task.error || '待生成的播客标签' : '待生成的播客标签',
thumbnail: task.avatar_base64 ? `data:image/png;base64,${task.avatar_base64}` : '',
author: {
name: '',
avatar: '',
},
audio_duration: task.audio_duration || '00:00',
playCount: 0,
createdAt: task.timestamp ? new Date(task.timestamp * 1000).toISOString() : new Date().toISOString(),
audioUrl: task.audioUrl ? task.audioUrl : '',
tags: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag) : task.status === 'failed' ? [task.error] : ['待生成的播客标签'],
status: task.status,
file_name: task.output_audio_filepath || '',
}));
};
const [uiState, setUIState] = useState<UIState>({
sidebarCollapsed: true,
@@ -41,7 +93,8 @@ export default function HomePage() {
// 音频播放器状态
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// 播客详情页状态
// 从后端获取积分数据和初始化数据加载
const initialized = React.useRef(false); // 使用 useRef 追踪是否已初始化
@@ -51,8 +104,10 @@ export default function HomePage() {
if (!initialized.current) {
initialized.current = true;
// 首次加载时获取播客列表
// 首次加载时获取播客列表和积分/用户信息
fetchRecentPodcasts();
// fetchCreditsAndUserInfo(); // 在fetchRecentPodcasts中调用
}
// 设置定时器每20秒刷新一次
@@ -69,32 +124,7 @@ export default function HomePage() {
const loadSettings = async () => {
const savedSettings = await getTTSProviders();
if (savedSettings) {
// 确保从 localStorage 加载的设置中,所有预期的字符串字段都为字符串
const normalizedSettings: SettingsFormData = {
apikey: savedSettings.apikey || '',
model: savedSettings.model || '',
baseurl: savedSettings.baseurl || '',
index: {
api_url: savedSettings.index?.api_url || '',
},
edge: {
api_url: savedSettings.edge?.api_url || '',
},
doubao: {
'X-Api-App-Id': savedSettings.doubao?.['X-Api-App-Id'] || '',
'X-Api-Access-Key': savedSettings.doubao?.['X-Api-Access-Key'] || '',
},
fish: {
api_key: savedSettings.fish?.api_key || '',
},
minimax: {
group_id: savedSettings.minimax?.group_id || '',
api_key: savedSettings.minimax?.api_key || '',
},
gemini: {
api_key: savedSettings.gemini?.api_key || '',
},
};
const normalizedSettings = normalizeSettings(savedSettings);
setSettings(normalizedSettings);
}
};
@@ -153,6 +183,9 @@ export default function HomePage() {
if(response.status === 401) {
throw new Error('生成播客失败请检查API Key是否正确或登录状态。');
}
if(response.status === 402) {
throw new Error('生成播客失败,请检查积分是否足够。');
}
if(response.status === 403) {
setIsLoginModalOpen(true); // 显示登录模态框
throw new Error('生成播客失败,请登录后重试。');
@@ -185,6 +218,11 @@ export default function HomePage() {
}
};
// 处理播客标题点击
const handleTitleClick = (podcast: PodcastItem) => {
router.push(`/podcast/${podcast.file_name.split(".")[0]}`);
};
const handlePlayPodcast = (podcast: PodcastItem) => {
if (currentPodcast?.id === podcast.id) {
setIsPlaying(prev => !prev);
@@ -221,91 +259,65 @@ export default function HomePage() {
try {
const apiResponse: { success: boolean; tasks?: { message: string; tasks: PodcastGenerationResponse[]; }; error?: string } = result;
if (apiResponse.success && apiResponse.tasks && Array.isArray(apiResponse.tasks)) { // 检查 tasks 属性是否存在且为数组
const newPodcasts: PodcastItem[] = apiResponse.tasks.map((task: any) => ({ // 遍历 tasks 属性
id: task.task_id, // 使用 task_id
title: task.title ? task.title : task.status === 'failed' ? '播客生成失败,请重试' : ' ',
description: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).join(', ') : task.status === 'failed' ? task.error || '待生成的播客标签' : '待生成的播客标签',
thumbnail: task.avatar_base64 ? `data:image/png;base64,${task.avatar_base64}` : '',
author: {
name: '',
avatar: '',
},
duration: parseDurationToSeconds(task.audio_duration || '00:00'),
playCount: 0,
createdAt: task.timestamp ? new Date(task.timestamp * 1000).toISOString() : new Date().toISOString(),
audioUrl: task.audioUrl ? task.audioUrl : '',
tags: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()) : task.status === 'failed' ? [task.error] : ['待生成的播客标签'],
status: task.status,
}));
// 直接倒序,确保最新生成的播客排在前面
if (apiResponse.success && apiResponse.tasks && Array.isArray(apiResponse.tasks)) {
const newPodcasts = mapApiResponseToPodcasts(apiResponse.tasks);
const reversedPodcasts = newPodcasts.reverse();
setExplorePodcasts(reversedPodcasts);
// 如果有最新生成的播客,自动播放
}
} catch (err) {
console.error('Error processing podcast data:', err);
error('数据处理失败', err instanceof Error ? err.message : '无法处理播客列表数据');
}
const fetchCredits = async () => {
try {
const pointsResponse = await fetch('/api/points');
if (pointsResponse.ok) {
const data = await pointsResponse.json();
if (data.success) {
setCredits(data.points);
} else {
console.error('Failed to fetch credits:', data.error);
setCredits(0); // 获取失败则设置为0
}
} else {
console.error('Failed to fetch credits with status:', pointsResponse.status);
setCredits(0); // 获取失败则设置为0
}
} catch (error) {
console.error('Error fetching credits:', error);
setCredits(0); // 发生错误则设置为0
}
try {
const transactionsResponse = await fetch('/api/points/transactions');
if (transactionsResponse.ok) {
const data = await transactionsResponse.json();
if (data.success) {
setPointHistory(data.transactions);
} else {
console.error('Failed to fetch point transactions:', data.error);
setPointHistory([]);
}
} else {
console.error('Failed to fetch point transactions with status:', transactionsResponse.status);
setPointHistory([]);
}
} catch (error) {
console.error('Error fetching point transactions:', error);
setPointHistory([]);
}
const { session, user } = await getSessionData();
setUser(user); // 设置用户信息
};
fetchCredits(); // 调用获取积分函数
fetchCreditsAndUserInfo();
};
// 辅助函数:解析时长字符串为秒数
const parseDurationToSeconds = (durationStr: string): number => {
const parts = durationStr.split(':');
if (parts.length === 2) {
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
} else if (parts.length === 3) { // 支持 HH:MM:SS 格式
return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
}
return 0;
// 新增辅助函数:获取积分和用户信息
const fetchCreditsAndUserInfo = async () => {
try {
const pointsResponse = await fetch('/api/points');
if (pointsResponse.ok) {
const data = await pointsResponse.json();
if (data.success) {
setCredits(data.points);
} else {
console.error('Failed to fetch credits:', data.error);
setCredits(0); // 获取失败则设置为0
}
} else {
console.error('Failed to fetch credits with status:', pointsResponse.status);
setCredits(0); // 获取失败则设置为0
}
} catch (error) {
console.error('Error fetching credits:', error);
setCredits(0); // 发生错误则设置为0
}
try {
const transactionsResponse = await fetch('/api/points/transactions');
if (transactionsResponse.ok) {
const data = await transactionsResponse.json();
if (data.success) {
setPointHistory(data.transactions);
} else {
console.error('Failed to fetch point transactions:', data.error);
setPointHistory([]);
}
} else {
console.error('Failed to fetch point transactions with status:', transactionsResponse.status);
setPointHistory([]);
}
} catch (error) {
console.error('Error fetching point transactions:', error);
setPointHistory([]);
}
const { session, user } = await getSessionData();
setUser(user); // 设置用户信息
};
const renderMainContent = () => {
switch (uiState.currentView) {
case 'home':
return (
@@ -325,8 +337,9 @@ export default function HomePage() {
subtitle="数据只保留30分钟请尽快下载保存"
items={explorePodcasts}
onPlayPodcast={handlePlayPodcast}
currentPodcast={currentPodcast}
isPlaying={isPlaying}
onTitleClick={handleTitleClick} // 传递 handleTitleClick
currentPodcast={currentPodcast} // 继续传递给 ContentSection
isPlaying={isPlaying} // 继续传递给 ContentSection
variant="compact"
layout="grid"
showRefreshButton={true}
@@ -339,6 +352,7 @@ export default function HomePage() {
title="为你推荐"
items={[...explorePodcasts].slice(0, 6)}
onPlayPodcast={handlePlayPodcast}
onTitleClick={handleTitleClick} // 传递 handleTitleClick
variant="default"
layout="horizontal"
/> */}
@@ -415,7 +429,7 @@ export default function HomePage() {
<main className={`flex-1 transition-all duration-300 ${
uiState.sidebarCollapsed ? 'ml-16' : 'ml-64'
} max-md:ml-0`}>
<div className="pb-8 pt-8 sm:pt-32 px-4 sm:px-6">
<div className="pb-8 pt-8 sm:pt-16 px-4 sm:px-6">
{renderMainContent()}
</div>
</main>

View File

@@ -0,0 +1,15 @@
import PodcastContent from '@/components/PodcastContent';
interface PodcastDetailPageProps {
params: {
fileName: string;
};
}
export default async function PodcastDetailPage({ params }: PodcastDetailPageProps) {
return (
<div className="bg-white text-gray-800 font-sans">
<PodcastContent fileName={decodeURIComponent(params.fileName)} />
</div>
);
}

View File

@@ -17,6 +17,7 @@ import { cn, formatTime, downloadFile } from '@/lib/utils';
import AudioVisualizer from './AudioVisualizer';
import { useIsSmallScreen } from '@/hooks/useMediaQuery'; // 导入新的 Hook
import type { AudioPlayerState, PodcastItem } from '@/types';
import { useToast } from '@/components/Toast';
interface AudioPlayerProps {
podcast: PodcastItem;
@@ -35,7 +36,8 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const { success: toastSuccess } = useToast(); // 使用 useToast Hook
const [playerState, setPlayerState] = useState<Omit<AudioPlayerState, 'isPlaying'>>({
currentTime: 0,
duration: 0,
@@ -185,21 +187,32 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
};
const handleShare = async () => {
// 从 podcast.audioUrl 中提取文件名
const audioFileName = podcast.file_name;
if (!audioFileName) {
console.error("无法获取音频文件名进行分享。");
toastSuccess('分享失败:无法获取音频文件名。');
return;
}
// 构建分享链接:网站根目录 + podcast/路径 + 音频文件名
const shareUrl = `${window.location.origin}/podcast/${audioFileName}`;
if (navigator.share) {
try {
await navigator.share({
title: podcast.title,
text: podcast.description,
url: window.location.href,
url: shareUrl, // 使用构建的分享链接
});
} catch (err) {
console.log('Share cancelled', err);
}
} else {
// 降级到复制链接
await navigator.clipboard.writeText(window.location.href);
// 这里可以显示一个toast提示
alert('链接已复制到剪贴板!'); // 简单替代Toast
// 降级到复制音频链接
await navigator.clipboard.writeText(shareUrl); // 使用构建的分享链接
// 使用Toast提示
toastSuccess('播放链接已复制到剪贴板!');
}
};

View File

@@ -0,0 +1,55 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Play, Pause } from 'lucide-react';
interface AudioPlayerControlsProps {
audioUrl: string;
audioDuration?: string;
}
export default function AudioPlayerControls({ audioUrl, audioDuration }: AudioPlayerControlsProps) {
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
useEffect(() => {
const audio = audioRef.current;
if (audio) {
const onEnded = () => {
setIsPlaying(false);
};
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('ended', onEnded);
};
}
}, []);
return (
<div className="flex justify-center my-8">
<button
onClick={togglePlayPause}
className="bg-gray-900 text-white rounded-full px-6 py-3 inline-flex items-center gap-2 font-semibold hover:bg-gray-700 transition-colors shadow-md"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
)}
<span>{isPlaying ? '暂停' : '播放'} ({audioDuration ?? '00:00'})</span>
</button>
<audio ref={audioRef} src={audioUrl} preload="auto" />
</div>
);
}

View File

@@ -2,7 +2,6 @@
import React, { useState, useEffect } from 'react';
import { Check } from 'lucide-react';
import { usePreventDuplicateCall } from '@/hooks/useApiCall';
import type { TTSConfig, Voice } from '@/types';
import { getTTSProviders } from '@/lib/config';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
@@ -28,7 +27,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
const [voices, setVoices] = useState<Voice[]>([]); // 新增 voices 状态
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { executeOnce } = usePreventDuplicateCall();
const loadConfigFilesCalled = React.useRef(false);
// 检查TTS配置是否已设置
const isTTSConfigured = (configName: string, settings: any): boolean => {
@@ -54,18 +53,46 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
}
};
// 加载配置文件列表 - 使用防重复调用机制
const loadConfigFiles = async () => {
const result = await executeOnce(async () => {
const response = await fetch('/api/config');
return response.json();
});
// 加载特定配置文件
const loadConfig = async (configFile: string) => {
setIsLoading(true);
try {
const configResponse = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ configFile }),
});
if (!result) {
return; // 如果是重复调用,直接返回
const configResult = await configResponse.json();
let fetchedVoices: Voice[] = [];
if (configResult.success) {
fetchedVoices = configResult.data.voices;
setVoices(fetchedVoices); // 更新 voices 状态
setCurrentConfig(configResult.data);
onConfigChange?.(configResult.data, configFile, fetchedVoices); // 传递 fetchedVoices
}
} catch (error) {
console.error('Failed to load config or voices:', error);
} finally {
setIsLoading(false);
}
};
// 加载配置文件列表
const loadConfigFiles = async () => {
// 防止重复调用
if (loadConfigFilesCalled.current) {
return;
}
loadConfigFilesCalled.current = true;
try {
const response = await fetch('/api/config');
const result = await response.json();
if (result.success && Array.isArray(result.data)) {
// 过滤出已配置的TTS选项
const settings = await getTTSProviders();
@@ -96,7 +123,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
useEffect(() => {
loadConfigFiles();
}, [executeOnce]); // 添加 executeOnce 到依赖项
}, []);
// 监听localStorage变化重新加载配置
useEffect(() => {
@@ -114,35 +141,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('settingsUpdated', handleStorageChange);
};
}, [selectedConfig, executeOnce]);
// 加载特定配置文件
const loadConfig = async (configFile: string) => {
setIsLoading(true);
try {
const configResponse = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ configFile }),
});
const configResult = await configResponse.json();
let fetchedVoices: Voice[] = [];
if (configResult.success) {
fetchedVoices = configResult.data.voices;
setVoices(fetchedVoices); // 更新 voices 状态
setCurrentConfig(configResult.data);
onConfigChange?.(configResult.data, configFile, fetchedVoices); // 传递 fetchedVoices
}
} catch (error) {
console.error('Failed to load config or voices:', error);
} finally {
setIsLoading(false);
}
};
}, [selectedConfig]);
const handleConfigSelect = (configFile: string) => {
setSelectedConfig(configFile);
@@ -153,59 +152,59 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
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>
<div className={className}>
{/* 配置选择器 */}
<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}
{/* 下拉菜单 */}
{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>
{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>
)}
)}
</div>
)}
{/* 点击外部关闭下拉菜单 */}
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</div>
{/* 点击外部关闭下拉菜单 */}
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</div>
);
};

View File

@@ -3,7 +3,7 @@
import React, { useRef, useEffect } from 'react';
import { ChevronRight, RotateCw } from 'lucide-react';
import PodcastCard from './PodcastCard';
import type { PodcastItem } from '@/types';
import type { PodcastItem } from '@/types'; // 移除了 PodcastGenerationResponse
interface ContentSectionProps {
title: string;
@@ -11,13 +11,14 @@ interface ContentSectionProps {
items: PodcastItem[];
onViewAll?: () => void;
onPlayPodcast?: (podcast: PodcastItem) => void;
currentPodcast?: PodcastItem | null;
isPlaying?: boolean;
loading?: boolean;
variant?: 'default' | 'compact';
layout?: 'grid' | 'horizontal';
showRefreshButton?: boolean; // 新增刷新按钮属性
onRefresh?: () => void; // 新增刷新回调函数
showRefreshButton?: boolean;
onRefresh?: () => void;
onTitleClick?: (podcast: PodcastItem) => void; // 确保传入 onTitleClick
currentPodcast?: PodcastItem | null; // Keep this prop for PodcastCard
isPlaying?: boolean; // Keep this prop for PodcastCard
}
const ContentSection: React.FC<ContentSectionProps> = ({
@@ -26,14 +27,16 @@ const ContentSection: React.FC<ContentSectionProps> = ({
items,
onViewAll,
onPlayPodcast,
currentPodcast,
isPlaying,
loading = false,
variant = 'default',
layout = 'grid',
showRefreshButton, // 直接解构
onRefresh // 直接解构
showRefreshButton,
onRefresh,
onTitleClick, // 确保解构
currentPodcast, // 确保解构
isPlaying // 确保解构
}) => {
if (loading) {
return (
<div className="max-w-6xl mx-auto px-6">
@@ -136,6 +139,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
items={items}
onPlayPodcast={onPlayPodcast}
variant={variant}
onTitleClick={onTitleClick}
/>
) : (
// 网格布局
@@ -152,6 +156,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
variant={variant}
currentPodcast={currentPodcast}
isPlaying={isPlaying}
onTitleClick={onTitleClick}
/>
))}
</div>
@@ -165,12 +170,14 @@ interface HorizontalScrollSectionProps {
items: PodcastItem[];
onPlayPodcast?: (podcast: PodcastItem) => void;
variant?: 'default' | 'compact';
onTitleClick?: (podcast: PodcastItem) => void;
}
const HorizontalScrollSection: React.FC<HorizontalScrollSectionProps> = ({
items,
onPlayPodcast,
variant = 'default'
variant = 'default',
onTitleClick
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -250,6 +257,7 @@ const HorizontalScrollSection: React.FC<HorizontalScrollSectionProps> = ({
className={`flex-shrink-0 ${
variant === 'compact' ? 'w-80' : 'w-72'
}`}
onTitleClick={onTitleClick}
/>
))}
</div>

View File

@@ -0,0 +1,27 @@
'use client';
import { useState, useEffect } from 'react';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkHtml from 'remark-html';
interface MarkdownRendererProps {
content: string;
}
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
const [htmlContent, setHtmlContent] = useState('');
useEffect(() => {
async function processMarkdown() {
const processedContent = await unified()
.use(remarkParse)
.use(remarkHtml)
.process(content);
setHtmlContent(processedContent.toString());
}
processMarkdown();
}, [content]);
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

View File

@@ -13,6 +13,7 @@ interface PodcastCardProps {
variant?: 'default' | 'compact';
currentPodcast?: PodcastItem | null;
isPlaying?: boolean;
onTitleClick?: (podcast: PodcastItem) => void; // 新增 onTitleClick 回调
}
const PodcastCard: React.FC<PodcastCardProps> = ({
@@ -22,6 +23,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
variant = 'default',
currentPodcast,
isPlaying,
onTitleClick, // 解构 onTitleClick
}) => {
const [isLiked, setIsLiked] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -44,6 +46,10 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
// 更多操作菜单
};
const handleTitleClick = () => {
onTitleClick?.(podcast); // 调用传入的 onTitleClick 回调
};
// 根据变体返回不同的布局
if (variant === 'compact') {
return (
@@ -88,7 +94,10 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
{/* 内容 */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-black text-base mb-1 line-clamp-2 leading-tight">
<h3
className="font-semibold text-black text-base mb-1 line-clamp-2 leading-tight cursor-pointer hover:underline"
onClick={handleTitleClick}
>
{podcast.title}
</h3>
<p className="text-sm text-neutral-600 mb-2 line-clamp-1">
@@ -97,7 +106,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
<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)}
{podcast.audio_duration}
</span>
{/* <span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
@@ -190,15 +199,18 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
{/* 时长标签 */}
<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)}
{podcast.audio_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">
<h3
className="font-semibold text-black text-lg mb-3 line-clamp-2 leading-tight group-hover:text-brand-purple transition-colors duration-200 cursor-pointer hover:underline
sm:text-xl sm:mb-4"
onClick={handleTitleClick}
>
{podcast.title}
</h3>

View File

@@ -0,0 +1,123 @@
import { ArrowLeft } from 'lucide-react';
import { getAudioInfo, getUserInfo } from '@/lib/podcastApi';
import AudioPlayerControls from './AudioPlayerControls';
import PodcastTabs from './PodcastTabs';
import ShareButton from './ShareButton'; // 导入 ShareButton 组件
// 脚本解析函数 (与 page.tsx 中保持一致)
const parseTranscript = (
transcript: { speaker_id: number; dialog: string }[] | undefined,
podUsers: { role: string; code: string; name: string; usedname: string }[] | undefined
) => {
if (!transcript) return [];
return transcript.map((item, index) => {
let speakerName: string | null = null;
if (podUsers && podUsers[item.speaker_id]) {
speakerName = podUsers[item.speaker_id].usedname; // 使用 podUsers 中的 usedname 字段作为 speakerName
} else {
speakerName = `Speaker ${item.speaker_id}`; // 回退到 Speaker ID
}
return { id: index, speaker: speakerName, dialogue: item.dialog };
});
};
interface PodcastContentProps {
fileName: string;
}
export default async function PodcastContent({ fileName }: PodcastContentProps) {
const result = await getAudioInfo(fileName);
if (!result.success || !result.data || result.data.status!='completed') {
return (
<div className="flex flex-col justify-center items-center h-screen text-gray-800">
<p className="text-red-500 text-lg">{result.error || '未知错误'}</p>
<a href="/" className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 transition-colors">
</a>
</div>
);
}
const authId = result.data?.auth_id; // 确保 auth_id 存在且安全访问
let userInfoData = null;
if (authId) {
const userInfo = await getUserInfo(authId);
if (userInfo.success && userInfo.data) {
userInfoData = {
name: userInfo.data.name,
email: userInfo.data.email,
image: userInfo.data.image,
};
}
}
const responseData = {
...result.data,
user: userInfoData // 将用户信息作为嵌套对象添加
};
const audioInfo = responseData;
const parsedScript = parseTranscript(audioInfo.podcast_script?.podcast_transcripts || [], audioInfo.podUsers);
return (
<main className="max-w-3xl mx-auto px-6 py-10">
{/* 返回首页按钮和分享按钮 */}
<div className="flex justify-between items-center mb-6"> {/* 修改为 justify-between 和 items-center */}
<a
href="/"
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
>
<ArrowLeft className="w-5 h-5 mr-1" />
</a>
<ShareButton /> {/* 添加分享按钮 */}
</div>
{/* 1. 顶部信息区 */}
<div className="flex flex-col items-center text-center">
{/* 缩略图 */}
<div className="w-32 h-32 md:w-32 md:h-32 rounded-2xl mb-6 shadow-lg overflow-hidden bg-gradient-to-br from-brand-purple to-brand-pink">
{audioInfo.avatar_base64 && (
<img
src={`data:image/jpeg;base64,${audioInfo.avatar_base64}`}
alt="Podcast Thumbnail"
className="w-full h-full object-cover"
/>
)}
</div>
{/* 标题 */}
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 leading-tight break-words">
{audioInfo.title}
</h1>
{/* 元数据栏 */}
<div className="flex items-center justify-center flex-wrap gap-x-4 gap-y-2 mt-4 text-gray-500">
<div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-sm font-semibold text-gray-600">
<img
src={audioInfo.user?.image || '/default-avatar.png'}
alt={audioInfo.user?.name || 'User Avatar'}
className="w-full h-full object-cover rounded-full"
/>
</div>
<span>{audioInfo.user?.name}</span>
</div>
</div>
</div>
{/* 2. 播放控制区 - 使用客户端组件 */}
<AudioPlayerControls
audioUrl={audioInfo.audioUrl || ''}
audioDuration={audioInfo.audio_duration}
/>
{/* 3. 内容导航区和内容展示区 - 使用客户端组件 */}
<PodcastTabs
parsedScript={parsedScript}
overviewContent={audioInfo.overview_content}
/>
</main>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import MarkdownRenderer from './MarkdownRenderer';
interface PodcastTabsProps {
parsedScript: { id: number; speaker: string | null; dialogue: string }[];
overviewContent?: string;
}
export default function PodcastTabs({ parsedScript, overviewContent }: PodcastTabsProps) {
const [activeTab, setActiveTab] = useState<'script' | 'overview'>('script');
return (
<>
{/* 3. 内容导航区 */}
<div className="sticky top-0 bg-white z-10">
<div className="flex justify-center border-b border-gray-200">
<div className="flex gap-8">
{/* 脚本 */}
<button
className={`py-4 px-1 text-base font-semibold ${
activeTab === 'script' ? 'text-gray-900 border-b-2 border-gray-900' : 'text-gray-500 border-b-2 border-transparent hover:text-gray-900'
}`}
onClick={() => setActiveTab('script')}
>
</button>
{/* 大纲 */}
<button
className={`py-4 px-1 text-base font-semibold ${
activeTab === 'overview' ? 'text-gray-900 border-b-2 border-gray-900' : 'text-gray-500 border-b-2 border-transparent hover:text-gray-900'
}`}
onClick={() => setActiveTab('overview')}
>
</button>
</div>
</div>
</div>
{/* 4. 内容展示区 */}
<div className="mt-8">
<article className="prose prose-lg max-w-none">
{activeTab === 'script' ? (
parsedScript.map(({ id, speaker, dialogue }) => (
<p key={id}>
{speaker && (
<strong className="text-900">{speaker}: </strong>
)}
{dialogue}
</p>
))
) : (
// 大纲内容
overviewContent ? (
<MarkdownRenderer content={overviewContent} />
) : (
<p></p>
)
)}
</article>
</div>
</>
);
}

View File

@@ -29,14 +29,14 @@ const PointsOverview: React.FC<PointsOverviewProps> = ({
<div className="w-9/10 sm:w-3/5 lg:w-1/3 mx-auto flex flex-col gap-6 p-6 md:p-8 lg:p-10 bg-gray-50 dark:bg-gray-800 rounded-lg shadow-xl">
{/* Upper Section: Total Points and User Info */}
<div className="bg-gradient-to-r from-purple-600 to-pink-500 dark:from-purple-700 dark:to-pink-600 text-white p-6 rounded-lg shadow-lg flex flex-col md:flex-row items-center justify-between">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<div className="flex flex-row sm:flex-row items-center sm:items-start gap-4">
<img
src={user.image}
alt="User Avatar"
className="w-12 h-12 rounded-full object-cover"
/>
<div className="text-center sm:text-left">
<h2 className="text-xl sm:text-3xl font-bold tracking-tight">{user.name}</h2>
<h2 className="text-xl text-left sm:text-3xl font-bold tracking-tight">{user.name}</h2>
<p className="text-xs sm:text-base text-blue-200 dark:text-blue-300">{user.email}</p>
</div>
</div>
@@ -47,17 +47,17 @@ const PointsOverview: React.FC<PointsOverviewProps> = ({
</div>
</div>
{/* Small text for mobile view only */}
<p className="mt-4 text-center text-sm text-blue-500 dark:text-blue-300">
<p className="text-center text-sm text-blue-500 dark:text-blue-300">
20
</p>
{/* Lower Section: Point Details */}
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-lg">
<h3 className="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4 border-b pb-2 border-gray-200 dark:border-gray-700">
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-lg h-[60vh] sm:h-[70vh] overflow-y-auto">
<h3 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4 border-b pb-2 border-gray-200 dark:border-gray-700">
</h3>
{pointHistory.length === 0 ? (
<p className="text-gray-600 dark:text-gray-400 text-center py-4"></p>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 text-center py-4"></p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{pointHistory
@@ -65,14 +65,14 @@ const PointsOverview: React.FC<PointsOverviewProps> = ({
.map((entry) => (
<li key={entry.transactionId} className="py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-lg font-medium text-gray-900 dark:text-gray-50 break-words">
<p className="text-base sm:text-lg font-medium text-gray-900 dark:text-gray-50 break-words">
{entry.description}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
{new Date(entry.createdAt).toLocaleString()}
</p>
</div>
<div className={`mt-2 sm:mt-0 text-lg font-bold ${
<div className={`mt-2 sm:mt-0 text-base sm:text-lg font-bold ${
entry.pointsChange > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{entry.pointsChange > 0 ? '+' : ''} {entry.pointsChange}

View File

@@ -0,0 +1,41 @@
'use client';
import React from 'react';
import { Share2 } from 'lucide-react';
import { useToast } from './Toast'; // 确保路径正确
import { usePathname } from 'next/navigation'; // next/navigation 用于获取当前路径
interface ShareButtonProps {
className?: string; // 允许外部传入样式
}
const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
const { success, error } = useToast();
const pathname = usePathname(); // 获取当前路由路径
const handleShare = async () => {
console.log('handleShare clicked'); // 添加点击日志
try {
const currentUrl = window.location.origin + pathname; // 构建完整的当前页面 URL
await navigator.clipboard.writeText(currentUrl);
success('复制成功', '页面链接已复制到剪贴板!');
console.log('页面链接已复制:', currentUrl); // 添加成功日志
} catch (err) {
console.error('复制失败:', err); // 保留原有错误日志
error('复制失败', '无法复制页面链接到剪贴板。');
console.error('无法复制页面链接到剪贴板,错误信息:', err); // 添加详细错误日志
}
};
return (
<button
onClick={handleShare}
className={`text-neutral-500 hover:text-black transition-colors text-sm ${className}`}
aria-label="分享页面"
>
<Share2 className="w-5 h-5" />
</button>
);
};
export default ShareButton;

View File

@@ -58,6 +58,7 @@ const Sidebar: React.FC<SidebarProps> = ({
const router = useRouter(); // 初始化 useRouter 钩子
useEffect(() => {
// 首次加载时获取 session
if (!didFetch.current) {
didFetch.current = true; // 标记为已执行,避免在开发模式下重复执行
const fetchSession = async () => {
@@ -67,9 +68,8 @@ const Sidebar: React.FC<SidebarProps> = ({
};
fetchSession();
}
}, []); // 空依赖数组表示只在组件挂载时执行一次
useEffect(() => {
// 检查 session 是否过期
if (session?.expiresAt) {
const expirationTime = session.expiresAt.getTime();
const currentTime = new Date().getTime();
@@ -87,7 +87,7 @@ const Sidebar: React.FC<SidebarProps> = ({
});
}
}
}, [session, router]); // 监听 session 变化和 router因为 signOut 中使用了 router.push
}, [session, router, onCreditsChange]); // 监听 session 变化和 router因为 signOut 中使用了 router.push,并添加 onCreditsChange
const mainNavItems: NavItem[] = [
{ id: 'home', label: '首页', icon: Home },

View File

@@ -1,5 +1,8 @@
import { HttpError } from '@/types';
import type { PodcastGenerationRequest, PodcastGenerationResponse, ApiResponse, PodcastStatusResponse } from '@/types';
import { db } from "@/lib/database";
import * as schema from "../../drizzle-schema";
import { eq } from "drizzle-orm";
const API_BASE_URL = process.env.NEXT_PUBLIC_PODCAST_API_BASE_URL || 'http://192.168.1.232:8000';
@@ -65,4 +68,58 @@ export async function getPodcastStatus(userId: string): Promise<ApiResponse<Podc
const statusCode = error instanceof HttpError ? error.statusCode : undefined;
return { success: false, error: error.message || '获取任务状态失败', statusCode };
}
}
/**
* 处理 GET 请求,用于查询后端 FastAPI 应用的 /get-audio-info 接口。
*
* @param req NextRequest 对象,包含请求信息,如查询参数 file_name。
* @returns 返回从 FastAPI 后端获取的音频信息,或错误响应。
*/
export async function getAudioInfo(fileName: string) {
if (!fileName) {
return { success: false, error: '缺少 file_name 查询参数', statusCode: 400 };
}
try {
const response = await fetch(`${API_BASE_URL}/get-audio-info?file_name=${encodeURIComponent(fileName)}`);
if (!response.ok) {
// 如果后端返回错误状态码,则转发错误信息
const errorData = await response.json();
return { success: false, error: errorData.detail || '获取音频信息失败', statusCode: response.status };
}
const result: PodcastGenerationResponse = await response.json();
result.audioUrl = `/api/audio?filename=${result.output_audio_filepath}`;
return { success: true, data: result };
} catch (error) {
console.error('代理 /get-audio-info 失败:', error);
return { success: false, error: ' 无法连接到后端服务或内部服务器错误 查询参数', statusCode: 500 };
}
}
/**
* 根据用户ID查询用户信息。
* @param userId 用户ID
* @returns Promise<ApiResponse<typeof schema.user.$inferSelect | null>> 返回用户信息或null
*/
export async function getUserInfo(userId: string): Promise<ApiResponse<typeof schema.user.$inferSelect | null>> {
try {
const userInfo = await db
.select()
.from(schema.user)
.where(eq(schema.user.id, userId))
.limit(1);
if (userInfo.length > 0) {
return { success: true, data: userInfo[0] };
} else {
return { success: true, data: null }; // 用户不存在
}
} catch (error: any) {
console.error(`获取用户 ${userId} 信息失败:`, error);
return { success: false, error: error.message || '获取用户信息失败', statusCode: 500 };
}
}

View File

@@ -16,10 +16,10 @@ export interface PodcastGenerationResponse {
id?: string; // 任务ID
status: 'pending' | 'running' | 'completed' | 'failed' ;
task_id?: string;
podUsers?: Array<{ role: string; code: string; }>;
podUsers?: Array<{ role: string; code: string; name: string; usedname: string; }>;
output_audio_filepath?: string;
overview_content?: string;
podcast_script?: { podcast_transcripts: Array<{ speaker_id: number; dialog: string; }>; };
podcast_script?: { podcast_transcripts: Array<{ speaker_id: number; dialog: string; }>; }; // Changed from string to Array
avatar_base64?: string;
audio_duration?: string;
title?: string;
@@ -29,6 +29,14 @@ export interface PodcastGenerationResponse {
audioUrl?: string;
estimatedTime?: number; // 新增预估时间
progress?: number; // 新增进度百分比
auth_id?: string; // 新增 auth_id
callback_url?: string; // 新增 callback_url
user?: {
name: string;
email: string;
image: string;
};
listens?: number;
}
export interface PodcastScript {
@@ -116,12 +124,13 @@ export interface PodcastItem {
name: string;
avatar: string;
};
duration: number;
audio_duration: string;
playCount: number;
createdAt: string;
audioUrl: string;
tags: string[];
status: 'pending' | 'running' | 'completed' | 'failed'; // 添加status属性
file_name: string;
}
// 设置表单数据类型 - 从 SettingsForm.tsx 复制过来并导出

View File

@@ -21,8 +21,8 @@ if %errorlevel% neq 0 (
echo.
echo 3. 启动开发服务器...
echo 应用将在 http://localhost:3000 启动
echo 应用将在 http://localhost:3001 启动
echo 按 Ctrl+C 停止服务器
echo.
npm run dev
set PORT=3001 && npm run dev