From a7ef2d660657944aaa9b942650f0a5a6b6ae29db Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 19 Aug 2025 22:50:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=BB=84=E4=BB=B6=E5=92=8C=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=BA=93=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 替换lucide-react为react-icons feat(定价): 实现定价页面、卡片和切换组件 feat(页脚): 添加页脚链接组件 feat(文档): 新增使用条款、隐私政策和联系页面 style: 更新Toast组件样式和动画 chore: 更新项目元数据和favicon --- main.py | 89 ++++++----- openai_cli.py | 39 +++-- package-lock.json | 6 - prompt/prompt-overview.txt | 73 +++++++-- web/package-lock.json | 10 ++ web/package.json | 1 + web/public/favicon.webp | Bin 0 -> 28766 bytes web/scripts/setup.js | 4 +- web/src/app/api/newuser/route.ts | 5 +- web/src/app/contact/page.tsx | 102 ++++++++++++ web/src/app/layout.tsx | 12 +- web/src/app/page.tsx | 4 + web/src/app/pricing/page.tsx | 12 ++ web/src/app/privacy/page.tsx | 121 ++++++++++++++ web/src/app/terms/page.tsx | 116 ++++++++++++++ web/src/components/AudioPlayer.tsx | 47 +++--- web/src/components/AudioPlayerControls.tsx | 6 +- web/src/components/BillingToggle.tsx | 40 +++++ web/src/components/ConfigSelector.tsx | 4 +- web/src/components/ContentSection.tsx | 8 +- web/src/components/FooterLinks.tsx | 34 ++++ web/src/components/LoginModal.tsx | 6 +- web/src/components/PodcastCard.tsx | 32 ++-- web/src/components/PodcastContent.tsx | 31 +++- web/src/components/PodcastCreator.tsx | 110 ++++++++----- web/src/components/PricingCard.tsx | 127 +++++++++++++++ web/src/components/PricingSection.tsx | 139 ++++++++++++++++ web/src/components/ShareButton.tsx | 4 +- web/src/components/Sidebar.tsx | 92 +++++++---- web/src/components/Toast.tsx | 174 ++++++++++----------- web/src/components/VoicesModal.tsx | 8 +- web/src/lib/points.ts | 8 +- web/src/types/index.ts | 17 ++ 33 files changed, 1180 insertions(+), 301 deletions(-) delete mode 100644 package-lock.json create mode 100644 web/public/favicon.webp create mode 100644 web/src/app/contact/page.tsx create mode 100644 web/src/app/pricing/page.tsx create mode 100644 web/src/app/privacy/page.tsx create mode 100644 web/src/app/terms/page.tsx create mode 100644 web/src/components/BillingToggle.tsx create mode 100644 web/src/components/FooterLinks.tsx create mode 100644 web/src/components/PricingCard.tsx create mode 100644 web/src/components/PricingSection.tsx diff --git a/main.py b/main.py index eb5f1c0..76fc73d 100644 --- a/main.py +++ b/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 的依赖项。 diff --git a/openai_cli.py b/openai_cli.py index 0c554e3..0990125 100644 --- a/openai_cli.py +++ b/openai_cli.py @@ -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() diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 554e456..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Simple-Podcast-Script", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/prompt/prompt-overview.txt b/prompt/prompt-overview.txt index 3eae941..c2ee841 100644 --- a/prompt/prompt-overview.txt +++ b/prompt/prompt-overview.txt @@ -1,19 +1,64 @@ - - {{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}}**. - - 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 - + + You are a professional document analysis and processing expert, capable of intelligently switching work modes based on the length of the input content. + + + + 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 and 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 and instead execute a content generation task. + + + + + When the input content is sufficient, your task is to distill it into a clear, comprehensive, objective, and structured summary. + + + - Accurately capture the complete essence and core ideas of the source material. + - Strictly adhere to the (Accuracy, Objectivity, Comprehensiveness). + - Generate the summary following the and . + - Simultaneously complete the title and tag generation as specified in the . + + + + + + When the input content is too short to produce a meaningful summary, your task is to logically enrich and expand upon its core theme. + + + - **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 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 based on the **expanded content**. + - **Output**: Directly output the expanded text content without further summarization. + + @@ -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 diff --git a/web/package-lock.json b/web/package-lock.json index 3a292ba..72de57e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 12e7e15..80e6c78 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/favicon.webp b/web/public/favicon.webp new file mode 100644 index 0000000000000000000000000000000000000000..495d9090ca4ab92e7aff56a688c6a8fd57116caa GIT binary patch literal 28766 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 { + return ( +
+
+
+
+
+

