feat: 添加定价页面组件和图标库迁移
refactor: 替换lucide-react为react-icons feat(定价): 实现定价页面、卡片和切换组件 feat(页脚): 添加页脚链接组件 feat(文档): 新增使用条款、隐私政策和联系页面 style: 更新Toast组件样式和动画 chore: 更新项目元数据和favicon
This commit is contained in:
89
main.py
89
main.py
@@ -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 的依赖项。
|
||||
|
||||
@@ -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
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Simple-Podcast-Script",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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
10
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
BIN
web/public/favicon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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!🎙️');
|
||||
@@ -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);
|
||||
// 根据错误类型,可能需要更详细的错误处理或重定向
|
||||
|
||||
102
web/src/app/contact/page.tsx
Normal file
102
web/src/app/contact/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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="为你推荐"
|
||||
|
||||
12
web/src/app/pricing/page.tsx
Normal file
12
web/src/app/pricing/page.tsx
Normal 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;
|
||||
121
web/src/app/privacy/page.tsx
Normal file
121
web/src/app/privacy/page.tsx
Normal 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">最近更新日期:2025年8月21日</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
116
web/src/app/terms/page.tsx
Normal 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">最近更新日期:2025年8月21日</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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
web/src/components/BillingToggle.tsx
Normal file
40
web/src/components/BillingToggle.tsx
Normal 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;
|
||||
@@ -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>
|
||||
)) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
web/src/components/FooterLinks.tsx
Normal file
34
web/src/components/FooterLinks.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
127
web/src/components/PricingCard.tsx
Normal file
127
web/src/components/PricingCard.tsx
Normal 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;
|
||||
139
web/src/components/PricingSection.tsx
Normal file
139
web/src/components/PricingSection.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user