feat: 新增播客详情页及相关功能组件
实现播客详情页功能,包括: 1. 新增 PodcastContent 组件展示播客详情 2. 添加 AudioPlayerControls 和 PodcastTabs 组件 3. 实现分享功能组件 ShareButton 4. 优化音频文件命名规则和缓存机制 5. 完善类型定义和 API 接口 6. 调整 UI 布局和响应式设计 7. 修复积分不足状态码问题
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ output/
|
||||
excalidraw.log
|
||||
config/tts_providers-local.json
|
||||
.claude
|
||||
.serena
|
||||
.serena
|
||||
/node_modules
|
||||
73
main.py
73
main.py
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
1079
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
65
web/src/app/api/audio-info/route.ts
Normal file
65
web/src/app/api/audio-info/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 - 权限不足,因为积分不足
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
web/src/app/podcast/[fileName]/page.tsx
Normal file
15
web/src/app/podcast/[fileName]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('播放链接已复制到剪贴板!');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
55
web/src/components/AudioPlayerControls.tsx
Normal file
55
web/src/components/AudioPlayerControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
27
web/src/components/MarkdownRenderer.tsx
Normal file
27
web/src/components/MarkdownRenderer.tsx
Normal 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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
123
web/src/components/PodcastContent.tsx
Normal file
123
web/src/components/PodcastContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
web/src/components/PodcastTabs.tsx
Normal file
66
web/src/components/PodcastTabs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
41
web/src/components/ShareButton.tsx
Normal file
41
web/src/components/ShareButton.tsx
Normal 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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 复制过来并导出
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user