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 0000000..495d909 Binary files /dev/null and b/web/public/favicon.webp differ diff --git a/web/scripts/setup.js b/web/scripts/setup.js index 764d31b..bbaaf8e 100644 --- a/web/scripts/setup.js +++ b/web/scripts/setup.js @@ -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!🎙️'); \ No newline at end of file +console.log('\n享受使用PodcastHub!🎙️'); \ No newline at end of file diff --git a/web/src/app/api/newuser/route.ts b/web/src/app/api/newuser/route.ts index 7decb39..85f2f66 100644 --- a/web/src/app/api/newuser/route.ts +++ b/web/src/app/api/newuser/route.ts @@ -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); // 根据错误类型,可能需要更详细的错误处理或重定向 diff --git a/web/src/app/contact/page.tsx b/web/src/app/contact/page.tsx new file mode 100644 index 0000000..bf17bdb --- /dev/null +++ b/web/src/app/contact/page.tsx @@ -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 ( +
+
+
+
+
+

+ 联系我们 +

+

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

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

+ 电子邮箱 +

+

+ 对于一般查询和支持,请发送邮件至: + + 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