feat: 添加定价页面组件和图标库迁移

refactor: 替换lucide-react为react-icons
feat(定价): 实现定价页面、卡片和切换组件
feat(页脚): 添加页脚链接组件
feat(文档): 新增使用条款、隐私政策和联系页面
style: 更新Toast组件样式和动画
chore: 更新项目元数据和favicon
This commit is contained in:
hex2077
2025-08-19 22:50:34 +08:00
parent 47668b8a74
commit a7ef2d6606
33 changed files with 1180 additions and 301 deletions

View File

@@ -21,8 +21,9 @@ export async function GET(request: NextRequest) {
if (!userHasPointsAccount) {
console.log(`用户 ${userId} 不存在积分账户,正在初始化...`);
try {
await createPointsAccount(userId, 100); // 调用封装的创建积分账户函数
await recordPointsTransaction(userId, 100, "initial_bonus", "新用户注册,初始积分奖励"); // 调用封装的记录流水函数
const pointsPerPodcastDay = parseInt(process.env.POINTS_PER_PODCAST_DAY || '100', 10);
await createPointsAccount(userId, pointsPerPodcastDay); // 调用封装的创建积分账户函数
await recordPointsTransaction(userId, pointsPerPodcastDay, "initial_bonus", "新用户注册,初始积分奖励"); // 调用封装的记录流水函数
} catch (error) {
console.error(`初始化用户 ${userId} 积分账户或记录流水失败:`, error);
// 根据错误类型,可能需要更详细的错误处理或重定向

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Metadata } from 'next';
import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiFillMail } from 'react-icons/ai';
/**
* 设置页面元数据。
*/
export const metadata: Metadata = {
title: '联系我们 - PodcastHub',
description: '有任何问题或建议?请随时联系 PodcastHub 团队。我们期待您的声音。',
};
/**
* 联系我们页面组件。
* 优化后的版本,移除了联系表单,专注于清晰地展示联系方式。
* 采用单栏居中布局,设计简洁、现代。
*/
const ContactUsPage: React.FC = () => {
return (
<div className="bg-gray-50 min-h-screen py-12 sm:py-16">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="p-8 md:p-12">
<header className="text-center mb-10">
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900 tracking-tight">
</h1>
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
</p>
</header>
<div className="space-y-12">
{/* 电子邮件 */}
<div className="text-center">
<div className="inline-flex items-center justify-center bg-blue-100 rounded-full p-3 mb-4">
<AiFillMail className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
</h2>
<p className="text-gray-600">
<a
href="mailto:support@podcasthub.com"
className="block text-blue-600 hover:text-blue-700 transition-colors break-all mt-1 font-medium"
>
justlikemaki@foxmail.com
</a>
</p>
</div>
{/* 社交媒体 */}
<div className="text-center">
<div className="inline-flex items-center justify-center bg-blue-100 rounded-full p-3 mb-4">
<AiFillQqCircle className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
</h2>
<p className="text-gray-600 mb-4">
</p>
<div className="flex justify-center space-x-6">
<a
href="https://github.com/justlovemaki"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-blue-700 transition-colors"
aria-label="Github"
>
<AiOutlineGithub className="w-9 h-9" />
</a>
<a
href="https://x.com/justlikemaki"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-blue-500 transition-colors"
aria-label="Twitter"
>
<AiOutlineTwitter className="w-9 h-9" />
</a>
<a
href="https://cdn.jsdmirror.com/gh/justlovemaki/imagehub@main/logo/7fc30805eeb831e1e2baa3a240683ca3.md.png"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-blue-500 transition-colors"
aria-label="Douyin"
>
<AiOutlineTikTok className="w-9 h-9" />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ContactUsPage;

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import FooterLinks from '../components/FooterLinks';
const inter = Inter({
subsets: ['latin'],
@@ -9,18 +10,18 @@ const inter = Inter({
});
export const metadata: Metadata = {
title: 'PodcastHub - 把你的创意转为播客',
title: 'PodcastHub - 给创意一个真实的声音',
description: '使用AI技术将您的想法和内容转换为高质量的播客音频支持多种语音和风格选择。',
keywords: ['播客', 'AI', '语音合成', 'TTS', '音频生成'],
authors: [{ name: 'PodcastHub Team' }],
viewport: 'width=device-width, initial-scale=1',
themeColor: '#000000',
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png',
icon: '/favicon.webp',
apple: '/favicon.webp',
},
openGraph: {
title: 'PodcastHub - 把你的创意转为播客',
title: 'PodcastHub - 给创意一个真实的声音',
description: '使用AI技术将您的想法和内容转换为高质量的播客音频',
type: 'website',
locale: 'zh_CN',
@@ -46,6 +47,9 @@ export default function RootLayout({
<div id="toast-root" />
{/* Modal容器 */}
<div id="modal-root" />
<footer className="py-8">
<FooterLinks />
</footer>
</body>
</html>
);

View File

@@ -15,6 +15,7 @@ import { trackedFetch } from '@/utils/apiCallTracker';
import type { PodcastGenerationRequest, PodcastItem, UIState, PodcastGenerationResponse, SettingsFormData } from '@/types';
import { getTTSProviders } from '@/lib/config';
import { getSessionData } from '@/lib/server-actions';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
@@ -347,6 +348,9 @@ export default function HomePage() {
/>
)}
{/* 定价部分 */}
{/* <PricingSection /> */}
{/* 推荐播客 - 水平滚动 */}
{/* <ContentSection
title="为你推荐"

View File

@@ -0,0 +1,12 @@
'use client';
import React from 'react';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
const PricingPage: React.FC = () => {
return (
<PricingSection />
);
};
export default PricingPage;

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { Metadata } from 'next';
/**
* 设置页面元数据。
*/
export const metadata: Metadata = {
title: '隐私政策 - PodcastHub',
description: '了解 PodcastHub 如何保护您的隐私。我们致力于透明化地处理您的数据。',
};
/**
* 隐私政策页面组件。
* 提供了详细的隐私政策说明,涵盖信息收集、使用、共享、安全及用户权利。
* 布局采用 Tailwind CSS 进行优化,`prose` 类用于美化排版,`break-words` 确保内容不会溢出容器。
*/
const PrivacyPolicyPage: React.FC = () => {
return (
<div className="bg-gray-50 min-h-screen py-12 sm:py-16">
<div className="container mx-auto p-6 md:p-8 max-w-4xl bg-white shadow-lg rounded-lg">
<article className="prose max-w-full break-words">
<h1 className="text-4xl font-extrabold mb-6 text-gray-900 border-b pb-4">
PodcastHub
</h1>
<p className="text-gray-600">2025821</p>
<p>
PodcastHub使使
</p>
<h2 id="info-we-collect">1. </h2>
<p></p>
<ul>
<li>
<strong></strong>
使
</li>
<li>
<strong></strong>
使 IP
访使
</li>
<li>
<strong>Cookies </strong>
使 Cookies
使
Cookies
</li>
</ul>
<h2 id="how-we-use-info">2. 使</h2>
<p>使</p>
<ul>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
使
</li>
<li>
<strong></strong>
退
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
</ul>
<h2 id="info-sharing">3. </h2>
<p>
</p>
<ul>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
</ul>
<h2 id="data-security">4. </h2>
<p>
访使访
100%
</p>
<h2 id="user-rights">5. </h2>
<p>
访使
</p>
<h2 id="policy-changes">6. </h2>
<p>
</p>
<h2 id="contact-us">7. </h2>
<p>
</p>
</article>
</div>
</div>
);
};
export default PrivacyPolicyPage;

116
web/src/app/terms/page.tsx Normal file
View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Metadata } from 'next';
/**
* 设置页面元数据。
*/
export const metadata: Metadata = {
title: '使用条款 - PodcastHub',
description: '欢迎了解 PodcastHub 的使用条款。本条款旨在保护用户与平台的共同利益。',
};
/**
* 使用条款页面组件。
* 提供了详细的服务条款,涵盖账户、内容、知识产权、免责声明等关键方面。
* 布局采用 Tailwind CSS 进行优化,确保在各种设备上都有良好的可读性。
* `prose` 类用于优化排版,`break-words` 确保长文本能正确换行,防止布局破坏。
*/
const TermsOfServicePage: React.FC = () => {
return (
<div className="bg-gray-50 min-h-screen py-12 sm:py-16">
<div className="container mx-auto p-6 md:p-8 max-w-4xl bg-white shadow-lg rounded-lg">
<article className="prose max-w-full break-words">
<h1 className="text-4xl font-extrabold mb-6 text-gray-900 border-b pb-4">
PodcastHub 使
</h1>
<p className="text-gray-600">2025821</p>
<p>
使 PodcastHub AI
使使访使
</p>
<h2 id="service-overview">1. </h2>
<p>
PodcastHub
TTS
</p>
<h2 id="user-account">2. </h2>
<p>
使使
</p>
<h2 id="user-conduct">3. </h2>
<p>使</p>
<ul>
<li>
</li>
<li>
</li>
<li>
</li>
<li>
</li>
<li>
</li>
</ul>
<h2 id="intellectual-property">4. </h2>
<p>
<strong></strong>
PodcastHub
使
</p>
<p>
<strong></strong>
</p>
<p>
<strong></strong>
PodcastHub
使
</p>
<h2 id="limitation-of-liability">5. </h2>
<p>
</p>
<p>
PodcastHub
访使
</p>
<h2 id="termination">6. </h2>
<p>
访使
</p>
<h2 id="modification">7. </h2>
<p>
30
</p>
<h2 id="governing-law">8. </h2>
<p>
</p>
<h2 id="contact">9. </h2>
<p>
</p>
</article>
</div>
</div>
);
};
export default TermsOfServicePage;

View File

@@ -2,17 +2,17 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Play,
Pause,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Download,
Share2,
ChevronDown, // 用于收起播放器
ChevronUp, // 用于展开播放器
} from 'lucide-react';
AiFillPlayCircle,
AiFillPauseCircle,
AiOutlineStepBackward,
AiOutlineStepForward,
AiOutlineSound,
AiOutlineMuted,
AiOutlineCloudDownload,
AiOutlineShareAlt,
AiOutlineDown, // 用于收起播放器
AiOutlineUp, // 用于展开播放器
} from 'react-icons/ai';
import { cn, formatTime, downloadFile } from '@/lib/utils';
import AudioVisualizer from './AudioVisualizer';
import { useIsSmallScreen } from '@/hooks/useMediaQuery'; // 导入新的 Hook
@@ -238,15 +238,16 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
<button
onClick={togglePlayPause}
disabled={isLoading}
className="w-8 h-8 flex-shrink-0 bg-black text-white rounded-full flex items-center justify-center hover:bg-neutral-800 transition-colors disabled:opacity-50"
className="w-8 h-8 flex-shrink-0 bg-white text-black rounded-full flex items-center justify-center hover:bg-neutral-400 transition-colors disabled:opacity-50"
title={isPlaying ? "暂停" : "播放"}
>
{isLoading ? (
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
<div className="w-8 h-8 border-2 border-black border-t-transparent rounded-full animate-spin" />
) : isPlaying ? (
<Pause className="w-3 h-3" />
<AiFillPauseCircle className="w-8 h-8" />
) : (
<Play className="w-3 h-3 ml-0.5" />
<AiFillPlayCircle className="w-8 h-8" />
)}
</button>
@@ -305,14 +306,14 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="后退10秒"
>
<SkipBack className="w-4 h-4" />
<AiOutlineStepBackward className="w-4 h-4" />
</button>
<button
onClick={() => skipTime(10)}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="前进10秒"
>
<SkipForward className="w-4 h-4" />
<AiOutlineStepForward className="w-4 h-4" />
</button>
{/* 倍速控制按钮 */}
@@ -340,9 +341,9 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
title={isMuted ? "取消静音" : "静音"}
>
{isMuted || playerState.volume === 0 ? (
<VolumeX className="w-4 h-4" />
<AiOutlineMuted className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
<AiOutlineSound className="w-4 h-4" />
)}
</button>
<input
@@ -362,7 +363,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="分享"
>
<Share2 className="w-4 h-4" />
<AiOutlineShareAlt className="w-4 h-4" />
</button>
<button
@@ -370,7 +371,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="下载"
>
<Download className="w-4 h-4" />
<AiOutlineCloudDownload className="w-4 h-4" />
</button>
</>
)}
@@ -392,9 +393,9 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
disabled={isSmallScreen && effectiveIsCollapsed} // 当 effectiveIsCollapsed 为 true 且是小屏幕时禁用
>
{effectiveIsCollapsed ? ( // 根据 effectiveIsCollapsed 决定显示哪个图标
<ChevronUp className="w-4 h-4" />
<AiOutlineUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
<AiOutlineDown className="w-4 h-4" />
)}
</button>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Play, Pause } from 'lucide-react';
import { AiFillPlayCircle, AiFillPauseCircle } from 'react-icons/ai';
interface AudioPlayerControlsProps {
audioUrl: string;
@@ -43,9 +43,9 @@ export default function AudioPlayerControls({ audioUrl, audioDuration }: AudioPl
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" />
<AiFillPauseCircle className="w-5 h-5" />
) : (
<Play className="w-5 h-5" />
<AiFillPlayCircle className="w-5 h-5" />
)}
<span>{isPlaying ? '暂停' : '播放'} ({audioDuration ?? '00:00'})</span>
</button>

View File

@@ -0,0 +1,40 @@
import React from 'react';
interface BillingToggleProps {
billingPeriod: 'monthly' | 'annually';
onToggle: (period: 'monthly' | 'annually') => void;
}
const BillingToggle: React.FC<BillingToggleProps> = ({ billingPeriod, onToggle }) => {
return (
<div className="relative flex items-center justify-center p-1 bg-neutral-100 rounded-full shadow-inner-sm">
<button
type="button"
onClick={() => onToggle('monthly')}
className={`
relative z-10 px-6 py-2 rounded-full text-sm font-semibold transition-all duration-300 ease-in-out
${billingPeriod === 'monthly' ? 'bg-white text-neutral-900 shadow-medium' : 'text-neutral-500'}
`}
>
</button>
<button
type="button"
onClick={() => onToggle('annually')}
className={`
relative z-10 px-6 py-2 rounded-full text-sm font-semibold transition-all duration-300 ease-in-out
${billingPeriod === 'annually' ? 'bg-white text-neutral-900 shadow-medium' : 'text-neutral-500'}
`}
>
</button>
{billingPeriod === 'annually' && (
<span className="absolute right-0 mr-4 ml-2 -translate-y-1/2 top-1/2 px-3 py-1 bg-[#FCE7F3] text-[#F381AA] rounded-full text-xs font-semibold whitespace-nowrap hidden sm:inline-block">
20%
</span>
)}
</div>
);
};
export default BillingToggle;

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Check } from 'lucide-react';
import { AiOutlineCheck } from 'react-icons/ai';
import type { TTSConfig, Voice } from '@/types';
import { getTTSProviders } from '@/lib/config';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
@@ -184,7 +184,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
</div>
</div>
{selectedConfig === config.name && (
<Check className="w-4 h-4 text-green-500" />
<AiOutlineCheck className="w-4 h-4 text-green-500" />
)}
</button>
)) : (

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { ChevronRight, RotateCw } from 'lucide-react';
import { AiOutlineRight, AiOutlineReload } from 'react-icons/ai';
import PodcastCard from './PodcastCard';
import type { PodcastItem } from '@/types'; // 移除了 PodcastGenerationResponse
@@ -88,7 +88,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
>
<ChevronRight className="w-4 h-4" />
<AiOutlineRight className="w-4 h-4" />
</button>
)}
</div>
@@ -116,7 +116,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap"
title="刷新"
>
<RotateCw className="w-4 h-4" />
<AiOutlineReload className="w-4 h-4" />
</button>
)}
@@ -126,7 +126,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap"
>
<ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
<AiOutlineRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</button>
)}
</div>

