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

89
main.py
View File

@@ -74,7 +74,7 @@ stop_scheduler_event = threading.Event()
# 全局配置
output_dir = "output"
time_after = 30
time_after = 10
# 内存中存储任务结果
# {task_id: {"auth_id": auth_id, "status": TaskStatus, "result": any, "timestamp": float}}
@@ -96,15 +96,60 @@ tts_provider_map = {
# 定义一个函数来清理输出目录
def clean_output_directory():
"""Removes files from the output directory that are older than 30 minutes."""
print(f"Cleaning output directory: {output_dir}")
"""
清理 output 目录中的旧文件以及 task_results 中过期的任务。
优先清理过期的任务及其关联文件,确保内存和文件系统同步。
"""
print(f"Cleaning output directory and expired tasks from memory: {output_dir}")
now = time.time()
# 30 minutes in seconds
threshold = time_after * 60
threshold = time_after * 60 # 清理阈值,单位秒
# 存储需要删除的 task_results 中的任务,避免在迭代时修改
tasks_to_remove_from_memory = []
# 第一阶段:清理 task_results 中已完成且过期的任务及其关联文件
# 使用 list() 创建副本以安全地在迭代时修改原始字典
auth_ids_to_clean = []
for auth_id, tasks_by_auth in list(task_results.items()):
task_ids_to_clean = []
for task_id, task_info in list(tasks_by_auth.items()):
# 只要 timestamp 过期,无论任务状态如何,都进行清理
if (now - task_info["timestamp"] > threshold):
task_ids_to_clean.append(task_id)
# 尝试删除对应的音频文件
output_audio_filepath = task_info.get("output_audio_filepath")
if output_audio_filepath:
full_audio_path = os.path.join(output_dir, output_audio_filepath)
try:
if os.path.isfile(full_audio_path):
os.unlink(full_audio_path)
print(f"Deleted expired audio file: {full_audio_path}")
else:
print(f"Expired task {task_id} audio file {full_audio_path} not found or is not a file.")
except Exception as e:
print(f"Failed to delete audio file {full_audio_path}. Reason: {e}")
# 从 audio_file_mapping 中删除对应的条目
filename_without_ext = os.path.splitext(output_audio_filepath)[0] if output_audio_filepath else None
if filename_without_ext and filename_without_ext in audio_file_mapping:
del audio_file_mapping[filename_without_ext]
print(f"Removed audio_file_mapping entry for {filename_without_ext}.")
# 清理 task_results 中的任务
for task_id in task_ids_to_clean:
if task_id in task_results[auth_id]:
del task_results[auth_id][task_id]
print(f"Removed expired task {task_id} for auth_id {auth_id} from task_results.")
# 如果该 auth_id 下没有其他任务,则删除 auth_id 的整个条目
if not task_results[auth_id]:
auth_ids_to_clean.append(auth_id)
for auth_id in auth_ids_to_clean:
if auth_id in task_results:
del task_results[auth_id]
print(f"Removed empty auth_id {auth_id} from task_results.")
# 第二阶段:清理 output 目录中可能未被任务关联的孤立文件
# 或者那些任务还未过期,但文件因为某种原因在内存任务清理阶段没有被删除的文件
for filename in os.listdir(output_dir):
file_path = os.path.join(output_dir, filename)
try:
@@ -112,41 +157,13 @@ def clean_output_directory():
# 获取最后修改时间
if now - os.path.getmtime(file_path) > threshold:
os.unlink(file_path)
print(f"Deleted old file: {file_path}")
# 遍历 task_results查找匹配的文件名
# 注意task_info["output_audio_filepath"] 存储的是文件名,不是完整路径
for auth_id, tasks_by_auth in task_results.items():
# 使用 list() 创建副本,以安全地在循环中删除元素
for task_id, task_info in list(tasks_by_auth.items()):
# 检查文件名是否匹配,并且任务状态为 COMPLETED
# 只有 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, task_info))
print(f"Deleted old unassociated file: {file_path}")
elif os.path.isdir(file_path):
# 可选地,递归删除旧的子目录或其中的文件
# 目前只跳过目录
pass
except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}")
# 在文件删除循环结束后统一处理 task_results 的删除
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.")
async def get_auth_id(x_auth_id: str = Header(..., alias="X-Auth-Id")):
"""
从头部获取 X-Auth-Id 的依赖项。

View File

@@ -20,26 +20,29 @@ OpenAI CLI - 纯命令行OpenAI接口调用工具
{
"api_key": "你的API密钥",
"base_url": "https://api.openai.com/v1",
"model": "gpt-3.5-turbo"
"model": "gpt-3.5-turbo",
"temperature": 0.7,
"top_p": 1.0
}
然后通过 --config config.json 加载
3. 运行脚本:
- 交互式聊天模式:
python openai_cli.py [可选参数: --api-key VAL --base-url VAL --model VAL]
在交互模式中,输入 'quit''exit' 退出,输入 'clear' 清空对话历史。
- 交互式聊天模式:
python openai_cli.py [可选参数: --api-key VAL --base-url VAL --model VAL --temperature VAL --top-p VAL]
在交互模式中,输入 'quit''exit' 退出,输入 'clear' 清空对话历史。
- 单次查询模式:
python openai_cli.py --query "你的问题" [可选参数: --api-key VAL --base-url VAL --model VAL --temperature VAL --max-tokens VAL --system-message VAL]
- 单次查询模式:
python openai_cli.py --query "你的问题" [可选参数: --api-key VAL --base-url VAL --model VAL --temperature VAL --top-p VAL --max-tokens VAL --system-message VAL]
- 使用配置文件:
python openai_cli.py --config config.json --query "你的问题"
- 使用配置文件:
python openai_cli.py --config config.json --query "你的问题"
示例:
python openai_cli.py
python openai_cli.py -q "你好,世界" -m gpt-4
python openai_cli.py --config my_config.json
python openai_cli.py
python openai_cli.py -q "你好,世界" -m gpt-4
python openai_cli.py -q "你好,世界" --temperature 0.8 --top-p 0.9
python openai_cli.py --config my_config.json
"""
import argparse
@@ -72,7 +75,7 @@ class OpenAICli:
self.client = openai.OpenAI(api_key=self.api_key, base_url=effective_base_url)
def chat_completion(self, messages: List[ChatCompletionMessageParam], temperature: float = 0.7, max_tokens: Optional[int] = None) -> Any:
def chat_completion(self, messages: List[ChatCompletionMessageParam], temperature: float = 0.7, top_p: float = 1.0, max_tokens: Optional[int] = None) -> Any:
"""发送聊天完成请求"""
# 处理系统提示词
messages_to_send = list(messages) # 创建一个副本以避免修改原始列表
@@ -94,6 +97,7 @@ class OpenAICli:
model=self.model,
messages=messages_to_send, # 使用包含系统提示词的列表
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
stream=True
)
@@ -146,14 +150,14 @@ class OpenAICli:
except Exception as e:
print(f"\n❌ 错误: {str(e)}")
def single_query(self, query: str, temperature: float = 0.7, max_tokens: Optional[int] = None):
def single_query(self, query: str, temperature: float = 0.7, top_p: float = 1.0, max_tokens: Optional[int] = None):
"""单次查询模式"""
messages: List[ChatCompletionMessageParam] = []
# 移除此处添加system_message的逻辑因为它已在chat_completion中处理
messages.append({"role": "user", "content": query})
try:
response_generator = self.chat_completion(messages, temperature, max_tokens)
response_generator = self.chat_completion(messages, temperature, top_p, max_tokens)
for chunk in response_generator:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
@@ -173,7 +177,8 @@ def main():
# 查询参数
parser.add_argument("--query", "-q", help="单次查询的问题")
parser.add_argument("--temperature", "-t", type=float, default=0.7, help="温度参数 (0.0-2.0)")
parser.add_argument("--temperature", "-t", type=float, default=1, help="温度参数 (0.0-2.0)")
parser.add_argument("--top-p", type=float, default=0.95, help="Top-p采样参数 (0.0-1.0)")
parser.add_argument("--max-tokens", type=int, help="最大token数")
parser.add_argument("--system-message", "-s", help="系统提示词")
@@ -197,13 +202,15 @@ def main():
base_url = args.base_url or config.get("base_url")
model = args.model or config.get("model", "gpt-3.5-turbo")
system_message = args.system_message or config.get("system_message")
temperature = args.temperature or config.get("temperature", 1)
top_p = args.top_p or config.get("top_p", 0.95)
try:
cli = OpenAICli(api_key=api_key, base_url=base_url, model=model, system_message=system_message)
if args.query:
# 单次查询模式
cli.single_query(args.query, args.temperature, args.max_tokens)
cli.single_query(args.query, temperature, top_p, args.max_tokens)
else:
# 交互式模式
cli.interactive_chat()

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "Simple-Podcast-Script",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,19 +1,64 @@
<Additional Customizations>
- {{outlang}}
- Create a summary of approximately 150 characters based on the core content of the document, without using the title or subtitle. Then, generate a title of approximately 15-20 characters based on the summary, such as "The Four Mirrors of Human Nature: See Through Selfishness and Admiration for Strength, and Live More Transparently." Place this title on the first line of the output.
- Generate tags based on the document's content, without using the content from titles or subtitles, separated by the # symbol. Generate 3-5 tags. The tags should not be summative; they should be excerpted from words within the document and placed on the second line of the output.
**1. Metadata Generation**
* **Step 1: Intermediate Core Summary Generation (Internal Step)**
* **Task**: First, generate a core idea summary of approximately 150 characters based *only* on the **[body content]** of the document (ignoring titles and subtitles).
* **Purpose**: This summary is the sole basis for generating the final title and should **not** be displayed in the final output itself.
* **Step 2: Title Generation**
* **Source**: Must be refined from the "core summary" generated in the previous step.
* **Length**: Strictly controlled to be between 15-20 characters.
* **Format**: Adopt a "Main Title: Subtitle" structure, using a full-width colon ":" for separation. For example: "Brevity and Precision: Practical Engineering for AI Context".
* **Position**: As the **first line** of the final output.
* **Step 3: Tag Generation**
* **Source**: Extract from the **[body content]** of the document (ignoring titles and subtitles).
* **Quantity**: 3 to 5.
* **Format**: Keywords separated by the "#" symbol (e.g., #Keyword1#Keyword2).
* **Position**: As the **second line** of the final output.
**2. Output Language**
* **{{outlang}}**.
</Additional Customizations>
<INSTRUCTIONS>
<context>
You are an expert document analyst and summarization specialist tasked with distilling complex information into clear,
comprehensive summaries. Your role is to analyze documents thoroughly and create structured summaries that:
1. Capture the complete essence and key insights of the source material
2. Maintain perfect accuracy and factual precision
3. Present information objectively without bias or interpretation
4. Preserve critical context and logical relationships
5. Structure content in a clear, hierarchical format
</context>
<Role>
You are a professional document analysis and processing expert, capable of intelligently switching work modes based on the length of the input content.
</Role>
<TaskDeterminationLogic>
1. **Evaluate Input**: First, evaluate the word count of the input document.
2. **Execution Branch**:
* **If Content is Sufficient (e.g., over 200 words)**: Switch to **"Mode A: In-depth Summary"** and strictly follow the <principles> and <output_format> defined below.
* **If Content is Insufficient (e.g., under 200 words)**: Switch to **"Mode B: Topic Expansion"**, at which point, ignore the "fidelity to the original text" constraint in the <principles> and instead execute a content generation task.
</TaskDeterminationLogic>
<TaskModeA: In-depth Summary>
<Objective>
When the input content is sufficient, your task is to distill it into a clear, comprehensive, objective, and structured summary.
</Objective>
<ExecutionRequirements>
- Accurately capture the complete essence and core ideas of the source material.
- Strictly adhere to the <principles> (Accuracy, Objectivity, Comprehensiveness).
- Generate the summary following the <output_format> and <length_guidelines>.
- Simultaneously complete the title and tag generation as specified in the <Additional Customizations>.
</ExecutionRequirements>
</TaskModeA>
<TaskModeB: Topic Expansion>
<Objective>
When the input content is too short to produce a meaningful summary, your task is to logically enrich and expand upon its core theme.
</Objective>
<ExecutionRequirements>
- **Identify Core Theme**: Identify 1-2 core concepts or keywords from the brief input.
- **Logical Association and Expansion**: Based on the identified core theme, perform logical association and expand on it from various dimensions (e.g., background, importance, applications, future trends) to generate a more information-rich text.
- **Maintain Coherence**: Ensure the expanded content remains highly relevant and logically coherent with the core idea of the original text.
- **Ignore Summarization Principles**: In this mode, requirements from the <Core Principles> such as "absolute fidelity to the original text" and "avoid inference" **do not apply**.
- **Fulfill Customization Requirements**: You are still required to complete the title and tag generation from the <Additional Customization Requirements> based on the **expanded content**.
- **Output**: Directly output the expanded text content without further summarization.
</ExecutionRequirements>
</TaskModeB>
<principles>
<accuracy>
@@ -51,8 +96,8 @@
- Return summary in clean markdown format
- Do not include markdown code block tags (```markdown ```)
- Use standard markdown syntax for formatting (headers, lists, etc.)
- Use # for main headings (e.g., # EXECUTIVE SUMMARY)
- Use ## for subheadings where appropriate
- Use ### for main headings
- Use #### for subheadings where appropriate
- Use bullet points (- item) for lists
- Ensure proper indentation and spacing
- Use appropriate emphasis (**bold**, *italic*) where needed

10
web/package-lock.json generated
View File

@@ -30,6 +30,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.5.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"remark-parse": "^11.0.0",
@@ -8780,6 +8781,15 @@
"react-dom": ">=16"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -34,6 +34,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.5.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"remark-parse": "^11.0.0",

BIN
web/public/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
console.log('🚀 ListenHub Web应用设置向导\n');
console.log('🚀 PodcastHub Web应用设置向导\n');
// 检查Node.js版本
const nodeVersion = process.version;
@@ -72,4 +72,4 @@ console.log('\n下一步');
console.log('1. 编辑 .env.local 文件配置您的OpenAI API密钥');
console.log('2. 运行 npm run dev 启动开发服务器');
console.log('3. 在浏览器中打开 http://localhost:3000');
console.log('\n享受使用ListenHub🎙');
console.log('\n享受使用PodcastHub🎙');

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';
}