+ 联系我们 +

+

+ 我们很乐意听取您的意见。无论是问题、建议还是合作机会,请随时通过以下方式与我们联系。 +

+
+ +
+ {/* 电子邮件 */} +
+
+ +
+

+ 电子邮箱 +

+

+ 对于一般查询和支持,请发送邮件至: + + justlikemaki@foxmail.com + +

+
+ + {/* 社交媒体 */} +
+
+ +
+

+ 社交媒体 +

+

+ 在社交网络上关注我们,获取最新动态: +

+ +
+
+
+
+
+
+ ); +}; + +export default ContactUsPage; \ No newline at end of file diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index cebfc13..024e2a1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -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({
{/* Modal容器 */} diff --git a/web/src/components/AudioPlayerControls.tsx b/web/src/components/AudioPlayerControls.tsx index f4b1dc8..dcefcb5 100644 --- a/web/src/components/AudioPlayerControls.tsx +++ b/web/src/components/AudioPlayerControls.tsx @@ -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 ? ( - + ) : ( - + )} {isPlaying ? '暂停' : '播放'} ({audioDuration ?? '00:00'}) diff --git a/web/src/components/BillingToggle.tsx b/web/src/components/BillingToggle.tsx new file mode 100644 index 0000000..60358a7 --- /dev/null +++ b/web/src/components/BillingToggle.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +interface BillingToggleProps { + billingPeriod: 'monthly' | 'annually'; + onToggle: (period: 'monthly' | 'annually') => void; +} + +const BillingToggle: React.FC = ({ billingPeriod, onToggle }) => { + return ( +
+ + + {billingPeriod === 'annually' && ( + + 节省 20% + + )} +
+ ); +}; + +export default BillingToggle; \ No newline at end of file diff --git a/web/src/components/ConfigSelector.tsx b/web/src/components/ConfigSelector.tsx index 4e81149..13f450c 100644 --- a/web/src/components/ConfigSelector.tsx +++ b/web/src/components/ConfigSelector.tsx @@ -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 = ({
{selectedConfig === config.name && ( - + )} )) : ( diff --git a/web/src/components/ContentSection.tsx b/web/src/components/ContentSection.tsx index e354660..5d3c8a7 100644 --- a/web/src/components/ContentSection.tsx +++ b/web/src/components/ContentSection.tsx @@ -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 = ({ className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm" > 查看全部 - + )} @@ -116,7 +116,7 @@ const ContentSection: React.FC = ({ className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap" title="刷新" > - + 刷新 )} @@ -126,7 +126,7 @@ const ContentSection: React.FC = ({ className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap" > 查看全部 - + )} diff --git a/web/src/components/FooterLinks.tsx b/web/src/components/FooterLinks.tsx new file mode 100644 index 0000000..144f76f --- /dev/null +++ b/web/src/components/FooterLinks.tsx @@ -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 ( +
+ {/* 分隔符 */} +
+ +
+ ); +}; + +export default FooterLinks; \ No newline at end of file diff --git a/web/src/components/LoginModal.tsx b/web/src/components/LoginModal.tsx index d512e1e..f77ef8f 100644 --- a/web/src/components/LoginModal.tsx +++ b/web/src/components/LoginModal.tsx @@ -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 = ({ 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" > - + 使用 Google 登录 @@ -66,7 +66,7 @@ const LoginModal: FC = ({ 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 登录 diff --git a/web/src/components/PodcastCard.tsx b/web/src/components/PodcastCard.tsx index 06bb017..f49e356 100644 --- a/web/src/components/PodcastCard.tsx +++ b/web/src/components/PodcastCard.tsx @@ -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 = ({ /> ) : (
-
)} @@ -81,12 +80,12 @@ const PodcastCard: React.FC = ({
@@ -105,11 +104,11 @@ const PodcastCard: React.FC = ({

- + {podcast.audio_duration} {/* - + {podcast.playCount.toLocaleString()} */}
@@ -154,7 +153,6 @@ const PodcastCard: React.FC = ({ ) : (
-
)} @@ -163,12 +161,12 @@ const PodcastCard: React.FC = ({
@@ -185,15 +183,15 @@ const PodcastCard: React.FC = ({ isLiked ? "bg-red-500 text-white" : "bg-white/90 hover:bg-white text-neutral-600 hover:text-red-500" - )} - > - + )} + > + @@ -227,7 +225,7 @@ const PodcastCard: React.FC = ({ /> ) : (
- +
)} @@ -241,7 +239,7 @@ const PodcastCard: React.FC = ({ {/* 元数据 */}
{/*
- + {podcast.playCount.toLocaleString()}
*/}
diff --git a/web/src/components/PodcastContent.tsx b/web/src/components/PodcastContent.tsx index edc21c3..339edda 100644 --- a/web/src/components/PodcastContent.tsx +++ b/web/src/components/PodcastContent.tsx @@ -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" > - + 返回首页 - {/* 添加分享按钮 */} +
{/* 使用 flex 容器包裹分享和下载按钮 */} + {/* 添加分享按钮 */} + {audioInfo.audioUrl && ( + + + + )} +
{/* 1. 顶部信息区 */}
@@ -92,6 +104,17 @@ export default async function PodcastContent({ fileName }: PodcastContentProps) {audioInfo.title} + {/* 标签 */} + {audioInfo.tags && audioInfo.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag).length > 0 && ( +
+ {audioInfo.tags.split('#').filter((tag: string) => !!tag).map((tag: string) => ( + + {tag.trim()} + + ))} +
+ )} + {/* 元数据栏 */}
@@ -116,7 +139,7 @@ export default async function PodcastContent({ fileName }: PodcastContentProps) {/* 3. 内容导航区和内容展示区 - 使用客户端组件 */} ); diff --git a/web/src/components/PodcastCreator.tsx b/web/src/components/PodcastCreator.tsx index 99ca67c..d3556f8 100644 --- a/web/src/components/PodcastCreator.tsx +++ b/web/src/components/PodcastCreator.tsx @@ -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; // 修改为返回 Promise @@ -145,17 +155,46 @@ const PodcastCreator: React.FC = ({ {/* 品牌标题区域 */}
-
-
-
-

PodcastHub

+ + + + + + + + + + + + + + + + + + + + + PodcastHub + + +
-

- 把你的创意转为播客 +

+ 给创意一个真实的声音

- {/* 模式切换按钮 */} -
+ {/* 模式切换按钮 todo */} + {/*
-
+
*/}
{/* 主要创作区域 */} @@ -193,8 +232,8 @@ const PodcastCreator: React.FC = ({ 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 = ({ 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} />
@@ -259,7 +298,7 @@ const PodcastCreator: React.FC = ({ ))} - +
{/* 时长选择 */} @@ -276,20 +315,20 @@ const PodcastCreator: React.FC = ({ ))} - +
- {/* 右侧操作按钮 */} + {/* 右侧操作按钮 todo */}
{/* 文件上传 */} - = ({ accept=".txt,.md,.doc,.docx" onChange={handleFileUpload} className="hidden" - /> + /> */} {/* 粘贴链接 */} - + + */} {/* 复制 */} - + + */} + {/* 积分显示 */} -
+
@@ -338,7 +378,7 @@ const PodcastCreator: React.FC = ({ > {isGenerating ? ( <> - + Biu! ) : ( diff --git a/web/src/components/PricingCard.tsx b/web/src/components/PricingCard.tsx new file mode 100644 index 0000000..d22d4a3 --- /dev/null +++ b/web/src/components/PricingCard.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { PricingPlan, Feature } from '../types'; // 导入之前定义的类型 + +interface PricingCardProps { + plan: PricingPlan; +} + +const FeatureItem: React.FC<{ feature: Feature }> = ({ feature }) => ( +
  • + {feature.included ? ( + + + + ) : ( + + + + )} + {/* 文字调小,从text-lg到text-base */} + {feature.name} + {feature.notes && ( + {/* 文字调小,从text-sm到text-xs */} + {feature.notes} + + )} + +
  • +); + +const PricingCard: React.FC = ({ plan }) => { + const isMostPopular = plan.isMostPopular; + + return ( +
    + {isMostPopular && ( +
    + 最受欢迎 +
    + )} +
    +

    + {plan.name} +

    + +
    + + {plan.currency} + {plan.price} + + + /{plan.period === 'monthly' ? '月' : '月'} + +
    + + + +
    {/* 允许特性列表滚动,并添加自定义滚动条和右边距 */} +
      {/* 控制功能列表项之间的间距 */} + {plan.features.map((feature, index) => ( + + ))} +
    +
    +
    +
    + ); +}; + +export default PricingCard; \ No newline at end of file diff --git a/web/src/components/PricingSection.tsx b/web/src/components/PricingSection.tsx new file mode 100644 index 0000000..e89ae68 --- /dev/null +++ b/web/src/components/PricingSection.tsx @@ -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 ( +
    +
    +

    + 选择适合你的计划 +

    +

    + 无论你是个人创作者还是大型团队,我们都有满足你需求的方案。 +

    +
    + +
    + +
    + +
    + {currentPlans.map((plan) => ( + + ))} +
    + + +
    + ); +}; + +export default PricingSection; \ No newline at end of file diff --git a/web/src/components/ShareButton.tsx b/web/src/components/ShareButton.tsx index 617c0fb..0cd7e8b 100644 --- a/web/src/components/ShareButton.tsx +++ b/web/src/components/ShareButton.tsx @@ -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 = ({ className }) => { className={`text-neutral-500 hover:text-black transition-colors text-sm ${className}`} aria-label="分享页面" > - + ); }; diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index b52618c..d083e27 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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 = ({ } }, [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 = ({ 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 = ({ className="w-8 h-8 gradient-brand rounded-lg flex items-center justify-center hover:opacity-80 transition-opacity" title="展开侧边栏" > - +
    ) : ( /* 展开状态 - Logo和品牌名称 */ <> - {/* Logo图标 */} -
    -
    -
    - {/* 品牌名称容器 - 慢慢收缩动画 */} -
    - PodcastHub +
    + + + + + + + + + + + + + + + + + + + + + PodcastHub + + +
    {/* 收起按钮 */} @@ -154,7 +180,7 @@ const Sidebar: React.FC = ({ 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="收起侧边栏" > - +
    @@ -249,7 +275,7 @@ const Sidebar: React.FC = ({ collapsed ? "h-0 pt-0" : "h-auto pt-4" )}>
    {socialLinks.map((link, index) => { @@ -303,7 +329,7 @@ const Sidebar: React.FC = ({ /> ) : (
    - +
    )} @@ -331,7 +357,7 @@ const Sidebar: React.FC = ({ )} title={collapsed ? "登录" : undefined} > - +
    = ({ type, title, message, - duration = 5000, + duration = 3000, onClose, }) => { const [isVisible, setIsVisible] = useState(false); @@ -28,7 +28,8 @@ const Toast: React.FC = ({ 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 = ({ setIsLeaving(true); setTimeout(() => { onClose(id); - }, 300); + }, 200); // 调整动画时长,保持流畅 }; - - const getIcon = () => { - switch (type) { - case 'success': - return ; - case 'error': - return ; - case 'warning': - return ; - case 'info': - return ; - default: - return ; - } - }; - - 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 ( -
    - {getIcon()} - -
    -

    - {title} -

    - {message && ( -

    - {message} -

    - )} -
    - - -
    - ); -}; - -// Toast容器组件 -export interface ToastContainerProps { - toasts: ToastProps[]; - onRemove: (id: string) => void; -} - -export const ToastContainer: React.FC = ({ - toasts, - onRemove, -}) => { - return ( -
    - {toasts.map((toast) => ( - - ))} -
    + + const getIcon = () => { + switch (type) { + case 'success': + return ; // 更深沉的绿色 + case 'error': + return ; // 更深沉的红色 + case 'warning': + return ; // 调整为橙色 + case 'info': + return ; // 更深沉的蓝色 + default: + return ; // 默认灰色 + } + }; + + 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 ( +
    + {getIcon()} + +
    +

    {/* 字体更粗,颜色更深 */} + {title} +

    + {message && ( +

    {/* 字体稍大,颜色更深,允许换行 */} + {message} +

    + )} +
    + + +
    + ); + }; + + // Toast容器组件 + export interface ToastContainerProps { + toasts: ToastProps[]; + onRemove: (id: string) => void; + } + + export const ToastContainer: React.FC = ({ + toasts, + onRemove, + }) => { + return ( +
    {/* 定位到顶部水平居中,并限制宽度,使用flex布局垂直居中,增加间距 */} + {toasts.map((toast) => ( + + ))} +
    ); }; diff --git a/web/src/components/VoicesModal.tsx b/web/src/components/VoicesModal.tsx index 309cebd..3d7d410 100644 --- a/web/src/components/VoicesModal.tsx +++ b/web/src/components/VoicesModal.tsx @@ -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 = ({ 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="关闭" > - +
    @@ -254,7 +254,7 @@ const VoicesModal: React.FC = ({ 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="删除" > - +
    ))} @@ -279,4 +279,4 @@ const VoicesModal: React.FC = ({ isOpen, onClose, voices, onSe ); }; -export default VoicesModal; \ No newline at end of file +export default VoicesModal; diff --git a/web/src/lib/points.ts b/web/src/lib/points.ts index ff60c6a..2e5b362 100644 --- a/web/src/lib/points.ts +++ b/web/src/lib/points.ts @@ -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)); diff --git a/web/src/types/index.ts b/web/src/types/index.ts index daf8295..1a1aae6 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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'; } \ No newline at end of file