View File

@@ -0,0 +1,34 @@
import Link from 'next/link';
import React from 'react';
/**
* FooterLinks 组件用于展示页脚的法律和联系链接。
* 采用了 Next.js 的 Link 组件进行客户端路由,并使用 Tailwind CSS 进行样式布局。
*
* @returns {React.FC} 包含链接布局的 React 函数组件。
*/
const FooterLinks: React.FC = () => {
const links = [
{ href: '/terms', label: '使用条款' },
{ href: '/privacy', label: '隐私政策' },
{ href: '/contact', label: '联系我们' },
{ href: '#', label: '© 2025 Hex2077' },
];
return (
<div className="w-full">
{/* 分隔符 */}
<div className="border-t border-gray-200 pt-6 mt-6"></div>
<nav className="flex flex-wrap justify-center gap-x-4 gap-y-2 text-sm text-gray-500">
{/* 遍历链接数组,为每个链接创建 Link 组件 */}
{links.map((link) => (
<Link key={link.href} href={link.href} target="_blank" className="hover:text-gray-900 transition-colors duration-200">
{link.label}
</Link>
))}
</nav>
</div>
);
};
export default FooterLinks;

View File

@@ -5,7 +5,7 @@ import React, { FC, MouseEventHandler, useCallback, useRef } from "react";
import { signIn } from '@/lib/auth-client';
import { createPortal } from "react-dom";
import { XMarkIcon } from "@heroicons/react/24/outline"; // 导入关闭图标
import { Chrome, Github } from "lucide-react"; // 从 lucide-react 导入 Google 和 GitHub 图标
import { AiOutlineChrome, AiOutlineGithub } from "react-icons/ai"; // 从 react-icons/ai 导入 Google 和 GitHub 图标
interface LoginModalProps {
isOpen: boolean;
@@ -58,7 +58,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
onClick={() => signIn.social({ provider: "google" , newUserCallbackURL: "/api/newuser?provider=google"})}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-lg font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<Chrome className="h-6 w-6" />
<AiOutlineChrome className="h-6 w-6" />
<span className="text-lg">使 Google </span>
</button>
@@ -66,7 +66,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
onClick={() => signIn.social({ provider: "github" , newUserCallbackURL: "/api/newuser?provider=github" })}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-lg font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<Github className="h-6 w-6" />
<AiOutlineGithub className="h-6 w-6" />
<span className="text-lg">使 GitHub </span>
</button>
</div>

View File

@@ -2,7 +2,7 @@
import React, { useState } from 'react';
import Image from 'next/image';
import { Play, Pause, Clock, Eye, User, Heart, MoreHorizontal } from 'lucide-react';
import { AiFillPlayCircle, AiFillPauseCircle, AiOutlineClockCircle, AiOutlineEye, AiOutlineUser, AiFillHeart, AiOutlineEllipsis } from 'react-icons/ai';
import { cn, formatTime, formatRelativeTime } from '@/lib/utils';
import type { PodcastItem } from '@/types';
@@ -73,7 +73,6 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-6 h-6 text-white" />
</div>
)}
@@ -81,12 +80,12 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
<button
onClick={handlePlayClick}
className="w-8 h-8 bg-white/90 hover:bg-white rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-200"
className="w-8 h-8 rounded-full flex items-center justify-center transform scale-100 hover:scale-100 transition-all duration-200"
>
{isCurrentlyPlaying ? (
<Pause className="w-3 h-3 text-black" />
<AiFillPauseCircle className="w-full h-full text-white" />
) : (
<Play className="w-3 h-3 text-black ml-0.5" />
<AiFillPlayCircle className="w-full h-full text-white" />
)}
</button>
</div>
@@ -105,11 +104,11 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
</p>
<div className="flex items-center gap-3 text-xs text-neutral-500">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<AiOutlineClockCircle className="w-3 h-3" />
{podcast.audio_duration}
</span>
{/* <span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
<AiOutlineEye className="w-3 h-3" />
{podcast.playCount.toLocaleString()}
</span> */}
</div>
@@ -154,7 +153,6 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
<Play className="w-8 h-8 text-white" />
</div>
</div>
)}
@@ -163,12 +161,12 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300 flex items-center justify-center opacity-0 group-hover:opacity-100">
<button
onClick={handlePlayClick}
className="w-14 h-14 bg-white/95 hover:bg-white rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-300 shadow-medium"
className="w-14 h-14 rounded-full flex items-center justify-center transform scale-90 hover:scale-100 transition-all duration-300 shadow-medium"
>
{isCurrentlyPlaying ? (
<Pause className="w-6 h-6 text-black" />
<AiFillPauseCircle className="w-full h-full text-white" />
) : (
<Play className="w-6 h-6 text-black ml-0.5" />
<AiFillPlayCircle className="w-full h-full text-white" />
)}
</button>
</div>
@@ -185,15 +183,15 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
isLiked
? "bg-red-500 text-white"
: "bg-white/90 hover:bg-white text-neutral-600 hover:text-red-500"
)}
>
<Heart className={cn("w-4 h-4", isLiked && "fill-current")} />
)}
>
<AiFillHeart className={cn("w-4 h-4", isLiked && "fill-current")} />
</button>
<button
onClick={handleMoreClick}
className="w-8 h-8 bg-white/90 hover:bg-white rounded-full flex items-center justify-center text-neutral-600 hover:text-black transition-all duration-200 backdrop-blur-sm"
>
<MoreHorizontal className="w-4 h-4" />
<AiOutlineEllipsis className="w-4 h-4" />
</button>
</div>
@@ -227,7 +225,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
/>
) : (
<div className="w-full h-full bg-neutral-300 flex items-center justify-center">
<User className="w-3.5 h-3.5 text-neutral-500" />
<AiOutlineUser className="w-3.5 h-3.5 text-neutral-500" />
</div>
)}
</div>
@@ -241,7 +239,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
{/* 元数据 */}
<div className="flex items-center gap-4 text-sm text-neutral-500 mb-4">
{/* <div className="flex items-center gap-1.5">
<Eye className="w-4 h-4" />
<AiOutlineEye className="w-4 h-4" />
<span>{podcast.playCount.toLocaleString()}</span>
</div> */}
<div className="w-1 h-1 bg-neutral-300 rounded-full"></div>

