feat(config): 添加webvoice配置支持多TTS提供商和优化播客生成流程
新增webvoice.json配置文件,包含大量语音选项,更新TTS适配器以支持多提供商配置,改进播客生成流程中的错误处理和重试机制,优化UI组件以支持新的语音选择功能
This commit is contained in:
@@ -56,7 +56,8 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
const { lang } = use(params);
|
||||
const { t } = useTranslation(lang, 'home');
|
||||
const { toasts, success, error, warning, info, removeToast } = useToast();
|
||||
const { executeOnce } = usePreventDuplicateCall();
|
||||
const { executeOnce: executeOncePodcasts } = usePreventDuplicateCall();
|
||||
const { executeOnce: executeOnceCredits } = usePreventDuplicateCall();
|
||||
const router = useRouter(); // Initialize useRouter
|
||||
|
||||
// 辅助函数:将 API 响应映射为 PodcastItem 数组
|
||||
@@ -72,7 +73,7 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
},
|
||||
audio_duration: task.audio_duration || '00:00',
|
||||
playCount: 0,
|
||||
createdAt: task.timestamp ? new Date(task.timestamp * 1000).toISOString() : new Date().toISOString(),
|
||||
createdAt: task.timestamp ? new Date(task.timestamp * 1000).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] : [t('podcastTagsPlaceholder')],
|
||||
status: task.status,
|
||||
@@ -105,18 +106,11 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
// 播客详情页状态
|
||||
|
||||
// 从后端获取积分数据和初始化数据加载
|
||||
const initialized = React.useRef(false); // 使用 useRef 追踪是否已初始化
|
||||
|
||||
useEffect(() => {
|
||||
// 确保只在组件首次挂载时执行一次
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
|
||||
// 首次加载时获取播客列表和积分/用户信息
|
||||
fetchRecentPodcasts();
|
||||
// fetchCreditsAndUserInfo(); // 在fetchRecentPodcasts中调用
|
||||
|
||||
}
|
||||
console.log('HomePage mounted: 初始化数据加载');
|
||||
// 首次加载时获取播客列表和积分/用户信息
|
||||
fetchRecentPodcasts();
|
||||
// fetchCreditsAndUserInfo(); // 在fetchRecentPodcasts中调用
|
||||
|
||||
// 设置定时器每20秒刷新一次
|
||||
// const interval = setInterval(() => {
|
||||
@@ -124,7 +118,10 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
// }, 20000);
|
||||
|
||||
// // 清理定时器
|
||||
// return () => clearInterval(interval);
|
||||
// return () => {
|
||||
// clearInterval(interval);
|
||||
// console.log('HomePage unmounted: 清理定时器');
|
||||
// };
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
// 加载设置
|
||||
@@ -267,7 +264,7 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
|
||||
// 获取最近播客列表 - 使用防重复调用机制
|
||||
const fetchRecentPodcasts = async () => {
|
||||
const result = await executeOnce(async () => {
|
||||
const result = await executeOncePodcasts(async () => {
|
||||
const response = await trackedFetch('/api/podcast-status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -282,6 +279,7 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
console.log('fetchRecentPodcasts: 重复调用已跳过');
|
||||
return; // 如果是重复调用,直接返回
|
||||
}
|
||||
|
||||
@@ -298,61 +296,83 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
error(t('error.dataProcessing'), err instanceof Error ? err.message : t('error.cantProcessPodcastList'));
|
||||
}
|
||||
|
||||
fetchCreditsAndUserInfo();
|
||||
// 调用积分和用户信息获取(也有防重复机制)
|
||||
await fetchCreditsAndUserInfo();
|
||||
};
|
||||
|
||||
// 新增辅助函数:获取积分和用户信息
|
||||
// 新增辅助函数:获取积分和用户信息 - 使用防重复调用机制
|
||||
const fetchCreditsAndUserInfo = async () => {
|
||||
const result = await executeOnceCredits(async () => {
|
||||
const results = {
|
||||
credits: 0,
|
||||
transactions: [] as any[],
|
||||
user: null as any,
|
||||
};
|
||||
|
||||
// 获取积分
|
||||
try {
|
||||
const pointsResponse = await fetch('/api/points', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-next-locale': lang,
|
||||
},
|
||||
});
|
||||
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
|
||||
}
|
||||
const pointsResponse = await trackedFetch('/api/points', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-next-locale': lang,
|
||||
},
|
||||
});
|
||||
if (pointsResponse.ok) {
|
||||
const data = await pointsResponse.json();
|
||||
if (data.success) {
|
||||
results.credits = data.points;
|
||||
} else {
|
||||
console.error('Failed to fetch credits with status:', pointsResponse.status);
|
||||
setCredits(0); // 获取失败则设置为0
|
||||
console.error('Failed to fetch credits:', data.error);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch credits with status:', pointsResponse.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching credits:', error);
|
||||
setCredits(0); // 发生错误则设置为0
|
||||
console.error('Error fetching credits:', error);
|
||||
}
|
||||
|
||||
// 获取积分历史
|
||||
try {
|
||||
const transactionsResponse = await fetch('/api/points/transactions', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-next-locale': lang,
|
||||
},
|
||||
});
|
||||
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([]);
|
||||
}
|
||||
const transactionsResponse = await trackedFetch('/api/points/transactions', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-next-locale': lang,
|
||||
},
|
||||
});
|
||||
if (transactionsResponse.ok) {
|
||||
const data = await transactionsResponse.json();
|
||||
if (data.success) {
|
||||
results.transactions = data.transactions;
|
||||
} else {
|
||||
console.error('Failed to fetch point transactions with status:', transactionsResponse.status);
|
||||
setPointHistory([]);
|
||||
console.error('Failed to fetch point transactions:', data.error);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch point transactions with status:', transactionsResponse.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching point transactions:', error);
|
||||
setPointHistory([]);
|
||||
console.error('Error fetching point transactions:', error);
|
||||
}
|
||||
|
||||
const { session, user } = await getSessionData();
|
||||
setUser(user); // 设置用户信息
|
||||
// 获取用户信息
|
||||
try {
|
||||
const { session, user } = await getSessionData();
|
||||
results.user = user;
|
||||
} catch (error) {
|
||||
console.error('Error fetching session data:', error);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
console.log('fetchCreditsAndUserInfo: 重复调用已跳过');
|
||||
return; // 如果是重复调用,直接返回
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setCredits(result.credits);
|
||||
setPointHistory(result.transactions);
|
||||
setUser(result.user);
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ const TTS_PROVIDER_ORDER = [
|
||||
'fish-audio',
|
||||
'gemini-tts',
|
||||
'index-tts',
|
||||
'webvoice',
|
||||
];
|
||||
|
||||
// 获取配置文件列表
|
||||
|
||||
@@ -6,18 +6,17 @@ import { fallbackLng } from '@/i18n/settings';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionData = await getSessionData();
|
||||
let baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "/";
|
||||
const pathname = request.nextUrl.searchParams.get('pathname');
|
||||
if(!!pathname){
|
||||
baseUrl += pathname.replace('/','');
|
||||
}
|
||||
const pathname = request.nextUrl.searchParams.get('pathname') || '';
|
||||
|
||||
// 如果没有获取到 session,直接重定向到根目录
|
||||
// 如果没有获取到 session,直接重定向
|
||||
if (!sessionData?.user) {
|
||||
const url = new URL(baseUrl, request.url);
|
||||
const url = new URL(request.url);
|
||||
url.pathname = pathname || '/';
|
||||
url.search = '';
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
|
||||
const lng = !pathname ? fallbackLng : pathname.replace('/','');
|
||||
const { t } = await getTranslation(lng, 'components');
|
||||
const userId = sessionData.user.id; // 获取 userId
|
||||
@@ -41,8 +40,11 @@ export async function GET(request: NextRequest) {
|
||||
console.log(t('newUser.pointsAccountExists', { userId }));
|
||||
}
|
||||
|
||||
// 创建一个 URL 对象,指向要重定向到的根目录
|
||||
const url = new URL(baseUrl, request.url);
|
||||
// 构建重定向 URL
|
||||
const url = new URL(request.url);
|
||||
url.pathname = pathname ? `${pathname}/` : '/';
|
||||
url.search = '';
|
||||
|
||||
// 返回重定向响应
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
|
||||
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
|
||||
const [selectedConfig, setSelectedConfig] = useState<string>('');
|
||||
const [selectedConfig, setSelectedConfig] = useState<string>();
|
||||
const [currentConfig, setCurrentConfig] = useState<TTSConfig | null>(null);
|
||||
const [voices, setVoices] = useState<Voice[]>([]); // 新增 voices 状态
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -52,6 +52,9 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
|
||||
return !!(settings.minimax?.group_id && settings.minimax?.api_key);
|
||||
case 'gemini':
|
||||
return !!(settings.gemini?.api_key);
|
||||
case 'webvoice':
|
||||
// webvoice 使用浏览器内置的 Web Speech API,无需额外配置
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -95,36 +98,43 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
|
||||
loadConfigFilesCalled.current = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-next-locale': lang,
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
// const response = await fetch('/api/config', {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// 'x-next-locale': lang,
|
||||
// },
|
||||
// });
|
||||
// const result = await response.json();
|
||||
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
// if (result.success && Array.isArray(result.data)) {
|
||||
// 过滤出已配置的TTS选项
|
||||
const settings = await getTTSProviders(lang);
|
||||
const availableConfigs = result.data.filter((config: ConfigFile) =>
|
||||
isTTSConfigured(config.name, settings)
|
||||
);
|
||||
|
||||
// const settings = await getTTSProviders(lang);
|
||||
// const availableConfigs = result.data.filter((config: ConfigFile) =>
|
||||
// isTTSConfigured(config.name, settings)
|
||||
// );
|
||||
|
||||
const availableConfigs = [
|
||||
{
|
||||
name: 'webvoice.json',
|
||||
displayName: 'webvoice',
|
||||
path: 'webvoice.json',
|
||||
},
|
||||
];
|
||||
setConfigFiles(availableConfigs);
|
||||
// 默认选择第一个可用配置
|
||||
if (availableConfigs.length > 0 && !selectedConfig) {
|
||||
setSelectedConfig(availableConfigs[0].name);
|
||||
loadConfig(availableConfigs[0].name);
|
||||
setSelectedConfig(availableConfigs[0].name);
|
||||
loadConfig(availableConfigs[0].name);
|
||||
} else if (availableConfigs.length === 0) {
|
||||
// 如果没有可用配置,清空当前选择
|
||||
setSelectedConfig('');
|
||||
setCurrentConfig(null);
|
||||
onConfigChange?.(null as any, '', []); // 传递空数组作为 voices
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid config files data:', result);
|
||||
setConfigFiles([]);
|
||||
}
|
||||
// } else {
|
||||
// console.error('Invalid config files data:', result);
|
||||
// setConfigFiles([]);
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error('Failed to process config files:', error);
|
||||
setConfigFiles([]);
|
||||
@@ -163,56 +173,61 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<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 ? t('configSelector.loading') : selectedConfigFile?.displayName || (configFiles.length === 0 ? t('configSelector.pleaseConfigTTS') : t('configSelector.selectTTSConfig'))}
|
||||
</span>
|
||||
{/* <ChevronDown className={cn(
|
||||
"w-4 h-4 text-neutral-400 transition-transform",
|
||||
isOpen && "rotate-180"
|
||||
)} /> */}
|
||||
</button>
|
||||
{/* 隐藏TTS选择按钮 */}
|
||||
{false && (
|
||||
<>
|
||||
{/* 配置选择器 */}
|
||||
<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 ? t('configSelector.loading') : selectedConfigFile?.displayName || (configFiles.length === 0 ? t('configSelector.pleaseConfigTTS') : t('configSelector.selectTTSConfig'))}
|
||||
</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 && (
|
||||
<AiOutlineCheck 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">{t('configSelector.noAvailableTTSConfig')}</div>
|
||||
<div className="text-xs">{t('configSelector.pleaseConfigTTS')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedConfig === config.name && (
|
||||
<AiOutlineCheck 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">{t('configSelector.noAvailableTTSConfig')}</div>
|
||||
<div className="text-xs">{t('configSelector.pleaseConfigTTS')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
|
||||
{/* 点击外部关闭下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 点击外部关闭下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ const ConfirmModal: FC<ConfirmModalProps> = ({
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm font-medium text-white bg-gradient-to-r from-brand-purple to-brand-pink hover:from-brand-purple-hover hover:to-brand-pink focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-purple transition-all"
|
||||
className="px-4 py-2 border-transparent rounded-md shadow-sm font-medium text-white bg-gradient-to-r from-brand-purple to-brand-pink hover:from-brand-purple-hover hover:to-brand-pink focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-purple transition-all"
|
||||
>
|
||||
{confirmText || t('podcastCreator.confirm')}
|
||||
</button>
|
||||
|
||||
@@ -17,17 +17,24 @@ const NotificationBanner: React.FC<NotificationBannerProps> = ({
|
||||
lang
|
||||
}) => {
|
||||
const { t } = useTranslation(lang, 'components');
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(false); // 初始为 false 避免水合错误
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// 从本地存储获取通知状态,避免重复显示
|
||||
// 组件挂载后再检查 localStorage
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const hasClosed = localStorage.getItem('notificationBannerClosed');
|
||||
if (hasClosed) {
|
||||
setIsVisible(false);
|
||||
if (!hasClosed) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 在挂载前不渲染任何内容,避免水合不匹配
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
// 添加关闭动画
|
||||
|
||||
@@ -128,11 +128,17 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false); // 控制确认模态框的显示
|
||||
const [voices, setVoices] = useState<Voice[]>([]); // 从 ConfigSelector 获取 voices
|
||||
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => {
|
||||
// 从 localStorage 读取缓存的说话人配置
|
||||
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>({}); // 初始为空对象,避免水合错误
|
||||
const [isVoicesLoaded, setIsVoicesLoaded] = useState(false);
|
||||
|
||||
// 组件挂载后从 localStorage 加载说话人配置
|
||||
useEffect(() => {
|
||||
const cachedVoices = getItem<{[key: string]: Voice[]}>('podcast-selected-voices');
|
||||
return cachedVoices || {};
|
||||
}); // 新增:单独存储选中的说话人
|
||||
if (cachedVoices) {
|
||||
setSelectedPodcastVoices(cachedVoices);
|
||||
}
|
||||
setIsVoicesLoaded(true);
|
||||
}, []);
|
||||
const [selectedConfig, setSelectedConfig] = useState<TTSConfig | null>(null);
|
||||
const [selectedConfigName, setSelectedConfigName] = useState<string>(''); // 新增状态来存储配置文件的名称
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -367,158 +373,158 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-start sm:justify-between px-4 sm:px-6 py-3 border-t border-neutral-100 bg-neutral-50 gap-y-4 sm:gap-x-2">
|
||||
{/* 左侧配置选项 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 w-full sm:max-w-[500px]">
|
||||
{/* TTS配置选择 */}
|
||||
<div className='relative w-full'>
|
||||
<ConfigSelector
|
||||
onConfigChange={(config, name, newVoices) => { // 接收新的 voices 参数
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between px-4 sm:px-6 py-4 border-t border-neutral-100 bg-gradient-to-br from-neutral-50 to-white gap-4">
|
||||
{/* 隐藏的 TTS 配置选择器 */}
|
||||
<div className="hidden">
|
||||
<ConfigSelector
|
||||
onConfigChange={(config, name, newVoices) => {
|
||||
setSelectedConfig(config);
|
||||
setSelectedConfigName(name); // 更新配置名称状态
|
||||
setVoices(newVoices); // 更新 voices 状态
|
||||
setSelectedConfigName(name);
|
||||
setVoices(newVoices);
|
||||
}}
|
||||
className="w-full"
|
||||
lang={lang} // 传递 lang
|
||||
/></div>
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 说话人按钮 */}
|
||||
<div className='relative w-full'>
|
||||
<button
|
||||
{/* 左侧配置选项 */}
|
||||
<div className="flex flex-wrap gap-2 lg:gap-3 justify-center lg:justify-start items-center">
|
||||
{/* 说话人按钮 */}
|
||||
<button
|
||||
onClick={() => setShowVoicesModal(true)}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg text-sm",
|
||||
"w-[120px] px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-sm hover:shadow-md",
|
||||
selectedPodcastVoices[selectedConfigName] && selectedPodcastVoices[selectedConfigName].length > 0
|
||||
? "w-full bg-black text-white"
|
||||
: "btn-secondary w-full"
|
||||
? "bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:from-purple-700 hover:to-pink-700"
|
||||
: "bg-white border border-neutral-200 text-neutral-700 hover:border-neutral-300 hover:bg-neutral-50",
|
||||
(isGenerating || !selectedConfig) && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
disabled={isGenerating || !selectedConfig}
|
||||
>
|
||||
>
|
||||
{t('podcastCreator.speaker')}
|
||||
</button></div>
|
||||
</button>
|
||||
|
||||
{/* 语言选择 */}
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="appearance-none bg-white border border-neutral-200 rounded-lg px-3 py-2 sm:px-3 sm:py-2 pr-6 sm:pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-black w-full text-center"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{languageOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AiOutlineDown className="absolute right-1 sm:right-2 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 text-neutral-400 pointer-events-none" />
|
||||
</div>
|
||||
{/* 语言选择 */}
|
||||
<div className="relative w-[120px]">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="appearance-none w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 pr-8 text-sm font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 shadow-sm hover:shadow-md hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{languageOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AiOutlineDown className="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* 时长选择 */}
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value as any)}
|
||||
className="appearance-none bg-white border border-neutral-200 rounded-lg px-3 py-2 sm:px-3 sm:py-2 pr-6 sm:pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-black w-full text-center"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{durationOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AiOutlineDown className="absolute right-1 sm:right-2 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 text-neutral-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 时长选择 */}
|
||||
<div className="relative w-[120px]">
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value as any)}
|
||||
className="appearance-none w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 pr-8 text-sm font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 shadow-sm hover:shadow-md hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{durationOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AiOutlineDown className="absolute right-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* 右侧操作按钮 todo */}
|
||||
<div className="flex items-center gap-6 sm:gap-1 flex-wrap justify-center sm:justify-right w-full sm:w-auto">
|
||||
{/* 文件上传 */}
|
||||
{/* <button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
|
||||
title={t('podcastCreator.fileUpload')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<AiOutlineUpload className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.doc,.docx"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/> */}
|
||||
|
||||
{/* 粘贴链接 */}
|
||||
{/* <button
|
||||
onClick={handlePaste}
|
||||
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
|
||||
title={t('podcastCreator.pasteContent')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<AiOutlineLink className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button> */}
|
||||
|
||||
{/* 复制 */}
|
||||
{/* <button
|
||||
onClick={() => navigator.clipboard.writeText(topic)}
|
||||
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
|
||||
title={t('podcastCreator.copyContent')}
|
||||
disabled={isGenerating || !topic}
|
||||
>
|
||||
<AiOutlineCopy className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button> */}
|
||||
|
||||
{/* 积分显示 */}
|
||||
<div className="flex items-center justify-center gap-1 text-xs text-neutral-500 w-20 flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-gem flex-shrink-0">
|
||||
{/* 积分显示 */}
|
||||
<div className="w-[120px] flex items-center justify-center gap-1.5 px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-purple-600 flex-shrink-0">
|
||||
<path d="M6 3v18l6-4 6 4V3z"/>
|
||||
<path d="M12 3L20 9L12 15L4 9L12 3Z"/>
|
||||
</svg>
|
||||
<span className="truncate">{credits}</span>
|
||||
<span className="text-sm font-semibold text-neutral-700">{credits}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center justify-center lg:justify-end gap-2 lg:gap-3 flex-shrink-0">
|
||||
{/* 文件上传 */}
|
||||
{/* <button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
|
||||
title={t('podcastCreator.fileUpload')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<AiOutlineUpload className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.doc,.docx"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/> */}
|
||||
|
||||
{/* 粘贴链接 */}
|
||||
{/* <button
|
||||
onClick={handlePaste}
|
||||
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
|
||||
title={t('podcastCreator.pasteContent')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<AiOutlineLink className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button> */}
|
||||
|
||||
{/* 复制 */}
|
||||
{/* <button
|
||||
onClick={() => navigator.clipboard.writeText(topic)}
|
||||
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
|
||||
title={t('podcastCreator.copyContent')}
|
||||
disabled={isGenerating || !topic}
|
||||
>
|
||||
<AiOutlineCopy className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button> */}
|
||||
{/* 签到按钮 */}
|
||||
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
disabled={isGenerating}
|
||||
className={cn(
|
||||
"btn-secondary flex items-center gap-1 text-sm px-3 py-2 sm:px-4 sm:py-2",
|
||||
"flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-sm hover:shadow-md",
|
||||
"bg-white border border-neutral-200 text-neutral-700 hover:border-neutral-300 hover:bg-neutral-50",
|
||||
isGenerating && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{t('podcastCreator.checkIn')}
|
||||
{t('podcastCreator.checkIn')}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{/* 创作按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!topic.trim() || isGenerating}
|
||||
className={cn(
|
||||
"btn-primary flex items-center gap-1 text-sm px-3 py-2 sm:px-4 sm:py-2",
|
||||
"flex items-center gap-1.5 px-5 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg",
|
||||
"bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:from-purple-700 hover:to-pink-700",
|
||||
(!topic.trim() || isGenerating) && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<AiOutlineLoading3Quarters className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
<span className=" xs:inline">{t('podcastCreator.biu')}</span>
|
||||
<AiOutlineLoading3Quarters className="w-4 h-4 animate-spin" />
|
||||
<span>{t('podcastCreator.biu')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className=" xs:inline">{t('podcastCreator.create')}</span>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>{t('podcastCreator.create')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voices Modal */}
|
||||
{selectedConfig && (
|
||||
|
||||
@@ -198,11 +198,16 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
|
||||
audio.pause();
|
||||
setPlayingVoiceId(null);
|
||||
} else {
|
||||
// 先暂停其他正在播放的音频
|
||||
if (playingVoiceId && audioRefs.current.has(playingVoiceId)) {
|
||||
audioRefs.current.get(playingVoiceId)?.pause();
|
||||
}
|
||||
audio.play();
|
||||
setPlayingVoiceId(voice.code!);
|
||||
// 尝试播放音频,处理可能的失败
|
||||
audio.play().catch((error) => {
|
||||
console.error('音频播放失败:', error);
|
||||
setPlayingVoiceId(null);
|
||||
});
|
||||
// 注意:状态会在 onPlay 事件中设置,而不是在这里
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -216,8 +221,18 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
|
||||
else audioRefs.current.delete(voice.code!);
|
||||
}}
|
||||
src={voice.audio}
|
||||
onPlay={() => setPlayingVoiceId(voice.code!)}
|
||||
onEnded={() => setPlayingVoiceId(null)}
|
||||
onPause={() => setPlayingVoiceId(null)}
|
||||
onPause={(e) => {
|
||||
// 只在音频真正暂停时清除状态(不是因为切换到其他音频)
|
||||
if (playingVoiceId === voice.code) {
|
||||
setPlayingVoiceId(null);
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
console.error('音频加载失败:', voice.audio);
|
||||
setPlayingVoiceId(null);
|
||||
}}
|
||||
preload="none"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user