View File

@@ -1,4 +1,4 @@
import { ArrowLeft } from 'lucide-react';
import { AiOutlineArrowLeft, AiOutlineCloudDownload } from 'react-icons/ai';
import { getAudioInfo, getUserInfo } from '@/lib/podcastApi';
import AudioPlayerControls from './AudioPlayerControls';
import PodcastTabs from './PodcastTabs';
@@ -69,10 +69,22 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
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" />
<AiOutlineArrowLeft className="w-5 h-5 mr-1" />
</a>
<ShareButton /> {/* 添加分享按钮 */}
<div className="flex items-center gap-4"> {/* 使用 flex 容器包裹分享和下载按钮 */}
<ShareButton /> {/* 添加分享按钮 */}
{audioInfo.audioUrl && (
<a
href={audioInfo.audioUrl}
download
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
aria-label="下载音频"
>
<AiOutlineCloudDownload className="w-5 h-5" />
</a>
)}
</div>
</div>
{/* 1. 顶部信息区 */}
<div className="flex flex-col items-center text-center">
@@ -92,6 +104,17 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
{audioInfo.title}
</h1>
{/* 标签 */}
{audioInfo.tags && audioInfo.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag).length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mt-4">
{audioInfo.tags.split('#').filter((tag: string) => !!tag).map((tag: string) => (
<span key={tag.trim()} className="px-3 py-1 rounded-full bg-gray-100 text-sm font-medium text-gray-600">
{tag.trim()}
</span>
))}
</div>
)}
{/* 元数据栏 */}
<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">
@@ -116,7 +139,7 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
{/* 3. 内容导航区和内容展示区 - 使用客户端组件 */}
<PodcastTabs
parsedScript={parsedScript}
overviewContent={audioInfo.overview_content}
overviewContent={audioInfo.overview_content ? audioInfo.overview_content.split('\n').slice(2).join('\n') : ''}
/>
</main>
);

View File

@@ -2,14 +2,16 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Play,
AiFillPlayCircle,
AiOutlineLink,
AiOutlineCopy,
AiOutlineUpload,
AiOutlineGlobal,
AiOutlineDown,
AiOutlineLoading3Quarters,
} from 'react-icons/ai';
import {
Wand2,
Link,
Copy,
Upload,
Globe,
ChevronDown,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import ConfigSelector from './ConfigSelector';
@@ -17,6 +19,14 @@ import VoicesModal from './VoicesModal'; // 引入 VoicesModal
import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container
import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types';
import { Satisfy } from 'next/font/google'; // 导入艺术字体 Satisfy
// 定义艺术字体,预加载并设置 fallback
const satisfy = Satisfy({
weight: '400', // Satisfy 只有 400 权重
subsets: ['latin'], // 根据需要选择子集,这里选择拉丁字符集
display: 'swap', // 字体加载策略
});
interface PodcastCreatorProps {
onGenerate: (request: PodcastGenerationRequest) => Promise<void>; // 修改为返回 Promise<void>
@@ -145,17 +155,46 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
{/* 品牌标题区域 */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 gradient-brand rounded-xl flex items-center justify-center">
<div className="w-6 h-6 bg-white rounded opacity-90" />
</div>
<h1 className="text-3xl font-bold text-black break-words">PodcastHub</h1>
<svg className="h-[80px] w-[300px] sm:h-[100px] sm:w-[600px]" viewBox="0 0 600 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="waveGradient" x1="49" y1="98" x2="140" y2="98" gradientUnits="userSpaceOnUse">
<stop stop-color="#8E54E9"/>
<stop offset="1" stop-color="#C26AE6"/>
</linearGradient>
<linearGradient id="textGradient" x1="175" y1="0" x2="810" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0.05" stop-color="#D069E6"/>
<stop offset="0.35" stop-color="#FB866C"/>
<stop offset="0.55" stop-color="#FA6F7E"/>
<stop offset="0.85" stop-color="#E968E2"/>
<stop offset="1" stop-color="#D869E5"/>
</linearGradient>
</defs>
<g>
<path
d="M49 98.5 C 56 56.5, 65 56.5, 73 90.5 C 79 120.5, 85 125.5, 91 100.5 C 96 80.5, 100 75.5, 106 95.5 C 112 115.5, 118 108.5, 125 98.5"
className="fill-none stroke-[10] stroke-round stroke-join-round" // 调整描边宽度为 7
stroke="url(#waveGradient)"
/>
<text
x="140"
y="125"
className={`${satisfy.className} text-[95px]`} // 应用艺术字体
fill="url(#textGradient)"
>
PodcastHub
</text>
</g>
</svg>
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-black mb-6 break-words">
<h2 className="text-2xl sm:text-3xl text-black mb-6 break-words">
</h2>
{/* 模式切换按钮 */}
<div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
{/* 模式切换按钮 todo */}
{/* <div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
<button
onClick={() => setSelectedMode('ai-podcast')}
className={cn(
@@ -165,7 +204,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
: "btn-secondary"
)}
>
<Play className="w-4 h-4" />
<AiFillPlayCircle className="w-4 h-4" />
AI播客
</button>
<button
@@ -177,10 +216,10 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
: "btn-secondary"
)}
>
<Wand2 className="w-4 h-4" />
<AiOutlineStar className="w-4 h-4" />
FlowSpeech
</button>
</div>
</div> */}
</div>
{/* 主要创作区域 */}
@@ -193,8 +232,8 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setTopic(e.target.value);
setItem('podcast-topic', e.target.value); // 实时保存到 localStorage
}}
placeholder="输入文字、上传文件或粘贴链接..."
className="w-full h-32 resize-none border-none outline-none text-lg placeholder-neutral-400"
placeholder="输入文字支持Markdown格式..."
className="w-full h-32 resize-none border-none outline-none text-lg placeholder-neutral-400 bg-white"
disabled={isGenerating}
/>
@@ -208,7 +247,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setItem('podcast-custom-instructions', e.target.value); // 实时保存到 localStorage
}}
placeholder="添加自定义指令(可选)..."
className="w-full h-16 resize-none border-none outline-none text-sm placeholder-neutral-400"
className="w-full h-16 resize-none border-none outline-none text-sm placeholder-neutral-400 bg-white"
disabled={isGenerating}
/>
</div>
@@ -259,7 +298,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
</option>
))}
</select>
<ChevronDown className="absolute right-1 sm:right-2 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 text-neutral-400 pointer-events-none" />
<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>
{/* 时长选择 */}
@@ -276,20 +315,20 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
</option>
))}
</select>
<ChevronDown className="absolute right-1 sm:right-2 top-1/2 transform -translate-y-1/2 w-3 h-3 sm:w-4 sm:h-4 text-neutral-400 pointer-events-none" />
<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>
{/* 右侧操作按钮 */}
{/* 右侧操作按钮 todo */}
<div className="flex items-center gap-6 sm:gap-1 flex-wrap justify-center sm:justify-right w-full sm:w-auto">
{/* 文件上传 */}
<button
{/* <button
onClick={() => fileInputRef.current?.click()}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="上传文件"
disabled={isGenerating}
>
<Upload className="w-4 h-4 sm:w-5 sm:h-5" />
<AiOutlineUpload className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<input
ref={fileInputRef}
@@ -297,29 +336,30 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
accept=".txt,.md,.doc,.docx"
onChange={handleFileUpload}
className="hidden"
/>
/> */}
{/* 粘贴链接 */}
<button
{/* <button
onClick={handlePaste}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="粘贴内容"
disabled={isGenerating}
>
<Link className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<AiOutlineLink className="w-4 h-4 sm:w-5 sm:h-5" />
</button> */}
{/* 复制 */}
<button
{/* <button
onClick={() => navigator.clipboard.writeText(topic)}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="复制内容"
disabled={isGenerating || !topic}
>
<Copy className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<AiOutlineCopy className="w-4 h-4 sm:w-5 sm:h-5" />
</button> */}
{/* 积分显示 */}
<div className="flex items-center justify-end gap-1 text-xs text-neutral-500 w-20 flex-shrink-0">
<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">
<path d="M6 3v18l6-4 6 4V3z"/>
<path d="M12 3L20 9L12 15L4 9L12 3Z"/>
@@ -338,7 +378,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
>
{isGenerating ? (
<>
<Loader2 className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<AiOutlineLoading3Quarters className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<span className=" xs:inline">Biu!</span>
</>
) : (

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { PricingPlan, Feature } from '../types'; // 导入之前定义的类型
interface PricingCardProps {
plan: PricingPlan;
}
const FeatureItem: React.FC<{ feature: Feature }> = ({ feature }) => (
<li className="flex items-start gap-3">
{feature.included ? (
<svg
className="h-5 w-5 text-neutral-500 flex-shrink-0" // 使用中度灰色作为对勾图标的颜色
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 13l4 4L19 7"
></path>
</svg>
) : (
<svg
className="h-5 w-5 text-neutral-300 flex-shrink-0" // 未包含的功能使用更浅的灰色
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
)}
<span className="text-neutral-900 text-base font-medium"> {/* 文字调小从text-lg到text-base */}
{feature.name}
{feature.notes && (
<span className="ml-2 text-neutral-500 text-xs"> {/* 文字调小从text-sm到text-xs */}
{feature.notes}
</span>
)}
</span>
</li>
);
const PricingCard: React.FC<PricingCardProps> = ({ plan }) => {
const isMostPopular = plan.isMostPopular;
return (
<div
className={`
relative
${isMostPopular ? 'p-5 rounded-[2rem] bg-[#EBE9FE]' : ''} /* 突出卡片的外部容器 */
flex-shrink-0
w-full lg:w-1/3 xl:w-96 {/* 确保在小屏幕全宽在大屏幕为1/3宽度并限制最大宽度为96 (384px) */}
`}
>
{isMostPopular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-white px-4 py-1 rounded-full shadow-medium text-sm font-semibold text-neutral-800 whitespace-nowrap">
</div>
)}
<div
className={`
bg-white
rounded-[1.5rem] /* border-radius 24px */
p-8 /* padding 32px */
shadow-medium /* 对应 .card-hover 中的 shadow-medium */
flex
flex-col
gap-6 /* 控制内部元素的垂直间距 24px */
min-h-[580px] md:h-[680px] {/* 固定高度,可根据实际内容调整 */}
`}
>
<h3 className="text-3xl font-bold text-neutral-900 text-center">
{plan.name}
</h3>
<div className="text-center my-4">
<span className="text-6xl font-extrabold text-neutral-900">
{plan.currency}
{plan.price}
</span>
<span className="text-xl text-neutral-500 ml-2">
/{plan.period === 'monthly' ? '月' : '月'}
</span>
</div>
<button
className={`
w-full
py-4 /* padding 12px 0 */
rounded-xl /* border-radius 12px */
font-semibold
text-white
transition-transform
duration-200
ease-in-out
hover:scale-[1.03]
focus:outline-none
focus:ring-2
focus:ring-offset-2
${plan.buttonVariant === 'primary' ? 'bg-gradient-to-r from-brand-purple to-brand-pink focus:ring-[#7C6BDE]' : 'bg-neutral-900 focus:ring-neutral-900'}
`}
>
{plan.ctaText}
</button>
<div className="flex-grow overflow-y-auto pr-2 custom-scrollbar"> {/* 允许特性列表滚动,并添加自定义滚动条和右边距 */}
<ul className="space-y-4"> {/* 控制功能列表项之间的间距 */}
{plan.features.map((feature, index) => (
<FeatureItem key={index} feature={feature} />
))}
</ul>
</div>
</div>
</div>
);
};
export default PricingCard;

View File

@@ -0,0 +1,139 @@
'use client';
import React, { useState } from 'react';
import PricingCard from './PricingCard'; // 修改导入路径
import BillingToggle from './BillingToggle'; // 修改导入路径
import { PricingPlan } from '../types';
const PricingSection: React.FC = () => { // 重命名组件
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annually'>('annually');
// 定义月度计划的特性常量
const MONTHLY_CREATOR_FEATURES = [
{ name: '2,000 积分每月', included: true },
{ name: 'AI 语音合成', included: true },
{ name: '两个说话人支持', included: true },
{ name: '商业使用许可', included: true },
{ name: '音频下载', included: true },
] as const;
const MONTHLY_PRO_FEATURES = [
{ name: '5,000 积分每月', included: true },
{ name: 'AI 语音合成', included: true },
{ name: '多说话人支持', included: true },
{ name: '商业使用许可', included: true },
{ name: '音频下载', included: true },
{ name: '高级音色', included: true },
{ name: '说书模式', included: true, notes: '即将推出'},
] as const;
const MONTHLY_BUSINESS_FEATURES = [
{ name: '12,000 积分每月', included: true },
{ name: 'AI 语音合成', included: true },
{ name: '多说话人支持', included: true },
{ name: '商业使用许可', included: true },
{ name: '专用账户经理', included: true },
{ name: '音频下载', included: true },
{ name: '高级音色', included: true },
{ name: '说书模式', included: true, notes: '即将推出'},
{ name: 'API 访问', included: true, notes: '即将推出' },
] as const;
const monthlyPlans: PricingPlan[] = [
{
name: '创作者',
price: 9.9,
currency: '$',
period: 'monthly',
features: MONTHLY_CREATOR_FEATURES,
ctaText: '立即开始',
buttonVariant: 'secondary',
},
{
name: '专业版',
price: 19.9,
currency: '$',
period: 'monthly',
features: MONTHLY_PRO_FEATURES,
ctaText: '升级至专业版',
buttonVariant: 'primary',
isMostPopular: true,
},
{
name: '商业版',
price: 39.9,
currency: '$',
period: 'monthly',
features: MONTHLY_BUSINESS_FEATURES,
ctaText: '升级至商业版',
buttonVariant: 'secondary',
},
];
const annuallyPlans: PricingPlan[] = [
{
name: '创作者',
price: 8,
currency: '$',
period: 'annually',
features: MONTHLY_CREATOR_FEATURES,
ctaText: '立即开始',
buttonVariant: 'secondary',
},
{
name: '专业版',
price: 16,
currency: '$',
period: 'annually',
features: MONTHLY_PRO_FEATURES,
ctaText: '升级至专业版',
buttonVariant: 'primary',
isMostPopular: true,
},
{
name: '商业版',
price: 32,
currency: '$',
period: 'annually',
features: MONTHLY_BUSINESS_FEATURES,
ctaText: '升级至商业版',
buttonVariant: 'secondary',
},
];
const currentPlans = billingPeriod === 'monthly' ? monthlyPlans : annuallyPlans;
return (
<div className="flex flex-col items-center justify-center min-h-screen py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl sm:text-5xl font-extrabold text-neutral-900 leading-tight mb-4">
</h1>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">
</p>
</div>
<div className="mb-12">
<BillingToggle billingPeriod={billingPeriod} onToggle={setBillingPeriod} />
</div>
<div className="flex flex-col lg:flex-row justify-center items-center lg:items-end gap-8 w-full max-w-7xl">
{currentPlans.map((plan) => (
<PricingCard key={plan.name} plan={plan} />
))}
</div>
<div className="mt-12 text-center text-neutral-500">
<a href="/pricing" target="_blank" className="flex items-center justify-center text-neutral-600 hover:text-neutral-900 transition-colors duration-200">
访
<svg className="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
</div>
);
};
export default PricingSection;

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { Share2 } from 'lucide-react';
import { AiOutlineShareAlt } from 'react-icons/ai';
import { useToast } from './Toast'; // 确保路径正确
import { usePathname } from 'next/navigation'; // next/navigation 用于获取当前路径
@@ -33,7 +33,7 @@ const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
className={`text-neutral-500 hover:text-black transition-colors text-sm ${className}`}
aria-label="分享页面"
>
<Share2 className="w-5 h-5" />
<AiOutlineShareAlt className="w-5 h-5" />
</button>
);
};

View File

@@ -2,19 +2,18 @@
import React, { useState, useEffect, useRef } from 'react'; // 导入 useState, useEffect, 和 useRef 钩子
import {
Home,
Settings,
X,
MessageCircle,
Mail,
Cloud,
Smartphone,
PanelLeftClose,
PanelLeftOpen,
Coins,
LogIn, // 导入 LogIn 图标用于登录按钮
User2 // 导入 User2 图标用于默认头像
} from 'lucide-react';
AiOutlineHome,
AiOutlineSetting,
AiOutlineTwitter,
AiOutlineTikTok,
AiOutlineMail,
AiOutlineGithub,
AiOutlineMenuFold,
AiOutlineMenuUnfold,
AiOutlineMoneyCollect, // 或者 AiOutlineDollarCircle
AiOutlineLogin, // 导入 AiOutlineLogin 图标用于登录按钮
AiOutlineUser // 导入 AiOutlineUser 图标用于默认头像
} from 'react-icons/ai';
import { signOut } from '@/lib/auth-client'; // 导入 signOut 函数
import { useRouter } from 'next/navigation'; // 导入 useRouter 钩子
import { getSessionData } from '@/lib/server-actions';
@@ -89,8 +88,9 @@ const Sidebar: React.FC<SidebarProps> = ({
}
}, [session, router, onCreditsChange]); // 监听 session 变化和 router因为 signOut 中使用了 router.push并添加 onCreditsChange
// todo
const mainNavItems: NavItem[] = [
{ id: 'home', label: '首页', icon: Home },
{ id: 'home', label: '首页', icon: AiOutlineHome },
// 隐藏资料库和探索
// { id: 'library', label: '资料库', icon: Library },
// { id: 'explore', label: '探索', icon: Compass },
@@ -99,17 +99,16 @@ const Sidebar: React.FC<SidebarProps> = ({
const bottomNavItems: NavItem[] = [
// 隐藏定价和积分
// { id: 'pricing', label: '定价', icon: DollarSign },
{ id: 'credits', label: '积分', icon: Coins, badge: credits.toString() }, // 动态设置 badge
...(enableTTSConfigPage ? [{ id: 'settings', label: 'TTS设置', icon: Settings }] : [])
{ id: 'credits', label: '积分', icon: AiOutlineMoneyCollect, badge: credits.toString() }, // 动态设置 badge
...(enableTTSConfigPage ? [{ id: 'settings', label: 'TTS设置', icon: AiOutlineSetting }] : [])
];
const socialLinks = [
{ icon: X, href: '#', label: 'Twitter' },
{ icon: MessageCircle, href: '#', label: 'Discord' },
{ icon: Mail, href: '#', label: 'Email' },
{ icon: Cloud, href: '#', label: 'Cloud' },
{ icon: Smartphone, href: '#', label: 'Mobile' },
{ icon: AiOutlineGithub, href: 'https://github.com/justlovemaki', label: 'Github' },
{ icon: AiOutlineTwitter, href: 'https://x.com/justlikemaki', label: 'Twitter' },
{ icon: AiOutlineTikTok, href: 'https://cdn.jsdmirror.com/gh/justlovemaki/imagehub@main/logo/7fc30805eeb831e1e2baa3a240683ca3.md.png', label: 'Douyin' },
{ icon: AiOutlineMail, href: 'mailto:justlikemaki@foxmail.com', label: 'Email' },
];
return (
@@ -131,20 +130,47 @@ const Sidebar: React.FC<SidebarProps> = ({
className="w-8 h-8 gradient-brand rounded-lg flex items-center justify-center hover:opacity-80 transition-opacity"
title="展开侧边栏"
>
<PanelLeftOpen className="w-4 h-4 text-white" />
<AiOutlineMenuUnfold className="w-4 h-4 text-white" />
</button>
</div>
) : (
/* 展开状态 - Logo和品牌名称 */
<>
{/* Logo图标 */}
<div className="w-8 h-8 gradient-brand rounded-lg flex items-center justify-center flex-shrink-0">
<div className="w-4 h-4 bg-white rounded-sm opacity-80" />
</div>
{/* 品牌名称容器 - 慢慢收缩动画 */}
<div className="overflow-hidden transition-all duration-500 ease-in-out w-auto ml-3">
<span className="text-xl font-semibold text-black whitespace-nowrap transition-all duration-500 ease-in-out transform-gpu opacity-100 scale-x-100">PodcastHub</span>
<div className="overflow-hidden transition-all duration-500 ease-in-out w-auto ">
<svg className="h-[30px] w-[180px] sm:h-[30px] sm:w-[180px]" viewBox="0 0 800 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="waveGradient" x1="49" y1="98" x2="140" y2="98" gradientUnits="userSpaceOnUse">
<stop stop-color="#8E54E9"/>
<stop offset="1" stop-color="#C26AE6"/>
</linearGradient>
<linearGradient id="textGradient" x1="175" y1="0" x2="810" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0.05" stop-color="#D069E6"/>
<stop offset="0.35" stop-color="#FB866C"/>
<stop offset="0.55" stop-color="#FA6F7E"/>
<stop offset="0.85" stop-color="#E968E2"/>
<stop offset="1" stop-color="#D869E5"/>
</linearGradient>
</defs>
<g>
<path
d="M49 98.5 C 56 56.5, 65 56.5, 73 90.5 C 79 120.5, 85 125.5, 91 100.5 C 96 80.5, 100 75.5, 106 95.5 C 112 115.5, 118 108.5, 125 98.5"
className="fill-none stroke-[10] stroke-round stroke-join-round" // 调整描边宽度为 7
stroke="black"
/>
<text
x="140"
y="125"
className={`font-bold text-[95px]`} // 应用艺术字体
fill="black"
>
PodcastHub
</text>
</g>
</svg>
</div>
{/* 收起按钮 */}
@@ -154,7 +180,7 @@ const Sidebar: React.FC<SidebarProps> = ({
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-neutral-100 border border-neutral-200 transition-all duration-200"
title="收起侧边栏"
>
<PanelLeftClose className="w-4 h-4 text-neutral-500" />
<AiOutlineMenuFold className="w-4 h-4 text-neutral-500" />
</button>
</div>
</>
@@ -249,7 +275,7 @@ const Sidebar: React.FC<SidebarProps> = ({
collapsed ? "h-0 pt-0" : "h-auto pt-4"
)}>
<div className={cn(
"flex items-center gap-3 transition-all duration-500 ease-in-out transform-gpu",
"flex items-center justify-center gap-3 transition-all duration-500 ease-in-out transform-gpu",
collapsed ? "opacity-0 scale-y-0" : "opacity-100 scale-y-100"
)}>
{socialLinks.map((link, index) => {
@@ -303,7 +329,7 @@ const Sidebar: React.FC<SidebarProps> = ({
/>
) : (
<div className="w-full h-full bg-neutral-200 flex items-center justify-center">
<User2 className="w-5 h-5 text-neutral-500" />
<AiOutlineUser className="w-5 h-5 text-neutral-500" />
</div>
)}
</button>
@@ -331,7 +357,7 @@ const Sidebar: React.FC<SidebarProps> = ({
)}
title={collapsed ? "登录" : undefined}
>
<LogIn className="w-5 h-5 flex-shrink-0" />
<AiOutlineLogin className="w-5 h-5 flex-shrink-0" />
<div className={cn(
"overflow-hidden transition-all duration-500 ease-in-out",
collapsed ? "w-0 ml-0" : "w-auto ml-3"

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useState } from 'react';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
import { AiOutlineClose, AiFillCheckCircle, AiFillWarning, AiFillInfoCircle } from 'react-icons/ai';
import { cn } from '@/lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -20,7 +20,7 @@ const Toast: React.FC<ToastProps> = ({
type,
title,
message,
duration = 5000,
duration = 3000,
onClose,
}) => {
const [isVisible, setIsVisible] = useState(false);
@@ -28,7 +28,8 @@ const Toast: React.FC<ToastProps> = ({
useEffect(() => {
// 进入动画
const timer = setTimeout(() => setIsVisible(true), 10);
// 进入动画
const timer = setTimeout(() => setIsVisible(true), 10); // 短暂延迟确保CSS动画生效
// 自动关闭
const autoCloseTimer = setTimeout(() => {
@@ -45,91 +46,90 @@ const Toast: React.FC<ToastProps> = ({
setIsLeaving(true);
setTimeout(() => {
onClose(id);
}, 300);
}, 200); // 调整动画时长,保持流畅
};
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <Info className="w-5 h-5 text-blue-500" />;
default:
return <Info className="w-5 h-5 text-blue-500" />;
}
};
const getStyles = () => {
const baseStyles = "border-l-4";
switch (type) {
case 'success':
return `${baseStyles} border-green-500 bg-green-50`;
case 'error':
return `${baseStyles} border-red-500 bg-red-50`;
case 'warning':
return `${baseStyles} border-yellow-500 bg-yellow-50`;
case 'info':
return `${baseStyles} border-blue-500 bg-blue-50`;
default:
return `${baseStyles} border-blue-500 bg-blue-50`;
}
};
return (
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg shadow-large max-w-sm w-full transition-all duration-300 ease-out",
getStyles(),
isVisible && !isLeaving ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}
>
{getIcon()}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm text-black mb-1">
{title}
</h4>
{message && (
<p className="text-xs text-neutral-600 leading-relaxed">
{message}
</p>
)}
</div>
<button
onClick={handleClose}
className="p-1 text-neutral-400 hover:text-neutral-600 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
};
// Toast容器组件
export interface ToastContainerProps {
toasts: ToastProps[];
onRemove: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({
toasts,
onRemove,
}) => {
return (
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
{...toast}
onClose={onRemove}
/>
))}
</div>
const getIcon = () => {
switch (type) {
case 'success':
return <AiFillCheckCircle className="w-5 h-5 text-green-600" />; // 更深沉的绿色
case 'error':
return <AiFillWarning className="w-5 h-5 text-red-600" />; // 更深沉的红色
case 'warning':
return <AiFillWarning className="w-5 h-5 text-orange-500" />; // 调整为橙色
case 'info':
return <AiFillInfoCircle className="w-5 h-5 text-blue-600" />; // 更深沉的蓝色
default:
return <AiFillInfoCircle className="w-5 h-5 text-gray-500" />; // 默认灰色
}
};
const getAccentColor = () => {
switch (type) {
case 'success':
return 'border-green-500';
case 'error':
return 'border-red-500';
case 'warning':
return 'border-orange-400';
case 'info':
return 'border-blue-500';
default:
return 'border-gray-300';
}
};
return (
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg shadow-lg bg-white border border-gray-200 backdrop-blur-md max-w-sm w-full transition-all duration-300 ease-in-out",
getAccentColor(), // 添加左侧强调色边框
isVisible && !isLeaving ? "translate-y-0 opacity-100" : "-translate-y-4 opacity-0" // 向上弹出动画
)}
>
{getIcon()}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-base text-gray-800 mb-1"> {/* 字体更粗,颜色更深 */}
{title}
</h4>
{message && (
<p className="text-sm text-gray-600 leading-relaxed break-words"> {/* 字体稍大,颜色更深,允许换行 */}
{message}
</p>
)}
</div>
<button
onClick={handleClose}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
>
<AiOutlineClose className="w-4 h-4" />
</button>
</div>
);
};
// Toast容器组件
export interface ToastContainerProps {
toasts: ToastProps[];
onRemove: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({
toasts,
onRemove,
}) => {
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md pointer-events-none p-4 flex flex-col items-center space-y-3"> {/* 定位到顶部水平居中并限制宽度使用flex布局垂直居中增加间距 */}
{toasts.map((toast) => (
<Toast
key={toast.id}
{...toast}
onClose={onRemove}
/>
))}
</div>
);
};

View File

@@ -2,7 +2,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
import type { Voice } from '@/types';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { PlayIcon, PauseIcon } from '@heroicons/react/24/solid';
import { X } from 'lucide-react';
import { AiOutlineClose } from 'react-icons/ai';
interface VoicesModalProps {
isOpen: boolean;
@@ -131,7 +131,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
className="absolute top-4 right-4 p-2 rounded-full text-neutral-600 hover:bg-neutral-100 hover:text-black transition-all duration-200 z-10"
aria-label="关闭"
>
<X className="w-5 h-5" />
<AiOutlineClose className="w-5 h-5" />
</button>
</div>
@@ -254,7 +254,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-all duration-200 rounded-full backdrop-blur-sm"
aria-label="删除"
>
<X className="w-5 h-5" />
<AiOutlineClose className="w-5 h-5" />
</button>
</div>
))}
@@ -279,4 +279,4 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
);
};
export default VoicesModal;
export default VoicesModal;

View File

@@ -13,7 +13,7 @@ export async function createPointsAccount(userId: string, initialPoints: number
await db.insert(schema.pointsAccounts).values({
userId: userId,
totalPoints: initialPoints,
updatedAt: sql`CURRENT_TIMESTAMP`,
updatedAt: new Date().toISOString(),
});
console.log(`用户 ${userId} 的积分账户初始化成功,初始积分:${initialPoints}`);
} catch (error) {
@@ -42,7 +42,7 @@ export async function recordPointsTransaction(
pointsChange: pointsChange,
reasonCode: reasonCode,
description: description,
createdAt: sql`CURRENT_TIMESTAMP`,
createdAt: new Date().toISOString(),
});
console.log(`用户 ${userId} 的积分流水记录成功: 变动 ${pointsChange}, 原因 ${reasonCode}`);
} catch (error) {
@@ -133,7 +133,7 @@ export async function deductUserPoints(
pointsChange: -pointsToDeduct, // 扣减为负数
reasonCode: reasonCode,
description: description,
createdAt: sql`CURRENT_TIMESTAMP`,
createdAt: new Date().toISOString(),
});
// 4. 更新积分账户
@@ -141,7 +141,7 @@ export async function deductUserPoints(
.update(schema.pointsAccounts)
.set({
totalPoints: newPoints,
updatedAt: sql`CURRENT_TIMESTAMP`,
updatedAt: new Date().toISOString(),
})
.where(eq(schema.pointsAccounts.userId, userId));

View File

@@ -184,4 +184,21 @@ export class HttpError extends Error {
export interface PodcastStatusResponse {
message: string;
tasks: PodcastGenerationResponse[]; // 包含任务列表
}
export interface Feature {
name: string;
included: boolean;
notes?: string; // 例如 "即将推出"
}
export interface PricingPlan {
name: string;
price: number; // 原始价格,按月或年计
currency: string;
period: 'monthly' | 'annually';
features: readonly Feature[];
isMostPopular?: boolean;
ctaText: string;
buttonVariant: 'primary' | 'secondary';
}