feat(i18n): 添加多语言支持并重构相关组件

实现国际化(i18n)支持,包括:
1. 新增i18n配置文件和中间件
2. 重构页面和组件以支持多语言
3. 添加中英日三语翻译文件
4. 修改API路由以支持语言参数
5. 更新README文档说明i18n功能
6. 添加语言切换组件
7. 调整布局和路由结构支持多语言路径
This commit is contained in:
hex2077
2025-08-25 00:46:32 +08:00
parent f9db0215e0
commit 0b00a3b0ae
74 changed files with 3342 additions and 876 deletions

View File

@@ -144,9 +144,9 @@ curl -X POST "http://localhost:8000/generate-podcast" \
```
```custom-begin
您希望提供给 AI 的额外指令或上下文,例如:
- 请确保讨论中包含对 [特定概念] 的深入分析。
- 请在对话中加入一些幽默元素,特别是关于 [某个主题] 的笑话。
- 所有角色的发言都必须是简短的,并且每句话不超过两行。
- "请确保讨论中包含对 [特定概念] 的深入分析。"
- "请在对话中加入一些幽默元素,特别是关于 [某个主题] 的笑话。"
- "所有角色的发言都必须是简短的,并且每句话不超过两行。"
```custom-end
```
@@ -194,6 +194,33 @@ curl -X POST "http://localhost:8000/generate-podcast" \
本项目支持通过 Docker 进行部署,详细信息请参考 [Docker 使用指南](DOCKER_USAGE.md)。
---
## 🌍 国际化 (i18n) 支持
本项目支持多语言界面,目前支持中文 (zh-CN) 和英文 (en)。
### 📁 语言文件结构
语言文件位于 `web/public/locales/` 目录下,按照语言代码分组:
- `web/public/locales/zh-CN/common.json` - 中文翻译
- `web/public/locales/en/common.json` - 英文翻译
### 🛠️ 添加新语言
1. 在 `web/public/locales/` 目录下创建新的语言文件夹,例如 `fr/`
2. 复制 `common.json` 文件到新文件夹中
3. 翻译文件中的所有键值对
4. 在 `web/next-i18next.config.js` 文件中添加新的语言代码到 `locales` 数组
5. 在 `web/src/i18n.ts` 文件中更新 `languages` 变量
### 🌐 语言切换
用户可以通过 URL 路径或浏览器语言设置自动切换语言:
- `http://localhost:3000/zh-CN/` - 中文界面
- `http://localhost:3000/en/` - 英文界面
---
## ⚙️ 配置文件详解
### `config/[tts-provider].json` (TTS 角色与语音配置)
@@ -329,7 +356,7 @@ curl -X POST "http://localhost:8000/generate-podcast" \
## 📝 免责声明
* **许可证**: 本项目采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 授权。
* **无担保**: 本软件按现状提供,不附带任何明示或暗示的担保。
* **无担保**: 本软件按"现状"提供,不附带任何明示或暗示的担保。
* **责任限制**: 在任何情况下,作者或版权持有者均不对因使用本软件而产生的任何损害承担责任。
* **第三方服务**: 用户需自行承担使用第三方服务(如 OpenAI API、TTS 服务)的风险和责任。
* **使用目的**: 本项目仅供学习和研究目的使用,请遵守所有适用的法律法规。

View File

@@ -9,6 +9,7 @@ const nextConfig = {
removeConsole: process.env.NODE_ENV === 'production',
},
output: 'standalone',
devIndicators: false,
};
module.exports = nextConfig;

793
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,17 @@
"drizzle-orm": "^0.44.4",
"framer-motion": "^11.3.8",
"globby": "^14.1.0",
"i18next": "^25.4.1",
"i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^0.424.0",
"next": "^14.2.5",
"next": "15.2.4",
"next-language-detector": "^1.1.0",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.40",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.7.2",
"react-icons": "^5.5.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1",

View File

@@ -0,0 +1,256 @@
{
"audioPlayer": {
"play": "Play",
"pause": "Pause",
"backward10s": "Backward 10s",
"forward10s": "Forward 10s",
"currentPlaybackRate": "Current playback rate",
"mute": "Mute",
"unmute": "Unmute",
"share": "Share",
"download": "Download",
"cannotGetAudioFileName": "Cannot get audio file name for sharing.",
"shareFailed": "Share failed: Cannot get audio file name.",
"playLinkCopied": "Play link copied to clipboard!"
},
"audioPlayerControls": {
"pause": "Pause",
"play": "Play"
},
"billingToggle": {
"monthly": "Monthly",
"annually": "Annually",
"save20Percent": "Save 20%"
},
"configSelector": {
"loading": "Loading...",
"selectTTSConfig": "Select TTS Config",
"noAvailableTTSConfig": "No available TTS config",
"pleaseConfigTTS": "Please configure TTS service in settings first"
},
"contentSection": {
"viewAll": "View All",
"noContent": "No content yet",
"refresh": "Refresh",
"recommendForYou": "Recommend for you"
},
"footerLinks": {
"termsOfUse": "Terms of Use",
"privacyPolicy": "Privacy Policy",
"contactUs": "Contact Us",
"copyright": "© 2025 Hex2077"
},
"languageSwitcher": {
"chinese": "中文",
"english": "English"
},
"loginModal": {
"loginToYourAccount": "Login to Your Account",
"signInWithGoogle": "Sign in with Google",
"signInWithGitHub": "Sign in with GitHub"
},
"podcastCard": {
"podcastGenerationQueued": "Podcast generation queued...",
"podcastGenerating": "Podcast generating...",
"moreOperations": "More operations",
"mostPopular": "Most Popular"
},
"podcastContent": {
"speaker": "Speaker",
"cannotLoadPodcastDetails": "Cannot load podcast details:",
"unknownError": "Unknown error",
"returnToHomepage": "Return to Homepage",
"downloadAudio": "Download Audio",
"script": "Script",
"outline": "Outline",
"noOutlineContent": "No outline content."
},
"podcastCreator": {
"giveVoiceToCreativity": "Give voice to creativity",
"enterTextPlaceholder": "Enter text, Markdown format supported...",
"addCustomInstructions": "Add custom instructions (optional)... e.g. fixed opening and closing remarks, contextual text, key points of output content",
"ttsConfigSelection": "TTS Config Selection",
"speaker": "Speaker",
"languageSelection": "Language Selection",
"durationSelection": "Duration Selection",
"fileUpload": "Upload File",
"pasteContent": "Paste Content",
"copyContent": "Copy Content",
"credits": "Credits",
"checkIn": "Check In",
"create": "Create",
"biu": "Biu!",
"checkInSuccess": "Check-in successful",
"checkInFailed": "Check-in failed",
"networkError": "Network error or server no response",
"topicCannotBeEmpty": "Topic cannot be empty",
"pleaseEnterPodcastTopic": "Please enter podcast topic.",
"ttsConfigNotSelected": "TTS config not selected",
"pleaseSelectTTSConfig": "Please select a TTS config.",
"pleaseSelectSpeaker": "Please select a speaker",
"pleaseSelectAtLeastOneSpeaker": "Please select at least one podcast speaker.",
"podcastGenerationFailed": "Podcast generation failed:",
"maximum5Speakers": "You can select up to 5 speakers.",
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"under5Minutes": "Under 5 minutes",
"between5And10Minutes": "5-10 minutes",
"between10And15Minutes": "10-15 minutes"
},
"podcastTabs": {
"script": "Script",
"outline": "Outline",
"noOutlineContent": "No outline content."
},
"pointsOverview": {
"totalPoints": "Total Points",
"last20EntriesOnly": "Only the last 20 entries are displayed.",
"pointDetails": "Point Details",
"noPointDetails": "No point details yet."
},
"pricingCard": {
"perMonth": "/month",
"getStarted": "Get Started",
"upgradeToPro": "Upgrade to Pro",
"upgradeToBusiness": "Upgrade to Business",
"mostPopular": "Most Popular"
},
"pricingSection": {
"creator": "Creator",
"pro": "Pro",
"business": "Business",
"chooseYourPlan": "Choose your plan",
"forIndividualsOrTeams": "Whether you are an individual creator or a large team, we have a plan for you.",
"visitPricingPage": "Visit Pricing Page",
"monthlyCreatorFeatures": {
"points": "2,000 Points/month",
"aiVoiceSynthesis": "AI voice synthesis",
"twoSpeakers": "Up to 2 speakers",
"commercialLicense": "Commercial License",
"audioDownload": "Audio Download"
},
"monthlyProFeatures": {
"points": "5,000 Points/month",
"aiVoiceSynthesis": "AI voice synthesis",
"multiSpeakers": "Multi-speaker support",
"commercialLicense": "Commercial License",
"audioDownload": "Audio Download",
"advancedVoices": "Advanced Voices",
"storytellingMode": "Storytelling Mode"
},
"monthlyBusinessFeatures": {
"points": "12,000 Points/month",
"aiVoiceSynthesis": "AI voice synthesis",
"multiSpeakers": "Multi-speaker support",
"commercialLicense": "Commercial License",
"dedicatedAccountManager": "Dedicated Account Manager",
"audioDownload": "Audio Download",
"advancedVoices": "Advanced Voices",
"storytellingMode": "Storytelling Mode",
"apiAccess": "API Access"
},
"comingSoon": "(Coming Soon)"
},
"settingsForm": {
"settings": "Settings",
"apiSettingsDescription": "Configure API settings and TTS services for the podcast generator",
"generalSettings": "General Settings",
"inputYourOpenAIAPIKey": "Input your OpenAI API Key",
"model": "Model",
"selectOrEnterModelName": "Select or enter model name",
"customModelInput": "Enter custom model",
"optionalCustomBaseURL": "Optional: Custom API Base URL",
"ttsServiceSettings": "TTS Service Settings",
"webAPITTSServices": "Web API TTS Services",
"edgeTTS": "Edge TTS",
"edgeTTSDescription": "Free TTS service based on Microsoft Edge, providing high-quality speech synthesis.",
"doubaoTTS": "Doubao TTS",
"doubaoTTSDescription": "Speech synthesis service powered by ByteDance Volcano Engine, baseUrl=https://openspeech.bytedance.com/api/v3/tts/unidirectional",
"inputDoubaoAppID": "Input Doubao App ID",
"inputDoubaoAccessKey": "Input Doubao Access Key",
"minimaxTTS": "Minimax TTS",
"minimaxTTSDescription": "Speech synthesis service powered by Minimax, baseUrl=https://api.minimaxi.com/v1/t2a_v2",
"inputMinimaxGroupID": "Input Minimax Group ID",
"inputMinimaxAPIKey": "Input Minimax API Key",
"fishTTS": "Fish TTS",
"fishTTSDescription": "Speech synthesis service powered by FishAudio, baseUrl=https://api.fish.audio/v1/tts",
"inputFishTTSAPIKey": "Input Fish TTS API Key",
"geminiTTS": "Gemini TTS",
"geminiTTSDescription": "Speech synthesis service powered by Google Gemini, baseUrl=https://generativelanguage.googleapis.com/v1beta/models",
"inputGeminiAPIKey": "Input Gemini API Key",
"localAPITTSServices": "Local API TTS Services",
"indexTTS": "Index TTS",
"indexTTSDescription": "IndexTTS service for local deployment, providing custom speech synthesis capabilities.",
"reset": "Reset",
"saving": "Saving...",
"saveSettings": "Save Settings",
"settingsSavedSuccessfully": "Settings saved successfully!",
"errorSavingSettings": "Error saving settings, please try again",
"configurationNotes": "Configuration Notes",
"apiKeyRequired": "API Key is required to call OpenAI service to generate podcast script",
"ttsOptional": "TTS service configuration is optional, unconfigured services will not be displayed in voice selection",
"emptyFieldsNull": "Empty fields will be saved as null values",
"settingsApplyImmediately": "Settings will take effect immediately after saving, no application restart required",
"apiKey": "API Key",
"baseURL": "Base URL",
"appID": "App ID",
"accessKey": "Access Key",
"groupID": "Group ID"
},
"shareButton": {
"copySuccess": "Copy successful",
"pageLinkCopied": "Page link copied to clipboard!",
"copyFailed": "Copy failed",
"cannotCopyPageLink": "Cannot copy page link to clipboard."
},
"sidebar": {
"expandSidebar": "Expand Sidebar",
"collapseSidebar": "Collapse Sidebar",
"home": "Home",
"library": "Library",
"explore": "Explore",
"pricing": "Pricing",
"points": "Points",
"ttsSettings": "TTS Settings",
"github": "Github",
"twitter": "Twitter",
"tiktok": "TikTok",
"email": "Email",
"login": "Login",
"logout": "Logout",
"areYouSureToLogout": "Are you sure you want to log out?",
"cancel": "Cancel",
"confirmLogout": "Logout",
"sessionExpired": "Session expired, logging out...",
"user": "User",
"clickAvatarToLogout": "Click avatar to logout",
"lessThanSMSizeCannotExpand": "Cannot expand on screens smaller than sm size",
"showMore": "Show more"
},
"toast": {
"title": "Notification",
"message": "This is a notification message."
},
"voicesModal": {
"selectSpeaker": "Select Speaker",
"all": "All",
"male": "Male",
"female": "Female",
"chinese": "Chinese (zh)",
"english": "English (en)",
"japanese": "Japanese (ja)",
"close": "Close",
"searchVoices": "Search voices...",
"noMatchingVoices": "No matching voices found.",
"language": "Language",
"unknown": "Unknown",
"host": "Host",
"confirmSelection": "Confirm Selection",
"max5Speakers": "You can select up to 5 speakers.",
"searchVoicesPlaceholder": "Search voices...",
"maxVoicesAlert": "You can select up to 5 speakers.",
"delete": "Delete",
"presenter": "Presenter"
}
}

View File

@@ -0,0 +1,10 @@
{
"contact_us_title": "Contact Us - PodcastHub",
"contact_us_description": "Have questions or suggestions? Feel free to contact the PodcastHub team. We look forward to hearing from you.",
"contact_us_heading": "Contact Us",
"contact_us_subheading": "We'd love to hear from you. Whether it's a question, suggestion, or collaboration opportunity, feel free to contact us through the following methods.",
"email_title": "Email",
"email_description": "For general inquiries and support, please email:",
"social_media_title": "Social Media",
"social_media_description": "Follow us on social media for the latest updates:"
}

View File

@@ -0,0 +1,49 @@
{
"invalid_filename": "Invalid filename",
"unsupported_file_type": "Unsupported file type",
"file_not_found": "File not found",
"internal_server_error": "Internal server error"
,
"unauthorized": "Unauthorized",
"invalid_status": "Invalid status",
"invalid_request_parameters": "Invalid request parameters",
"request_too_old_or_future": "Request too old or in the future",
"points_deducted_successfully": "Points deducted successfully",
"insufficient_points": "Insufficient points",
"daily_sign_in": "Daily sign-in",
"already_signed_in_today": "Already signed in today",
"points_added_successfully": "Points added successfully"
,
"config_files_list_success": "Configuration files list retrieved successfully",
"config_files_list_error": "Failed to retrieve configuration files list",
"invalid_config_file_name": "Invalid configuration file name",
"config_file_read_success": "Configuration file read successfully",
"read_config_file_error": "Failed to read configuration file"
,
"missing_file_name_parameter": "Missing file_name query parameter",
"internal_server_error_backend_connection": "Internal server error or unable to connect to backend service"
,
"user_not_logged_in_or_session_expired": "User not logged in or session expired",
"request_body_cannot_be_empty": "Request body cannot be empty",
"tts_provider_cannot_be_empty": "TTS provider cannot be empty",
"please_select_at_least_one_speaker": "Please select at least one podcast speaker",
"invalid_speaker_config_format": "Invalid podcast speaker configuration format",
"insufficient_points_for_podcast": "Insufficient points, generating a podcast requires {{pointsNeeded}} points, you currently have {{currentPoints}} points.",
"invalid_output_language": "Invalid output language",
"invalid_podcast_duration": "Invalid podcast duration",
"missing_frontend_tts_config": "Missing frontend TTS configuration information",
"incomplete_backend_tts_config": "Incomplete backend TTS configuration, please check backend configuration file.",
"internal_server_error_default": "Internal server error"
,
"failed_to_get_task_status": "Failed to get task status"
,
"insufficient_points_raw": "积分不足"
,
"forbidden_user_id": "Forbidden: User ID not allowed to access this resource",
"invalid_request_parameters_add_points": "Invalid request parameters. `userId`, `pointsToAdd` (positive number), `reasonCode`, and `description` are required.",
"points_added_successfully_to_user": "Points successfully added to user {{userId}}"
,
"invalid_pagination_parameters": "Invalid pagination parameters"
,
"cannot_read_tts_provider_config": "Cannot read TTS provider configuration file"
}

View File

@@ -0,0 +1,26 @@
{
"podcastGenerationFailed": "Podcast generation failed, please retry",
"podcastTagsPlaceholder": "Pending podcast tags",
"configErrorTitle": "Configuration Error",
"configErrorMessage": "API Key or Model not set, please go to settings page to fill in.",
"error": {
"401": "Podcast generation failed, please check if the API Key is correct, or login status.",
"402": "Podcast generation failed, please check if you have enough points.",
"403": "Podcast generation failed, please login and try again.",
"409": "Podcast generation failed, there is a task in progress",
"backend": "Podcast generation failed, please check backend service or configuration",
"generationFailed": "Generation Failed",
"noTaskId": "Generation task failed, no task ID returned",
"unknown": "Unknown Error",
"dataProcessing": "Data processing failed",
"cantProcessPodcastList": "Unable to process podcast list data"
},
"taskCreatedTitle": "Task Created",
"taskCreatedMessage": "Podcast generation task has been started, Task ID",
"recentlyGenerated": "Recently Generated",
"dataRetentionWarning": "Data is only kept for 30 minutes, please download and save it as soon as possible",
"saveSuccessTitle": "Save successfully",
"saveErrorTitle": "Save failed",
"pageInDevelopment": "Page in development",
"featureComingSoon": "This feature is under development, please look forward to it."
}

View File

@@ -0,0 +1,5 @@
{
"title": "PodcastHub: Your AI Podcast creation platform - easily convert text into high-quality podcast audio, supporting multiple voices and styles, making creativity accessible.",
"description": "PodcastHub uses cutting-edge AI technology to provide unlimited possibilities for your creativity. Easily convert text and ideas into professional-quality podcast audio, with a variety of personalized voice and style options. Experience efficient creation now, spread your voice globally, attract more listeners, and simplify your podcast production process.",
"keywords": "podcast,AI,voice synthesis,TTS,audio generation"
}

View File

@@ -0,0 +1,84 @@
{
"privacy_policy": {
"title": "Privacy Policy - PodcastHub",
"description": "Learn how PodcastHub protects your privacy. We are committed to transparently handling your data.",
"last_updated": "Last updated: August 21, 2025",
"intro_paragraph": "Thank you for choosing PodcastHub! We understand the importance of privacy to you. This Privacy Policy (hereinafter referred to as \"this Policy\") aims to explain how we collect, use, store, share, and protect your personal information. Please read and understand this Policy carefully before using our services.",
"section1": {
"title": "1. Information We Collect",
"intro": "To provide and optimize our services, we collect the following types of information:",
"point1": {
"heading": "Information you actively provide",
"content": "When you register an account, use services, or contact us, you may provide personal information such as your name, email address, password, and text content you upload for podcast generation."
},
"point2": {
"heading": "Information we automatically collect",
"content": "When you use the service, our servers automatically record certain information, including your IP address, browser type and version, operating system, device information, access date and time, and your interaction data within the service (such as clickstream, feature usage frequency, etc.)."
},
"point3": {
"heading": "Cookies and similar technologies",
"content": "We use Cookies to store your preferences, maintain login status, and analyze usage to improve user experience. You can manage or delete Cookies according to your preferences."
}
},
"section2": {
"title": "2. How We Use Your Information",
"intro": "We will use your personal information for the following purposes:",
"point1": {
"heading": "Provide and maintain services",
"content": ": Process your text content to generate podcasts, manage your account, and ensure the normal operation of services."
},
"point2": {
"heading": "Improve and develop services",
"content": ": Analyze usage data to understand user preferences, optimize existing features, and develop new products and services."
},
"point3": {
"heading": "Communicate with you",
"content": ": Send you important service notifications, account security reminders, update instructions, or marketing information you may be interested in (you can choose to unsubscribe)."
},
"point4": {
"heading": "Security assurance",
"content": ": Protect our services, users, and the public from fraud, abuse, and security threats."
},
"point5": {
"heading": "Comply with legal obligations",
"content": ": Fulfill applicable laws and regulations."
}
},
"section3": {
"title": "3. Information Sharing and Disclosure",
"intro": "We are committed to keeping your information confidential. Unless the following circumstances exist, we will not share your personal information with any third party:",
"point1": {
"heading": "Obtain your explicit consent",
"content": ": With your explicit consent, we will share your information with other parties."
},
"point2": {
"heading": "Legal requirements",
"content": ": We may disclose your personal information externally in accordance with laws and regulations, legal procedures, or mandatory requirements from government authorities."
},
"point3": {
"heading": "Service providers",
"content": ": We may share necessary information with trusted third-party service providers (such as cloud storage, data analysis services) to assist us in providing and improving services. These providers are obligated to comply with our data protection standards."
},
"point4": {
"heading": "Protecting our rights",
"content": ": To enforce our terms of service, protect our rights, property, or safety, and protect our users or the public from harm, we may share information to the necessary extent."
}
},
"section4": {
"title": "4. Data Security",
"content": "We adopt industry-standard security measures to protect your information and prevent unauthorized access, disclosure, use, modification, or destruction. These measures include data encryption, access control, and regular security reviews. However, no internet transmission or electronic storage method is 100% secure, so we cannot guarantee absolute security."
},
"section5": {
"title": "5. Your Rights",
"content": "You have various rights regarding your personal information, including accessing, correcting, and deleting your account information. You can exercise these rights through account settings or by contacting us."
},
"section6": {
"title": "6. Policy Changes",
"content": "We may revise this Privacy Policy from time to time. For any significant changes, we will notify you by posting a prominent notice on the website or by sending you an email at least 30 days before the new terms take effect. What constitutes a significant change will be determined by us at our sole discretion."
},
"section7": {
"title": "7. Contact Us",
"content": "If you have any questions or concerns about this Privacy Policy or our privacy practices, please feel free to contact us through our contact page."
}
}
}

View File

@@ -0,0 +1,62 @@
{
"terms_of_service": {
"title": "Terms of Service - PodcastHub",
"description": "Welcome to PodcastHub's Terms of Service. These terms are designed to protect the common interests of users and the platform.",
"heading": "PodcastHub Terms of Service",
"last_updated": "Last updated: August 21, 2025",
"intro_paragraph": "Welcome to PodcastHub! We provide leading AI technology to convert your text content into high-quality podcast audio (hereinafter referred to as \"the Service\"). Before using our services, please read the following Terms of Service (hereinafter referred to as \"these Terms\") carefully. By accessing or using our services, you agree to be bound by these Terms.",
"section1": {
"title": "1. Service Overview",
"content": "PodcastHub is a platform that allows users to upload text, configure parameters, and generate audio content. Services include but are not limited to text-to-speech (TTS), audio processing, content storage, and distribution support. We are committed to providing stable and efficient technical solutions but reserve the right to modify or suspend services at any time without prior notice."
},
"section2": {
"title": "2. User Account and Security",
"content": "You need to register an account to use all our services. You promise to provide accurate, complete, and up-to-date registration information and are responsible for maintaining the confidentiality of your account and password. All activities conducted through your account are your responsibility. If you discover any unauthorized use of your account, please notify us immediately."
},
"section3": {
"title": "3. User Conduct Guidelines",
"intro": "You agree to abide by all applicable laws and regulations when using this service and shall not use this service to engage in the following activities:",
"point1": "Upload, generate, or disseminate any content that is illegal, harmful, threatening, abusive, harassing, defamatory, obscene, infringes on the privacy of others, incites hatred, or is racially or ethnically objectionable.",
"point2": "Infringe on the intellectual property rights of any third party, including but not limited to copyrights, trademarks, patents, or trade secrets.",
"point3": "Impersonate any person or entity, or otherwise misrepresent your affiliation with any person or entity.",
"point4": "Interfere with or disrupt our services, servers, or networks connected to the services, or fail to comply with any requirements, procedures, policies, or regulations of networks connected to the services.",
"point5": "Engage in any activity that may impose an unreasonable or disproportionately large load on our service infrastructure."
},
"section4": {
"title": "4. Intellectual Property",
"point1": {
"heading": "Your Content",
"content": ": You retain full ownership and intellectual property rights to all text content you submit to the service (\"User Content\"). You grant PodcastHub a worldwide, non-exclusive, royalty-free license to use, reproduce, process, adapt, modify, publish, transmit, and display your User Content solely for the purpose of operating, providing, and improving the Service."
},
"point2": {
"heading": "Generated Content",
"content": ": The copyright of audio content generated through this service (\"Generated Content\") depends on the copyright status of the User Content you input. As a technology service provider, we do not claim any ownership of the copyright of Generated Content."
},
"point3": {
"heading": "Our Services",
"content": ": This service and all its related technologies, trademarks, logos, and content (excluding your content and generated content) are the proprietary property of PodcastHub. You may not use them without our prior written consent."
}
},
"section5": {
"title": "5. Disclaimers and Limitation of Liability",
"point1": "This service is provided \"as is\" and \"as available,\" and we make no express or implied warranties, including but not limited to implied warranties of merchantability, fitness for a particular purpose, and non-infringement. We do not warrant that the service will be uninterrupted, timely, secure, or error-free.",
"point2": "To the maximum extent permitted by law, PodcastHub and its affiliates, directors, employees, or licensors shall in no event be liable for any indirect, incidental, special, consequential, or punitive damages arising from your access to or use of this service, whether such damages are based on warranty, contract, tort (including negligence), or any other legal theory."
},
"section6": {
"title": "6. Termination of Service",
"content": "We reserve the right to suspend or terminate your access to this service at any time for any reason (including your breach of these terms) without prior notice. You may also terminate this agreement at any time by deactivating your account. Upon termination, your right to use this service will cease immediately."
},
"section7": {
"title": "7. Modification of Terms",
"content": "We reserve the right to modify or replace these terms at our sole discretion at any time. If the changes are material, we will notify you by prominent notice on the website or by sending you an email at least 30 days before the new terms take effect. What constitutes a material change will be determined by us at our sole discretion."
},
"section8": {
"title": "8. Governing Law",
"content": "The conclusion, validity, interpretation, performance, and dispute resolution of these terms shall be governed by relevant laws."
},
"section9": {
"title": "9. Contact Us",
"content": "If you have any questions about these terms, please contact us through our contact page."
}
}
}

View File

@@ -0,0 +1,258 @@
{
"audioPlayer": {
"play": "再生",
"pause": "一時停止",
"backward10s": "10秒巻き戻し",
"forward10s": "10秒早送り",
"currentPlaybackRate": "現在の再生速度",
"mute": "ミュート",
"unmute": "ミュート解除",
"share": "共有",
"download": "ダウンロード",
"cannotGetAudioFileName": "共有する音声ファイル名を取得できません。",
"shareFailed": "共有に失敗しました: 音声ファイル名を取得できません。",
"playLinkCopied": "再生リンクがクリップボードにコピーされました!"
},
"audioPlayerControls": {
"pause": "一時停止",
"play": "再生"
},
"billingToggle": {
"monthly": "月払い",
"annually": "年払い",
"save20Percent": "20%割引"
},
"configSelector": {
"loading": "読み込み中...",
"selectTTSConfig": "TTS設定を選択",
"noAvailableTTSConfig": "利用可能なTTS設定がありません",
"pleaseConfigTTS": "最初に設定でTTSサービスを設定してください"
},
"contentSection": {
"viewAll": "すべて表示",
"noContent": "コンテンツがありません",
"refresh": "更新",
"recommendForYou": "あなたへのおすすめ"
},
"footerLinks": {
"termsOfUse": "利用規約",
"privacyPolicy": "プライバシーポリシー",
"contactUs": "お問い合わせ",
"copyright": "© 2025 Hex2077"
},
"languageSwitcher": {
"chinese": "中文",
"english": "英語"
},
"loginModal": {
"loginToYourAccount": "アカウントにログイン",
"signInWithGoogle": "Googleでサインイン",
"signInWithGitHub": "GitHubでサインイン"
},
"podcastCard": {
"podcastGenerationQueued": "ポッドキャスト生成キューに追加されました...",
"podcastGenerating": "ポッドキャスト生成中...",
"moreOperations": "その他の操作",
"mostPopular": "最も人気"
},
"podcastContent": {
"speaker": "スピーカー",
"cannotLoadPodcastDetails": "ポッドキャストの詳細を読み込めません:",
"unknownError": "不明なエラー",
"returnToHomepage": "ホームページに戻る",
"downloadAudio": "音声をダウンロード",
"script": "スクリプト",
"outline": "概要",
"noOutlineContent": "概要コンテンツがありません。"
},
"podcastCreator": {
"giveVoiceToCreativity": "創造性に声を",
"enterTextPlaceholder": "テキストを入力してください。Markdown形式をサポートしています...",
"addCustomInstructions": "カスタム指示を追加(任意)...例:固定のオープニングとクロージング、コンテキストテキスト、出力コンテンツの主要なポイント",
"ttsConfigSelection": "TTS設定の選択",
"speaker": "スピーカー",
"languageSelection": "言語選択",
"durationSelection": "長さの選択",
"fileUpload": "ファイルをアップロード",
"pasteContent": "コンテンツを貼り付け",
"copyContent": "コンテンツをコピー",
"credits": "クレジット",
"checkIn": "チェックイン",
"create": "作成",
"biu": "びゅう!",
"checkInSuccess": "チェックイン成功",
"checkInFailed": "チェックイン失敗",
"networkError": "ネットワークエラーまたはサーバー応答なし",
"topicCannotBeEmpty": "トピックは空にできません",
"pleaseEnterPodcastTopic": "ポッドキャストのトピックを入力してください。",
"ttsConfigNotSelected": "TTS設定が選択されていません",
"pleaseSelectTTSConfig": "TTS設定を選択してください。",
"pleaseSelectSpeaker": "スピーカーを選択してください",
"pleaseSelectAtLeastOneSpeaker": "少なくとも1人のポッドキャストスピーカーを選択してください。",
"podcastGenerationFailed": "ポッドキャストの生成に失敗しました:",
"maximum5Speakers": "最大5人のスピーカーを選択できます。",
"chinese": "中国語",
"english": "英語",
"japanese": "日本語",
"under5Minutes": "5分未満",
"between5And10Minutes": "5〜10分",
"between10And15Minutes": "10〜15分"
},
"podcastTabs": {
"script": "スクリプト",
"outline": "概要",
"noOutlineContent": "概要コンテンツがありません。"
},
"pointsOverview": {
"totalPoints": "合計ポイント",
"last20EntriesOnly": "最後の20エントリのみが表示されます。",
"pointDetails": "ポイント詳細",
"noPointDetails": "ポイント詳細がありません。"
},
"pricingCard": {
"perMonth": "/月",
"getStarted": "始める",
"upgradeToPro": "Proにアップグレード",
"upgradeToBusiness": "Businessにアップグレード",
"mostPopular": "最も人気"
},
"pricingSection": {
"creator": "クリエーター",
"pro": "プロ",
"business": "ビジネス",
"chooseYourPlan": "プランを選択してください",
"forIndividualsOrTeams": "個人クリエイターでも大規模チームでも、あなたに合ったプランがあります。",
"visitPricingPage": "料金ページを見る",
"monthlyCreatorFeatures": {
"points": "2,000ポイント/月",
"aiVoiceSynthesis": "AI音声合成",
"twoSpeakers": "最大2名スピーカー",
"commercialLicense": "商用ライセンス",
"audioDownload": "音声ダウンロード"
},
"monthlyProFeatures": {
"points": "5,000ポイント/月",
"aiVoiceSynthesis": "AI音声合成",
"multiSpeakers": "複数スピーカー対応",
"commercialLicense": "商用ライセンス",
"audioDownload": "音声ダウンロード",
"advancedVoices": "高度な音声",
"storytellingMode": "ストーリーテリングモード"
},
"monthlyBusinessFeatures": {
"points": "12,000ポイント/月",
"aiVoiceSynthesis": "AI音声合成",
"multiSpeakers": "複数スピーカー対応",
"commercialLicense": "商用ライセンス",
"dedicatedAccountManager": "専任アカウントマネージャー",
"audioDownload": "音声ダウンロード",
"advancedVoices": "高度な音声",
"storytellingMode": "ストーリーテリングモード",
"apiAccess": "APIアクセス"
},
"comingSoon": "(近日公開)",
"pricing_page_title": "料金プラン",
"pricing_page_description": "すべてのクリエイターのための柔軟なプラン。"
},
"settingsForm": {
"settings": "設定",
"apiSettingsDescription": "ポッドキャストジェネレーターのAPI設定とTTSサービスを設定",
"generalSettings": "一般設定",
"inputYourOpenAIAPIKey": "OpenAI APIキーを入力",
"model": "モデル",
"selectOrEnterModelName": "モデル名を選択または入力",
"customModelInput": "カスタムモデルを入力",
"optionalCustomBaseURL": "オプションカスタムAPIベースURL",
"ttsServiceSettings": "TTSサービス設定",
"webAPITTSServices": "Web API TTSサービス",
"edgeTTS": "Edge TTS",
"edgeTTSDescription": "Microsoft Edgeベースの無料TTSサービスで、高品質の音声合成を提供します。",
"doubaoTTS": "Doubao TTS",
"doubaoTTSDescription": "ByteDance Volcano Engineによる音声合成サービス、baseUrl=https://openspeech.bytedance.com/api/v3/tts/unidirectional",
"inputDoubaoAppID": "Doubao App IDを入力",
"inputDoubaoAccessKey": "Doubao Access Keyを入力",
"minimaxTTS": "Minimax TTS",
"minimaxTTSDescription": "Minimaxによる音声合成サービス、baseUrl=https://api.minimaxi.com/v1/t2a_v2",
"inputMinimaxGroupID": "Minimax Group IDを入力",
"inputMinimaxAPIKey": "Minimax APIキーを入力",
"fishTTS": "Fish TTS",
"fishTTSDescription": "FishAudioによる音声合成サービス、baseUrl=https://api.fish.audio/v1/tts",
"inputFishTTSAPIKey": "Fish TTS APIキーを入力",
"geminiTTS": "Gemini TTS",
"geminiTTSDescription": "Google Geminiによる音声合成サービス、baseUrl=https://generativelanguage.googleapis.com/v1beta/models",
"inputGeminiAPIKey": "Gemini APIキーを入力",
"localAPITTSServices": "ローカルAPI TTSサービス",
"indexTTS": "Index TTS",
"indexTTSDescription": "ローカル展開用のIndexTTSサービスで、カスタム音声合成機能を提供します。",
"reset": "リセット",
"saving": "保存中...",
"saveSettings": "設定を保存",
"settingsSavedSuccessfully": "設定が正常に保存されました!",
"errorSavingSettings": "設定の保存中にエラーが発生しました。もう一度お試しください",
"configurationNotes": "設定ノート",
"apiKeyRequired": "ポッドキャストスクリプトを生成するためにOpenAIサービスを呼び出すにはAPIキーが必要です",
"ttsOptional": "TTSサービス設定はオプションです。設定されていないサービスは音声選択に表示されません",
"emptyFieldsNull": "空のフィールドはnull値として保存されます",
"settingsApplyImmediately": "設定は保存後すぐに適用され、アプリケーションの再起動は不要です",
"apiKey": "APIキー",
"baseURL": "ベースURL",
"appID": "App ID",
"accessKey": "アクセスキー",
"groupID": "グループID"
},
"shareButton": {
"copySuccess": "コピー成功",
"pageLinkCopied": "ページリンクがクリップボードにコピーされました!",
"copyFailed": "コピー失敗",
"cannotCopyPageLink": "ページリンクをクリップボードにコピーできません。"
},
"sidebar": {
"expandSidebar": "サイドバーを展開",
"collapseSidebar": "サイドバーを折りたたむ",
"home": "ホーム",
"library": "ライブラリ",
"explore": "探索",
"pricing": "料金",
"points": "ポイント",
"ttsSettings": "TTS設定",
"github": "Github",
"twitter": "Twitter",
"tiktok": "TikTok",
"email": "メール",
"login": "ログイン",
"logout": "ログアウト",
"areYouSureToLogout": "本当にログアウトしますか?",
"cancel": "キャンセル",
"confirmLogout": "ログアウト",
"sessionExpired": "セッションが期限切れです。ログアウトします...",
"user": "ユーザー",
"clickAvatarToLogout": "アバターをクリックしてログアウト",
"lessThanSMSizeCannotExpand": "smサイズ未満の画面では展開できません",
"showMore": "もっと見る"
},
"toast": {
"title": "通知",
"message": "これは通知メッセージです。"
},
"voicesModal": {
"selectSpeaker": "スピーカーを選択",
"all": "すべて",
"male": "男性",
"female": "女性",
"chinese": "中国語 (zh)",
"english": "英語 (en)",
"japanese": "日本語 (ja)",
"close": "閉じる",
"searchVoices": "声を検索...",
"noMatchingVoices": "一致する声が見つかりません。",
"language": "言語",
"unknown": "不明",
"host": "ホスト",
"confirmSelection": "選択を確定",
"max5Speakers": "最大5人のスピーカーを選択できます。",
"searchVoicesPlaceholder": "声を検索...",
"maxVoicesAlert": "最大5人のスピーカーを選択できます。",
"delete": "削除",
"presenter": "プレゼンター"
}
}

View File

@@ -0,0 +1,10 @@
{
"contact_us_title": "お問い合わせ - PodcastHub",
"contact_us_description": "ご質問やご提案がありますかお気軽にPodcastHubチームにお問い合わせください。皆様からのご連絡をお待ちしております。",
"contact_us_heading": "お問い合わせ",
"contact_us_subheading": "ご質問、ご提案、またはコラボレーションの機会など、お気軽にお問い合わせください。",
"email_title": "メール",
"email_description": "一般的なお問い合わせやサポートは、以下のアドレスまでメールでお問い合わせください。",
"social_media_title": "ソーシャルメディア",
"social_media_description": "最新情報については、ソーシャルメディアで私たちをフォローしてください:"
}

View File

@@ -0,0 +1,49 @@
{
"invalid_filename": "無効なファイル名",
"unsupported_file_type": "サポートされていないファイルタイプ",
"file_not_found": "ファイルが見つかりません",
"internal_server_error": "サーバー内部エラー"
,
"unauthorized": "未承認",
"invalid_status": "無効なステータス",
"invalid_request_parameters": "無効なリクエストパラメータ",
"request_too_old_or_future": "リクエストが古すぎるか未来です",
"points_deducted_successfully": "ポイントが正常に差し引かれました",
"insufficient_points": "ポイント不足",
"daily_sign_in": "毎日サインイン",
"already_signed_in_today": "本日はすでにサインイン済みです",
"points_added_successfully": "ポイントが正常に追加されました"
,
"config_files_list_success": "設定ファイルリストが正常に取得されました",
"config_files_list_error": "設定ファイルリストの取得に失敗しました",
"invalid_config_file_name": "無効な設定ファイル名",
"config_file_read_success": "設定ファイルが正常に読み取られました",
"read_config_file_error": "設定ファイルの読み取りに失敗しました"
,
"missing_file_name_parameter": "ファイル名クエリパラメータがありません",
"internal_server_error_backend_connection": "内部サーバーエラーまたはバックエンドサービスに接続できません"
,
"user_not_logged_in_or_session_expired": "ユーザーがログインしていないか、セッションの有効期限が切れています",
"request_body_cannot_be_empty": "リクエストボディは空にできません",
"tts_provider_cannot_be_empty": "TTSプロバイダーは空にできません",
"please_select_at_least_one_speaker": "少なくとも1人のポッドキャスト話者を選択してください",
"invalid_speaker_config_format": "無効なポッドキャスト話者設定フォーマット",
"insufficient_points_for_podcast": "ポイントが不足しています。ポッドキャストを生成するには{{pointsNeeded}}ポイントが必要です。現在{{currentPoints}}ポイントしかありません。",
"invalid_output_language": "無効な出力言語",
"invalid_podcast_duration": "無効なポッドキャスト期間",
"missing_frontend_tts_config": "フロントエンドTTS設定情報がありません",
"incomplete_backend_tts_config": "バックエンドTTS設定が不完全です。バックエンド設定ファイルを確認してください。",
"internal_server_error_default": "サーバー内部エラー"
,
"failed_to_get_task_status": "タスクステータスの取得に失敗しました"
,
"insufficient_points_raw": "ポイント不足"
,
"forbidden_user_id": "禁止このリソースへのアクセスが許可されていないユーザーID",
"invalid_request_parameters_add_points": "無効なリクエストパラメータ。`userId`、`pointsToAdd`(正の数)、`reasonCode`、および`description`が必要です。",
"points_added_successfully_to_user": "ユーザー{{userId}}にポイントが正常に追加されました"
,
"invalid_pagination_parameters": "無効なページネーションパラメータ"
,
"cannot_read_tts_provider_config": "TTSプロバイダー構成ファイルを読み取れません"
}

View File

@@ -0,0 +1,26 @@
{
"podcastGenerationFailed": "ポッドキャストの生成に失敗しました。再試行してください。",
"podcastTagsPlaceholder": "保留中のポッドキャストタグ",
"configErrorTitle": "設定エラー",
"configErrorMessage": "APIキーまたはモデルが設定されていません。設定ページで入力してください。",
"error": {
"401": "ポッドキャストの生成に失敗しました。APIキーが正しいか、ログイン状態を確認してください。",
"402": "ポッドキャストの生成に失敗しました。ポイントが十分にあるか確認してください。",
"403": "ポッドキャストの生成に失敗しました。ログインしてもう一度お試しください。",
"409": "ポッドキャストの生成に失敗しました。タスクが進行中です。",
"backend": "ポッドキャストの生成に失敗しました。バックエンドサービスまたは設定を確認してください。",
"generationFailed": "生成に失敗しました。",
"noTaskId": "生成タスクに失敗しました。タスクIDが返されませんでした。",
"unknown": "不明なエラー",
"dataProcessing": "データ処理に失敗しました。",
"cantProcessPodcastList": "ポッドキャストリストデータを処理できませんでした。"
},
"taskCreatedTitle": "タスク作成済み",
"taskCreatedMessage": "ポッドキャスト生成タスクが開始されました。タスクID",
"recentlyGenerated": "最近生成されたもの",
"dataRetentionWarning": "データは30分間のみ保持されます。できるだけ早くダウンロードして保存してください。",
"saveSuccessTitle": "保存成功",
"saveErrorTitle": "保存失敗",
"pageInDevelopment": "開発中のページ",
"featureComingSoon": "この機能は開発中です。ご期待ください。"
}

View File

@@ -0,0 +1,5 @@
{
"title": "PodcastHubあなたのAIポッドキャスト作成プラットフォーム - テキストを高品質なポッドキャストオーディオに簡単に変換、複数の声とスタイルをサポートし、創造性を手軽に実現。",
"description": "PodcastHubは最先端のAI技術を駆使し、あなたの創造性に無限の可能性を提供します。テキストやアイデアをプロ品質のポッドキャストオーディオに簡単に変換し、多様なパーソナライズされた声とスタイルのオプションを提供します。今すぐ効率的な作成を体験し、あなたの声を世界に広め、より多くのリスナーを引きつけ、ポッドキャスト制作プロセスを簡素化します。",
"keywords": "ポッドキャスト,AI,音声合成,TTS,オーディオ生成"
}

View File

@@ -0,0 +1,84 @@
{
"privacy_policy": {
"title": "プライバシーポリシー - PodcastHub",
"description": "PodcastHubがあなたのプライバシーをどのように保護しているかをご覧ください。私たちはあなたのデータを透明に扱うことに尽力しています。",
"last_updated": "最終更新日2025年8月21日",
"intro_paragraph": "PodcastHubをご利用いただきありがとうございます。お客様のプライバシーは私たちにとって重要です。このプライバシーポリシー以下「本ポリシー」といいますは、お客様の個人情報をどのように収集、使用、保存、共有、保護するかを説明することを目的としています。サービスをご利用になる前に、本ポリシーを注意深くお読みになり、ご理解ください。",
"section1": {
"title": "1. 収集する情報",
"intro": "サービスを提供し、最適化するために、以下の種類の情報を収集します:",
"point1": {
"heading": "お客様が積極的に提供する情報",
"content": "アカウントの登録、サービスの利用、または当社への連絡時に、お客様の氏名、電子メールアドレス、パスワード、ポッドキャスト生成のためにアップロードしたテキストコンテンツなどの個人情報を提供する場合があります。"
},
"point2": {
"heading": "自動的に収集する情報",
"content": "サービスをご利用の際、当社のサーバーは、お客様のIPアドレス、ブラウザの種類とバージョン、オペレーティングシステム、デバイス情報、アクセス日時、サービス内でのインタラクションデータクリックストリーム、機能使用頻度などなど、特定の情報を自動的に記録します。"
},
"point3": {
"heading": "Cookieおよび類似技術",
"content": "当社はCookieを使用して、お客様の設定を保存し、ログイン状態を維持し、利用状況を分析してユーザーエクスペリエンスを向上させます。お客様は、ご自身の好みに応じてCookieを管理または削除することができます。"
}
},
"section2": {
"title": "2. 情報の利用方法",
"intro": "お客様の個人情報は、以下の目的で利用されます:",
"point1": {
"heading": "サービスの提供と維持",
"content": ":お客様のテキストコンテンツを処理してポッドキャストを生成し、お客様のアカウントを管理し、サービスが正常に動作することを確認します。"
},
"point2": {
"heading": "サービスの改善と開発",
"content": ":利用状況データを分析してユーザーの好みを理解し、既存の機能を最適化し、新しい製品やサービスを開発します。"
},
"point3": {
"heading": "お客様との連絡",
"content": ":重要なサービス通知、アカウントセキュリティアラート、更新指示、またはお客様が関心を持つ可能性のあるマーケティング情報を送信します(購読解除を選択できます)。"
},
"point4": {
"heading": "セキュリティ保証",
"content": ":詐欺、乱用、セキュリティ上の脅威から当社のサービス、ユーザー、および一般の人々を保護します。"
},
"point5": {
"heading": "法的義務の遵守",
"content": ":適用される法律および規制の要件を遵守します。"
}
},
"section3": {
"title": "3. 情報の共有と開示",
"intro": "当社は、お客様の情報を機密として保持することをお約束します。以下の状況を除き、お客様の個人情報を第三者と共有することはありません:",
"point1": {
"heading": "お客様の明示的な同意を得た場合",
"content": ":お客様の明示的な同意を得て、当社はお客様の情報を他の当事者と共有します。"
},
"point2": {
"heading": "法的要件",
"content": ":法律および規制、法的手続きの要件、または政府当局からの強制的な要求に従い、当社はお客様の個人情報を外部に開示する場合があります。"
},
"point3": {
"heading": "サービスプロバイダー",
"content": ":当社は、信頼できる第三者サービスプロバイダー(クラウドストレージ、データ分析サービスなど)と必要な情報を共有し、サービスの提供と改善を支援する場合があります。これらのプロバイダーは、当社のデータ保護基準を遵守する義務を負います。"
},
"point4": {
"heading": "当社の権利の保護",
"content": ":利用規約を執行し、当社の権利、財産、または安全を保護し、ユーザーまたは一般の人々を危害から保護するために、当社は必要な範囲で情報を共有する場合があります。"
}
},
"section4": {
"title": "4. データセキュリティ",
"content": "当社は、お客様の情報を保護し、不正なアクセス、開示、使用、変更、または破壊を防ぐために、業界標準のセキュリティ対策を採用しています。これらの対策には、データ暗号化、アクセス制御、定期的なセキュリティレビューが含まれます。ただし、インターネット送信や電子ストレージの方法は100%安全ではないため、絶対的な安全性を保証することはできません。"
},
"section5": {
"title": "5. お客様の権利",
"content": "お客様は、ご自身の個人情報に関して、アカウント情報のアクセス、修正、削除など、様々な権利を有しています。これらの権利は、アカウント設定または当社への連絡を通じて行使できます。"
},
"section6": {
"title": "6. ポリシー変更",
"content": "当社は、本プライバシーポリシーを随時改訂する権利を留保します。重要な変更があった場合、新しい規約が発効する少なくとも30日前に、ウェブサイトに目立つ通知を掲載するか、電子メールを送信してお客様に通知します。何が重要な変更を構成するかは、当社の単独の裁量で決定されます。"
},
"section7": {
"title": "7. お問い合わせ",
"content": "本プライバシーポリシーまたは当社のプライバシー慣行についてご質問やご不明な点がございましたら、お気軽にお問い合わせページからお問い合わせください。"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"terms_of_service": {
"title": "利用規約 - PodcastHub",
"description": "PodcastHubの利用規約へようこそ。本規約は、ユーザーとプラットフォームの共通の利益を保護するために策定されています。",
"heading": "PodcastHub 利用規約",
"last_updated": "最終更新日2025年8月21日",
"intro_paragraph": "PodcastHubへようこそ当社は、お客様のテキストコンテンツを高品質なポッドキャスト音声に変換する最先端のAI技術以下「本サービス」といいますを提供しています。本サービスをご利用になる前に、以下の利用規約以下「本規約」といいますを注意深くお読みください。本サービスにアクセスまたは利用することにより、お客様は本規約に拘束されることに同意したものとみなされます。",
"section1": {
"title": "1. サービス概要",
"content": "PodcastHubは、ユーザーがテキストをアップロードし、パラメーターを設定し、音声コンテンツを生成できるプラットフォームです。サービスには、テキスト読み上げTTS、音声処理、コンテンツストレージ、配信サポートが含まれますが、これらに限定されません。当社は安定した効率的な技術ソリューションの提供に尽力していますが、事前の通知なしにいつでもサービスを変更または中断する権利を留保します。"
},
"section2": {
"title": "2. ユーザーアカウントとセキュリティ",
"content": "すべてのサービスを利用するには、アカウントを登録する必要があります。お客様は、正確、完全、最新の登録情報を提供することを約束し、アカウントとパスワードの機密性を維持する責任を負います。お客様のアカウントを通じて行われるすべての活動は、お客様自身の責任です。お客様のアカウントの不正使用を発見した場合は、直ちに当社に通知してください。"
},
"section3": {
"title": "3. ユーザー行動ガイドライン",
"intro": "お客様は、本サービスを利用する際に適用されるすべての法令を遵守し、本サービスを以下の活動に従事するために利用しないことに同意するものとします。",
"point1": "違法、有害、脅迫的、虐待的、嫌がらせ、名誉毀損的、わいせつ的、他者のプライバシーを侵害する、憎悪を煽る、または人種的、民族的に不快なコンテンツをアップロード、生成、または配布すること。",
"point2": "著作権、商標権、特許権、企業秘密を含むがこれに限定されない、第三者の知的財産権を侵害すること。",
"point3": "個人または団体になりすます、またはその他の方法で、お客様と個人または団体との関係を虚偽表示すること。",
"point4": "当社のサービス、サーバー、またはサービスに接続されたネットワークを妨害または中断すること、またはサービスに接続されたネットワークの要件、手順、ポリシー、または規制を遵守しないこと。",
"point5": "当社のサービスインフラストラクチャに不合理または不均衡に大きな負荷をかける可能性のある活動に従事ること。"
},
"section4": {
"title": "4. 知的財産",
"point1": {
"heading": "お客様のコンテンツ",
"content": "お客様は、本サービスに提出するすべてのテキストコンテンツ「ユーザーコンテンツ」の完全な所有権および知的財産権を保持します。お客様は、PodcastHubに対し、本サービスの運営、提供、および改善のみを目的として、お客様のユーザーコンテンツを使用、複製、処理、適応、変更、公開、送信、および表示するための、世界規模の、非独占的、ロイヤリティフリーのライセンスを付与します。"
},
"point2": {
"heading": "生成されたコンテンツ",
"content": ":本サービスを通じて生成される音声コンテンツ(「生成コンテンツ」)の著作権は、お客様が入力するユーザーコンテンツの著作権の状態に依存します。技術サービスプロバイダーとして、当社は生成コンテンツの著作権に対するいかなる所有権も主張しません。"
},
"point3": {
"heading": "当社のサービス",
"content": "本サービスおよびこれに関連するすべての技術、商標、ロゴ、コンテンツお客様のコンテンツおよび生成コンテンツを除くは、PodcastHubの専有財産です。当社の事前の書面による同意なしにこれらを使用することはできません。"
}
},
"section5": {
"title": "5. 免責事項と責任の制限",
"point1": "本サービスは「現状有姿」および「利用可能な限り」提供され、当社は商品性、特定目的への適合性、非侵害に関する黙示の保証を含むがこれに限定されない、いかなる明示または黙示の保証も行いません。当社は、サービスが中断されず、タイムリーで、安全で、エラーフリーであることを保証しません。",
"point2": "法律で許容される最大限の範囲で、PodcastHubおよびその関連会社、取締役、従業員、またはライセンサーは、保証、契約、不法行為過失を含む、またはその他の法的理論に基づくものであっても、お客様の本サービスへのアクセスまたは使用に起因するいかなる間接的、偶発的、特別、結果的、または懲罰的な損害賠償についても一切責任を負いません。"
},
"section6": {
"title": "6. サービスの終了",
"content": "当社は、いつでも理由の如何を問わず(本規約違反を含む)、事前の通知なしに本サービスへのお客様のアクセスを一時停止または終了する権利を留保します。お客様は、アカウントを停止することにより、いつでも本契約を終了することができます。終了後、お客様の本サービスを利用する権利は直ちに停止します。"
},
"section7": {
"title": "7. 規約の変更",
"content": "当社は、いつでも単独の裁量で本規約を修正または置換する権利を留保します。変更が重大な場合、新しい規約が発効する少なくとも30日前に、ウェブサイトに目立つ通知を掲載するか、電子メールを送信してお客様に通知します。何が重大な変更を構成するかは、当社の単独の裁量で決定されます。"
},
"section8": {
"title": "8. 準拠法",
"content": "本規約の締結、効力、解釈、履行、および紛争解決には、関連法が適用されるものとします。"
},
"section9": {
"title": "9. お問い合わせ",
"content": "本規約についてご質問がございましたら、お問い合わせページからお問い合わせください。"
}
}
}

View File

@@ -0,0 +1,256 @@
{
"audioPlayer": {
"play": "播放",
"pause": "暂停",
"backward10s": "后退10秒",
"forward10s": "前进10秒",
"currentPlaybackRate": "当前倍速",
"mute": "静音",
"unmute": "取消静音",
"share": "分享",
"download": "下载",
"cannotGetAudioFileName": "无法获取音频文件名进行分享。",
"shareFailed": "分享失败:无法获取音频文件名。",
"playLinkCopied": "播放链接已复制到剪贴板!"
},
"audioPlayerControls": {
"pause": "暂停",
"play": "播放"
},
"billingToggle": {
"monthly": "连续包月",
"annually": "连续包年",
"save20Percent": "节省 20%"
},
"configSelector": {
"loading": "加载中...",
"selectTTSConfig": "选择TTS配置",
"noAvailableTTSConfig": "暂无可用的TTS配置",
"pleaseConfigTTS": "请先在设置中配置TTS服务"
},
"contentSection": {
"viewAll": "查看全部",
"noContent": "暂无内容",
"refresh": "刷新",
"recommendForYou": "为你推荐"
},
"footerLinks": {
"termsOfUse": "使用条款",
"privacyPolicy": "隐私政策",
"contactUs": "联系我们",
"copyright": "© 2025 Hex2077"
},
"languageSwitcher": {
"chinese": "中文",
"english": "英文"
},
"loginModal": {
"loginToYourAccount": "登录您的账户",
"signInWithGoogle": "使用 Google 登录",
"signInWithGitHub": "使用 GitHub 登录"
},
"podcastCard": {
"podcastGenerationQueued": "播客生成排队中...",
"podcastGenerating": "播客生成中...",
"moreOperations": "更多操作",
"mostPopular": "最受欢迎"
},
"podcastContent": {
"speaker": "说话人",
"cannotLoadPodcastDetails": "无法加载播客详情:",
"unknownError": "未知错误",
"returnToHomepage": "返回首页",
"downloadAudio": "下载音频",
"script": "脚本",
"outline": "大纲",
"noOutlineContent": "暂无大纲内容。"
},
"podcastCreator": {
"giveVoiceToCreativity": "给创意一个真实的声音",
"enterTextPlaceholder": "输入文字支持Markdown格式...",
"addCustomInstructions": "添加自定义指令(可选)... 例如:固定的开场白和结束语,文案脚本语境,输出内容的重点",
"ttsConfigSelection": "TTS配置选择",
"speaker": "说话人",
"languageSelection": "语言选择",
"durationSelection": "时长选择",
"fileUpload": "上传文件",
"pasteContent": "粘贴内容",
"copyContent": "复制内容",
"credits": "积分",
"checkIn": "签到",
"create": "创作",
"biu": "Biu!",
"checkInSuccess": "签到成功",
"checkInFailed": "签到失败",
"networkError": "网络错误或服务器无响应",
"topicCannotBeEmpty": "主题不能为空",
"pleaseEnterPodcastTopic": "请输入播客主题。",
"ttsConfigNotSelected": "TTS配置未选择",
"pleaseSelectTTSConfig": "请选择一个TTS配置。",
"pleaseSelectSpeaker": "请选择说话人",
"pleaseSelectAtLeastOneSpeaker": "请至少选择一位播客说话人。",
"podcastGenerationFailed": "播客生成失败:",
"maximum5Speakers": "最多只能选择5个说话人。",
"chinese": "中文",
"english": "英文",
"japanese": "日文",
"under5Minutes": "5分钟以内",
"between5And10Minutes": "5-10分钟",
"between10And15Minutes": "10-15分钟"
},
"podcastTabs": {
"script": "脚本",
"outline": "大纲",
"noOutlineContent": "暂无大纲内容。"
},
"pointsOverview": {
"totalPoints": "总积分",
"last20EntriesOnly": "仅显示最近20条积分明细。",
"pointDetails": "积分明细",
"noPointDetails": "暂无积分明细。"
},
"pricingCard": {
"perMonth": "/月",
"getStarted": "立即开始",
"upgradeToPro": "升级至专业版",
"upgradeToBusiness": "升级至商业版",
"mostPopular": "最受欢迎"
},
"pricingSection": {
"creator": "创作者",
"pro": "专业版",
"business": "商业版",
"chooseYourPlan": "选择适合你的计划",
"forIndividualsOrTeams": "无论你是个人创作者还是大型团队,我们都有满足你需求的方案。",
"visitPricingPage": "访问定价页",
"monthlyCreatorFeatures": {
"points": "2,000 积分每月",
"aiVoiceSynthesis": "AI语音合成",
"twoSpeakers": "最多2位说话人",
"commercialLicense": "商业许可",
"audioDownload": "音频下载"
},
"monthlyProFeatures": {
"points": "5,000 积分每月",
"aiVoiceSynthesis": "AI语音合成",
"multiSpeakers": "多说话人支持",
"commercialLicense": "商业许可",
"audioDownload": "音频下载",
"advancedVoices": "高级音色",
"storytellingMode": "说书模式"
},
"monthlyBusinessFeatures": {
"points": "12,000 积分每月",
"aiVoiceSynthesis": "AI 语音合成",
"multiSpeakers": "多说话人支持",
"commercialLicense": "商业许可",
"dedicatedAccountManager": "专属客户经理",
"audioDownload": "音频下载",
"advancedVoices": "高级语音",
"storytellingMode": "说书模式",
"apiAccess": "API访问"
},
"comingSoon": "(即将推出)"
},
"settingsForm": {
"settings": "设置",
"apiSettingsDescription": "配置播客生成器的API设置和TTS服务",
"generalSettings": "通用设置",
"inputYourOpenAIAPIKey": "输入您的OpenAI API Key",
"model": "模型",
"selectOrEnterModelName": "选择或输入模型名称",
"customModelInput": "输入自定义模型",
"optionalCustomBaseURL": "可选自定义API基础URL",
"ttsServiceSettings": "TTS服务设置",
"webAPITTSServices": "网络 API TTS 服务",
"edgeTTS": "Edge TTS",
"edgeTTSDescription": "基于微软Edge的TTS免费服务提供高质量语音合成。",
"doubaoTTS": "Doubao TTS",
"doubaoTTSDescription": "由火山引擎提供支持的语音合成服务baseUrl=https://openspeech.bytedance.com/api/v3/tts/unidirectional",
"inputDoubaoAppID": "输入Doubao App ID",
"inputDoubaoAccessKey": "输入Doubao Access Key",
"minimaxTTS": "Minimax TTS",
"minimaxTTSDescription": "由Minimax提供支持的语音合成服务baseUrl=https://api.minimaxi.com/v1/t2a_v2",
"inputMinimaxGroupID": "输入Minimax Group ID",
"inputMinimaxAPIKey": "输入Minimax API Key",
"fishTTS": "Fish TTS",
"fishTTSDescription": "由FishAudio提供支持的语音合成服务baseUrl=https://api.fish.audio/v1/tts",
"inputFishTTSAPIKey": "输入Fish TTS API Key",
"geminiTTS": "Gemini TTS",
"geminiTTSDescription": "由Google Gemini提供支持的语音合成服务baseUrl=https://generativelanguage.googleapis.com/v1beta/models",
"inputGeminiAPIKey": "输入Gemini API Key",
"localAPITTSServices": "本地 API TTS 服务",
"indexTTS": "Index TTS",
"indexTTSDescription": "用于本地部署的IndexTTS服务提供自定义语音合成能力。",
"reset": "重置",
"saving": "保存中...",
"saveSettings": "保存设置",
"settingsSavedSuccessfully": "设置保存成功!",
"errorSavingSettings": "保存设置时出现错误,请重试",
"configurationNotes": "配置说明",
"apiKeyRequired": "API Key 是必填项用于调用OpenAI服务生成播客脚本",
"ttsOptional": "TTS服务配置为可选项未配置的服务将不会在语音选择中显示",
"emptyFieldsNull": "空白字段将被保存为 null 值",
"settingsApplyImmediately": "配置保存后将立即生效,无需重启应用",
"apiKey": "API Key",
"baseURL": "Base URL",
"appID": "App ID",
"accessKey": "Access Key",
"groupID": "Group ID"
},
"shareButton": {
"copySuccess": "复制成功",
"pageLinkCopied": "页面链接已复制到剪贴板!",
"copyFailed": "复制失败",
"cannotCopyPageLink": "无法复制页面链接到剪贴板。"
},
"sidebar": {
"expandSidebar": "展开侧边栏",
"collapseSidebar": "收起侧边栏",
"home": "首页",
"library": "资料库",
"explore": "探索",
"pricing": "定价",
"points": "积分",
"ttsSettings": "TTS设置",
"github": "Github",
"twitter": "Twitter",
"tiktok": "抖音",
"email": "邮件",
"login": "登录",
"logout": "注销",
"areYouSureToLogout": "确定要注销吗?",
"cancel": "取消",
"confirmLogout": "注销",
"sessionExpired": "会话已过期,正在注销...",
"user": "用户",
"clickAvatarToLogout": "点击头像注销",
"lessThanSMSizeCannotExpand": "小于sm尺寸不可展开",
"showMore": "显示更多"
},
"toast": {
"title": "通知",
"message": "这是一条通知消息。"
},
"voicesModal": {
"selectSpeaker": "选择说话人",
"all": "全部",
"male": "男",
"female": "女",
"chinese": "中文 (zh)",
"english": "英文 (en)",
"japanese": "日文 (ja)",
"close": "关闭",
"searchVoices": "搜索声音...",
"noMatchingVoices": "未找到匹配的声音。",
"language": "语言",
"unknown": "未知",
"host": "主持人",
"confirmSelection": "确认选择",
"max5Speakers": "最多只能选择5个说话人。",
"searchVoicesPlaceholder": "搜索声音...",
"maxVoicesAlert": "最多只能选择5个说话人。",
"delete": "删除",
"presenter": "主讲人"
}
}

View File

@@ -0,0 +1,10 @@
{
"contact_us_title": "联系我们 - PodcastHub",
"contact_us_description": "有任何问题或建议?请随时联系 PodcastHub 团队。我们期待您的声音。",
"contact_us_heading": "联系我们",
"contact_us_subheading": "我们很乐意听取您的意见。无论是问题、建议还是合作机会,请随时通过以下方式与我们联系。",
"email_title": "电子邮箱",
"email_description": "对于一般查询和支持,请发送邮件至:",
"social_media_title": "社交媒体",
"social_media_description": "在社交网络上关注我们,获取最新动态:"
}

View File

@@ -0,0 +1,49 @@
{
"invalid_filename": "无效的文件名",
"unsupported_file_type": "不支持的文件类型",
"file_not_found": "文件不存在",
"internal_server_error": "服务器内部错误"
,
"unauthorized": "未授权",
"invalid_status": "无效的状态",
"invalid_request_parameters": "无效的请求参数",
"request_too_old_or_future": "请求过旧或在未来",
"points_deducted_successfully": "积分扣除成功",
"insufficient_points": "积分不足",
"daily_sign_in": "每日签到",
"already_signed_in_today": "今日已签到",
"points_added_successfully": "积分添加成功"
,
"config_files_list_success": "配置文件列表获取成功",
"config_files_list_error": "获取配置文件列表失败",
"invalid_config_file_name": "无效的配置文件名",
"config_file_read_success": "配置文件读取成功",
"read_config_file_error": "读取配置文件失败"
,
"missing_file_name_parameter": "缺少 file_name 查询参数",
"internal_server_error_backend_connection": "内部服务器错误或无法连接到后端服务"
,
"user_not_logged_in_or_session_expired": "用户未登录或会话已过期",
"request_body_cannot_be_empty": "请求正文不能为空",
"tts_provider_cannot_be_empty": "TTS服务提供商不能为空",
"please_select_at_least_one_speaker": "请至少选择一位播客说话人",
"invalid_speaker_config_format": "播客说话人配置格式无效",
"insufficient_points_for_podcast": "积分不足,生成一个播客需要 {{pointsNeeded}} 积分,您当前只有 {{currentPoints}} 积分。",
"invalid_output_language": "无效的输出语言",
"invalid_podcast_duration": "无效的播客时长",
"missing_frontend_tts_config": "缺少前端传入的TTS配置信息",
"incomplete_backend_tts_config": "后端TTS配置不完整请检查后端配置文件。",
"internal_server_error_default": "服务器内部错误"
,
"failed_to_get_task_status": "获取任务状态失败"
,
"insufficient_points_raw": "积分不足"
,
"forbidden_user_id": "禁止该用户ID无权访问此资源",
"invalid_request_parameters_add_points": "无效的请求参数。`userId`、`pointsToAdd`(正数)、`reasonCode`和`description`是必需的。",
"points_added_successfully_to_user": "积分已成功添加到用户 {{userId}}"
,
"invalid_pagination_parameters": "无效的分页参数"
,
"cannot_read_tts_provider_config": "无法读取TTS提供商配置文件"
}

View File

@@ -0,0 +1,26 @@
{
"podcastGenerationFailed": "播客生成失败,请重试",
"podcastTagsPlaceholder": "待生成的播客标签",
"configErrorTitle": "配置错误",
"configErrorMessage": "API Key 或模型未设置,请前往设置页填写。",
"error": {
"401": "生成播客失败请检查API Key是否正确或登录状态。",
"402": "生成播客失败,请检查积分是否足够。",
"403": "生成播客失败,请登录后重试。",
"409": "生成播客失败,有正在进行中的任务",
"backend": "生成播客失败,请检查后端服务或配置",
"generationFailed": "生成失败",
"noTaskId": "生成任务失败未返回任务ID",
"unknown": "未知错误",
"dataProcessing": "数据处理失败",
"cantProcessPodcastList": "无法处理播客列表数据"
},
"taskCreatedTitle": "任务已创建",
"taskCreatedMessage": "播客生成任务已启动任务ID",
"recentlyGenerated": "最近生成",
"dataRetentionWarning": "数据只保留30分钟请尽快下载保存",
"saveSuccessTitle": "保存成功",
"saveErrorTitle": "保存失败",
"pageInDevelopment": "页面开发中",
"featureComingSoon": "该功能正在开发中,敬请期待。"
}

View File

@@ -0,0 +1,5 @@
{
"title": "PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及",
"description": "PodcastHub 利用尖端AI技术为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频支持多种个性化语音和风格选择。立即体验高效创作让您的声音在全球范围内传播吸引更多听众并简化您的播客制作流程。",
"keywords": "播客,AI,语音合成,TTS,音频生成"
}

View File

@@ -0,0 +1,84 @@
{
"privacy_policy": {
"title": "隐私政策 - PodcastHub",
"description": "了解 PodcastHub 如何保护您的隐私。我们致力于透明化地处理您的数据。",
"last_updated": "最近更新日期2025年8月21日",
"intro_paragraph": "感谢您选择 PodcastHub我们深知隐私对您的重要性。本隐私政策以下简称“本政策”旨在向您说明我们如何收集、使用、存储、共享和保护您的个人信息。请在使用我们的服务前仔细阅读并理解本政策。",
"section1": {
"title": "1. 我们收集的信息",
"intro": "为了向您提供和优化我们的服务,我们会收集以下类型的信息:",
"point1": {
"heading": "您主动提供的信息",
"content": ":当您注册账户、使用服务或与我们联系时,您可能会提供个人信息,如您的姓名、电子邮件地址、密码、以及您为生成播客而上传的文本内容。"
},
"point2": {
"heading": "我们自动收集的信息",
"content": ":当您使用服务时,我们的服务器会自动记录某些信息,包括您的 IP 地址、浏览器类型与版本、操作系统、设备信息、访问日期和时间、以及您在服务中的交互数据(如点击流、功能使用频率等)。"
},
"point3": {
"heading": "Cookies 和类似技术",
"content": ":我们使用 Cookies 来存储您的偏好设置、维持登录状态并分析使用情况,以改善用户体验。您可以根据自己的偏好管理或删除 Cookies。"
}
},
"section2": {
"title": "2. 我们如何使用您的信息",
"intro": "我们将在以下目的范围内使用您的个人信息:",
"point1": {
"heading": "提供和维护服务",
"content": ":处理您的文本内容以生成播客,管理您的账户,并确保服务正常运行。"
},
"point2": {
"heading": "改进和开发服务",
"content": ":分析使用数据,以了解用户偏好,优化现有功能,并开发新的产品和服务。"
},
"point3": {
"heading": "与您沟通",
"content": ":向您发送重要的服务通知、账户安全提醒、更新说明或您可能感兴趣的市场营销信息(您可以选择退订)。"
},
"point4": {
"heading": "安全保障",
"content": ":保护我们的服务、用户和公众免受欺诈、滥用和安全威胁。"
},
"point5": {
"heading": "遵守法律义务",
"content": ":履行适用的法律法规要求。"
}
},
"section3": {
"title": "3. 信息的共享与披露",
"intro": "我们承诺对您的信息保密,除非存在以下情况,我们不会与任何第三方分享您的个人信息:",
"point1": {
"heading": "获得您的明确同意",
"content": ":在获得您明确同意后,我们会与其他方共享您的信息。"
},
"point2": {
"heading": "法律要求",
"content": ":根据法律法规、法律程序的要求或政府主管部门的强制性要求,我们可能会对外披露您的个人信息。"
},
"point3": {
"heading": "服务提供商",
"content": ":我们可能会与可信赖的第三方服务提供商(如云存储、数据分析服务)共享必要的信息,以协助我们提供和改进服务。这些提供商有义务遵守我们的数据保护标准。"
},
"point4": {
"heading": "保护我们的权利",
"content": ":为执行我们的服务条款、保护我们的权利、财产或安全,以及保护我们的用户或公众免受伤害,我们可能会在必要的范围内共享信息。"
}
},
"section4": {
"title": "4. 数据安全",
"content": "我们采用了行业标准的安全措施来保护您的信息,防止未经授权的访问、披露、使用、修改或销毁。这些措施包括数据加密、访问控制和定期的安全审查。然而,没有任何互联网传输或电子存储方法是 100% 安全的,因此我们无法保证其绝对安全。"
},
"section5": {
"title": "5. 您的权利",
"content": "您对您的个人信息享有多种权利,包括访问、更正、删除您的账户信息。您可以通过账户设置或联系我们来行使这些权利。"
},
"section6": {
"title": "6. 政策变更",
"content": "我们可能会不时修订本隐私政策。任何重大变更,我们都会通过在网站上发布醒目通知或向您发送电子邮件的方式通知您。我们鼓励您定期查看本页面以获取最新信息。"
},
"section7": {
"title": "7. 联系我们",
"content": "如果您对本隐私政策或我们的隐私实践有任何疑问或疑虑,请随时通过我们的联系页面与我们取得联系。"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"terms_of_service": {
"title": "使用条款 - PodcastHub",
"description": "欢迎了解 PodcastHub 的使用条款。本条款旨在保护用户与平台的共同利益。",
"heading": "PodcastHub 使用条款",
"last_updated": "最近更新日期2025年8月21日",
"intro_paragraph": "欢迎使用 PodcastHub我们提供领先的 AI 技术,将您的文本内容转换为高质量的播客音频(以下简称“服务”)。在使用我们的服务之前,请仔细阅读以下使用条款(以下简称“本条款”)。通过访问或使用我们的服务,即表示您同意受本条款的约束。",
"section1": {
"title": "1. 服务概述",
"content": "PodcastHub 是一个允许用户上传文本、配置参数并生成音频内容的平台。服务包括但不限于文本转语音TTS、音频处理、内容存储和分发支持。我们致力于提供稳定、高效的技术解决方案但保留随时修改或中止服务的权利恕不另行通知。"
},
"section2": {
"title": "2. 用户账户与安全",
"content": "您需要注册一个账户才能使用我们的全部服务。您承诺提供准确、完整、最新的注册信息,并负责维护您的账户和密码的机密性。任何通过您账户进行的所有活动,均由您本人负责。若发现任何未经授权使用您账户的情况,请立即通知我们。"
},
"section3": {
"title": "3. 用户行为准则",
"intro": "您同意在使用本服务时遵守所有适用的法律法规,并且不得利用本服务从事以下活动:",
"point1": "上传、生成或传播任何非法、有害、威胁、辱骂、骚扰、诽谤、淫秽、侵犯他人隐私、煽动仇恨或在种族、民族等方面令人反感的内容。",
"point2": "侵犯任何第三方的知识产权,包括但不限于版权、商标权、专利权或商业秘密。",
"point3": "冒充任何个人或实体,或以其他方式虚假陈述您与任何个人或实体的关系。",
"point4": "干扰或破坏我们的服务、服务器或与服务连接的网络,或不遵守与服务连接的网络的任何要求、程序、政策或规定。",
"point5": "从事任何可能对我们的服务基础设施造成不合理或不成比例的巨大负载的活动。"
},
"section4": {
"title": "4. 知识产权",
"point1": {
"heading": "您的内容",
"content": ":您保留您提交给服务的所有文本内容(“用户内容”)的全部所有权和知识产权。您授予 PodcastHub 一个全球性的、非独占的、免版税的许可,以使用、复制、处理、改编、修改、发布、传输和显示您的用户内容,仅用于运营、提供和改进本服务的目的。"
},
"point2": {
"heading": "生成内容",
"content": ":通过本服务生成的音频内容(“生成内容”)的版权归属,取决于您输入的用户内容的版权状态。我们作为技术服务提供商,不对生成内容的版权主张任何所有权。"
},
"point3": {
"heading": "我们的服务",
"content": ":本服务及其所有相关技术、商标、徽标和内容(不包括您的内容和生成内容)均为 PodcastHub 的专有财产。未经我们事先书面同意,您不得使用。"
}
},
"section5": {
"title": "5. 免责声明与责任限制",
"point1": "本服务按“现状”和“可用”提供,我们不作任何明示或暗示的担保,包括但不限于对适销性、特定用途适用性和不侵权的暗示担保。我们不保证服务将是不间断的、及时的、安全的或无错误的。",
"point2": "在法律允许的最大范围内PodcastHub 及其关联公司、董事、员工或许可方在任何情况下均不对因您访问或使用本服务而导致的任何间接、附带、特殊、后果性或惩罚性损害赔偿负责,无论该等损害赔偿是基于保证、合同、侵权(包括过失)还是任何其他法律理论。"
},
"section6": {
"title": "6. 终止服务",
"content": "我们保留随时以任何理由(包括您违反本条款)暂停或终止您访问本服务的权利,恕不另行通知。您也可以随时通过停用您的账户来终止本协议。终止后,您使用本服务的权利将立即停止。"
},
"section7": {
"title": "7. 条款修改",
"content": "我们保留随时自行决定修改或替换本条款的权利。如果修改是重大的,我们将在新条款生效前至少提前 30 天通过电子邮件或在我们的网站上发布通知。构成重大变更的内容将由我们自行决定。"
},
"section8": {
"title": "8. 适用法律",
"content": "本条款的订立、效力、解释、履行和争议解决均适用相关法律。"
},
"section9": {
"title": "9. 联系我们",
"content": "如果您对本条款有任何疑问,请通过我们的联系页面与我们联系。"
}
}
}

View File

@@ -1,24 +1,28 @@
import React from 'react';
import React, { use } from 'react';
import { Metadata } from 'next';
import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiFillMail } from 'react-icons/ai';
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
const { lang } = await params;
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'contact');
return {
title: t('contact_us_title'),
description: t('contact_us_description'),
alternates: {
canonical: `/${lang}/contact`,
},
};
}
import { useTranslation } from '../../../i18n'; // 导入服务端的 useTranslation
/**
*
*/
export const metadata: Metadata = {
title: '联系我们 - PodcastHub',
description: '有任何问题或建议?请随时联系 PodcastHub 团队。我们期待您的声音。',
alternates: {
canonical: '/contact',
},
};
*
*
*
*/
const ContactUsPage = async ({ params: { lang } }: { params: { lang: string } }) => {
const { t } = await useTranslation(lang, 'contact');
/**
*
*
*
*/
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">
@@ -26,10 +30,10 @@ const ContactUsPage: React.FC = () => {
<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">
{t('contact_us_heading')}
</h1>
<p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
{t('contact_us_subheading')}
</p>
</header>
@@ -40,10 +44,10 @@ const ContactUsPage: React.FC = () => {
<AiFillMail className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
{t('email_title')}
</h2>
<p className="text-gray-600">
{t('email_description')}
<a
href="mailto:support@podcasthub.com"
className="block text-blue-600 hover:text-blue-700 transition-colors break-all mt-1 font-medium"
@@ -59,10 +63,10 @@ const ContactUsPage: React.FC = () => {
<AiFillQqCircle className="w-8 h-8 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">
{t('social_media_title')}
</h2>
<p className="text-gray-600 mb-4">
{t('social_media_description')}
</p>
<div className="flex justify-center space-x-6">
<a

View File

@@ -0,0 +1,88 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import FooterLinks from '../../components/FooterLinks';
import { dir } from 'i18next';
import { languages } from '../../i18n/settings';
import { useTranslation } from '../../i18n';
// export async function generateStaticParams() {
// const params = await languages.map((lng) => ({ lng }));
// return params;
// }
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
const { lang } = await params;
const { t } = await useTranslation(lang, 'layout');
return {
metadataBase: new URL('https://www.podcasthub.com'),
title: t('title'),
description: t('description'),
keywords: t('keywords').split(','),
authors: [{ name: 'PodcastHub Team' }],
icons: {
icon: '/favicon.webp',
apple: '/favicon.webp',
},
openGraph: {
title: t('title'),
description: t('description'),
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: t('title'),
},
alternates: {
canonical: `/${lang}`,
languages: languages.reduce((acc: Record<string, string>, l) => {
acc[l] = `/${l}`;
return acc;
}, {}),
},
};
}
export const viewport = {
themeColor: '#000000',
width: 'device-width',
initialScale: 1,
};
export default async function RootLayout({
children,
params
}: {
children: React.ReactNode;
params: {
lang: string;
}
}) {
const { lang } = await params;
return (
<html lang={lang} dir={dir(lang)} className={inter.variable}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className={`${inter.className} antialiased`}>
<div id="root" className="min-h-screen bg-white">
{children}
</div>
{/* Toast容器 */}
<div id="toast-root" />
{/* Modal容器 */}
<div id="modal-root" />
<footer className="py-8">
<FooterLinks lang={lang} />
</footer>
</body>
</html>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, use } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import PodcastCreator from '@/components/PodcastCreator';
@@ -16,6 +16,7 @@ import type { PodcastGenerationRequest, PodcastItem, UIState, PodcastGenerationR
import { getTTSProviders } from '@/lib/config';
import { getSessionData } from '@/lib/server-actions';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
import { useTranslation } from '../../i18n/client';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
@@ -48,7 +49,9 @@ const normalizeSettings = (savedSettings: any): SettingsFormData => {
};
};
export default function HomePage() {
export default function HomePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = use(params);
const { t } = useTranslation(lang, 'home');
const { toasts, success, error, warning, info, removeToast } = useToast();
const { executeOnce } = usePreventDuplicateCall();
const router = useRouter(); // Initialize useRouter
@@ -57,8 +60,8 @@ export default function HomePage() {
const mapApiResponseToPodcasts = (tasks: PodcastGenerationResponse[]): PodcastItem[] => {
return tasks.map((task: any) => ({
id: task.task_id,
title: task.title ? task.title : task.status === 'failed' ? '播客生成失败,请重试' : ' ',
description: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag).join(', ') : task.status === 'failed' ? task.error || '待生成的播客标签' : '待生成的播客标签',
title: task.title ? task.title : task.status === 'failed' ? t('podcastGenerationFailed') : ' ',
description: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag).join(', ') : task.status === 'failed' ? task.error || t('podcastTagsPlaceholder') : t('podcastTagsPlaceholder'),
thumbnail: task.avatar_base64 ? `data:image/png;base64,${task.avatar_base64}` : '',
author: {
name: '',
@@ -68,7 +71,7 @@ export default function HomePage() {
playCount: 0,
createdAt: task.timestamp ? new Date(task.timestamp * 1000).toISOString() : new Date().toISOString(),
audioUrl: task.audioUrl ? task.audioUrl : '',
tags: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag) : task.status === 'failed' ? [task.error] : ['待生成的播客标签'],
tags: task.tags ? task.tags.split('#').map((tag: string) => tag.trim()).filter((tag: string) => !!tag) : task.status === 'failed' ? [task.error] : [t('podcastTagsPlaceholder')],
status: task.status,
file_name: task.output_audio_filepath || '',
}));
@@ -123,7 +126,7 @@ export default function HomePage() {
// 加载设置
useEffect(() => {
const loadSettings = async () => {
const savedSettings = await getTTSProviders();
const savedSettings = await getTTSProviders(lang);
if (savedSettings) {
const normalizedSettings = normalizeSettings(savedSettings);
setSettings(normalizedSettings);
@@ -166,7 +169,7 @@ export default function HomePage() {
// info('开始生成播客', '正在处理您的请求...');
if (!settings || !settings.apikey || !settings.model) {
error('配置错误', 'API Key 或模型未设置,请前往设置页填写。');
error(t('configErrorTitle'), t('configErrorMessage'));
setIsGenerating(false);
return;
}
@@ -176,44 +179,45 @@ export default function HomePage() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-next-locale': lang,
},
body: JSON.stringify(request),
});
if (!response.ok) {
if(response.status === 401) {
throw new Error('生成播客失败请检查API Key是否正确或登录状态。');
throw new Error(t('error.401'));
}
if(response.status === 402) {
throw new Error('生成播客失败,请检查积分是否足够。');
throw new Error(t('error.402'));
}
if(response.status === 403) {
setIsLoginModalOpen(true); // 显示登录模态框
throw new Error('生成播客失败,请登录后重试。');
throw new Error(t('error.403'));
}
if(response.status === 409) {
throw new Error(`生成播客失败,有正在进行中的任务 (状态码: ${response.status})`);
throw new Error(`${t('error.409')} (状态码: ${response.status})`);
}
throw new Error(`生成播客失败,请检查后端服务或配置 (状态码: ${response.status})`);
throw new Error(`${t('error.backend')} (状态码: ${response.status})`);
}
const apiResponse: { success: boolean; data?: PodcastGenerationResponse; error?: string } = await response.json();
if (!apiResponse.success) {
throw new Error(apiResponse.error || '生成播客失败');
throw new Error(apiResponse.error || t('error.generationFailed'));
}
if (apiResponse.data && apiResponse.data.id) {
success('任务已创建', `播客生成任务已启动任务ID: ${apiResponse.data.id}`);
success(t('taskCreatedTitle'), `${t('taskCreatedMessage')}: ${apiResponse.data.id}`);
await fetchRecentPodcasts(); // 刷新最近生成列表
} else {
throw new Error('生成任务失败未返回任务ID');
throw new Error(t('error.noTaskId'));
}
} catch (err) {
console.error('Error generating podcast:', err);
error('生成失败', err instanceof Error ? err.message : '未知错误');
throw new Error(err instanceof Error ? err.message : '未知错误');
error(t('error.generationFailed'), err instanceof Error ? err.message : t('error.unknown'));
throw new Error(err instanceof Error ? err.message : t('error.unknown'));
} finally {
setIsGenerating(false);
}
@@ -246,6 +250,7 @@ export default function HomePage() {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-next-locale': lang,
},
});
if (!response.ok) {
@@ -267,7 +272,7 @@ export default function HomePage() {
}
} catch (err) {
console.error('Error processing podcast data:', err);
error('数据处理失败', err instanceof Error ? err.message : '无法处理播客列表数据');
error(t('error.dataProcessing'), err instanceof Error ? err.message : t('error.cantProcessPodcastList'));
}
fetchCreditsAndUserInfo();
@@ -276,7 +281,12 @@ export default function HomePage() {
// 新增辅助函数:获取积分和用户信息
const fetchCreditsAndUserInfo = async () => {
try {
const pointsResponse = await fetch('/api/points');
const pointsResponse = await fetch('/api/points', {
method: 'GET',
headers: {
'x-next-locale': lang,
},
});
if (pointsResponse.ok) {
const data = await pointsResponse.json();
if (data.success) {
@@ -295,7 +305,12 @@ export default function HomePage() {
}
try {
const transactionsResponse = await fetch('/api/points/transactions');
const transactionsResponse = await fetch('/api/points/transactions', {
method: 'GET',
headers: {
'x-next-locale': lang,
},
});
if (transactionsResponse.ok) {
const data = await transactionsResponse.json();
if (data.success) {
@@ -331,13 +346,14 @@ export default function HomePage() {
settings={settings} // 传递 settings
onSignInSuccess={fetchCreditsAndUserInfo} // 传递 onSignInSuccess
enableTTSConfigPage={enableTTSConfigPage} // 传递 enableTTSConfigPage
lang={lang}
/>
{/* 最近生成 - 紧凑布局 */}
{explorePodcasts.length > 0 && (
<ContentSection
title="最近生成"
subtitle="数据只保留30分钟请尽快下载保存"
title={t('recentlyGenerated')}
subtitle={t('dataRetentionWarning')}
items={explorePodcasts}
onPlayPodcast={handlePlayPodcast}
onTitleClick={handleTitleClick} // 传递 handleTitleClick
@@ -347,6 +363,7 @@ export default function HomePage() {
layout="grid"
showRefreshButton={true}
onRefresh={fetchRecentPodcasts}
lang={lang}
/>
)}
@@ -368,8 +385,9 @@ export default function HomePage() {
case 'settings':
return (
<SettingsForm
onSuccess={(message) => success('保存成功', message)}
onError={(message) => error('保存失败', message)}
onSuccess={(message) => success(t('saveSuccessTitle'), message)}
onError={(message) => error(t('saveErrorTitle'), message)}
lang={lang}
/>
);
@@ -379,14 +397,15 @@ export default function HomePage() {
totalPoints={credits}
user={user}
pointHistory={pointHistory}
lang={lang}
/>
);
default:
return (
<div className="max-w-4xl mx-auto px-6 text-center py-12">
<h1 className="text-2xl font-bold text-black mb-4"></h1>
<p className="text-neutral-600"></p>
<h1 className="text-2xl font-bold text-black mb-4">{t('pageInDevelopment')}</h1>
<p className="text-neutral-600">{t('featureComingSoon')}</p>
</div>
);
}
@@ -404,6 +423,7 @@ export default function HomePage() {
credits={credits} // 将积分传递给Sidebar
onPodcastExplore={setExplorePodcasts} // 传递刷新播客函数
onCreditsChange={handleCreditsChange} // 传递积分更新函数
lang={lang}
/>
{/* 移动端菜单按钮 */}
@@ -447,6 +467,7 @@ export default function HomePage() {
isPlaying={isPlaying}
onPlayPause={handleTogglePlayPause}
onEnded={() => setIsPlaying(false)}
lang={lang}
/>
)}
@@ -454,6 +475,7 @@ export default function HomePage() {
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
lang={lang}
/>
{/* Toast通知容器 */}

View File

@@ -0,0 +1,34 @@
import { Metadata } from 'next';
import PodcastContent from '@/components/PodcastContent';
import { useTranslation } from '../../../../i18n'; // 导入 useTranslation
export async function generateMetadata({ params: { fileName, lang } }: PodcastDetailPageProps): Promise<Metadata> {
const { t } = await useTranslation(lang);
const decodedFileName = decodeURIComponent(fileName);
const title = `${t('podcastContent.podcastDetails')} - ${decodedFileName}`;
const description = `${t('podcastContent.listenToPodcast')} ${decodedFileName}`;
return {
title,
description,
alternates: {
canonical: `/${lang}/podcast/${decodedFileName}`,
},
};
}
interface PodcastDetailPageProps {
params: {
fileName: string;
lang: string; // 添加 lang 属性
};
}
export default async function PodcastDetailPage({ params }: PodcastDetailPageProps) {
const { fileName, lang } = params; // 解构 lang
return (
<div className="bg-white text-gray-800 font-sans">
<PodcastContent fileName={decodeURIComponent(fileName)} lang={lang} />
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Metadata } from 'next';
import React, { use } from 'react';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
import { useTranslation } from '../../../i18n';
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
const { lang } = await params;
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'components');
return {
title: t('pricing_page_title'),
description: t('pricing_page_description'),
alternates: {
canonical: `/${lang}/pricing`,
},
};
}
const PricingPage = async ({ params: { lang } }: { params: { lang: string } }) => {
// 尽管 PricingSection 是客户端组件,为了使 PricingPage 成为服务器组件并加载服务端 i18n我们在这里模拟加载
await useTranslation(lang, 'components');
return (
<PricingSection lang={lang} />
);
};
export default PricingPage;

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Metadata } from 'next';
import { useTranslation } from '@/i18n';
/**
* 设置页面元数据。
*/
export async function generateMetadata({ params: { lang } }: { params: { lang: string } }): Promise<Metadata> {
const { t } = await useTranslation(lang, 'privacy');
return {
title: t('privacy_policy.title'),
description: t('privacy_policy.description'),
alternates: {
canonical: `/${lang}/privacy`,
},
};
}
/**
* 隐私政策页面组件。
* 提供了详细的隐私政策说明,涵盖信息收集、使用、共享、安全及用户权利。
* 布局采用 Tailwind CSS 进行优化,`prose` 类用于美化排版,`break-words` 确保内容不会溢出容器。
*/
type PrivacyPolicyPageProps = {
params: {
lang: string;
};
};
const PrivacyPolicyPage: React.FC<PrivacyPolicyPageProps> = async ({ params: { lang } }) => {
const { t } = await useTranslation(lang, 'privacy');
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">
{t('privacy_policy.title_long')}
</h1>
<p className="text-gray-600">{t('privacy_policy.last_updated')}</p>
<p>{t('privacy_policy.intro_paragraph')}</p>
<h2 id="info-we-collect">{t('privacy_policy.section1.title')}</h2>
<p>{t('privacy_policy.section1.intro')}</p>
<ul>
<li>
<strong>{t('privacy_policy.section1.point1.heading')}</strong>
{t('privacy_policy.section1.point1.content')}
</li>
<li>
<strong>{t('privacy_policy.section1.point2.heading')}</strong>
{t('privacy_policy.section1.point2.content')}
</li>
<li>
<strong>{t('privacy_policy.section1.point3.heading')}</strong>
{t('privacy_policy.section1.point3.content')}
</li>
</ul>
<h2 id="how-we-use-info">{t('privacy_policy.section2.title')}</h2>
<p>{t('privacy_policy.section2.intro')}</p>
<ul>
<li>
<strong>{t('privacy_policy.section2.point1.heading')}</strong>
{t('privacy_policy.section2.point1.content')}
</li>
<li>
<strong>{t('privacy_policy.section2.point2.heading')}</strong>
{t('privacy_policy.section2.point2.content')}
</li>
<li>
<strong>{t('privacy_policy.section2.point3.heading')}</strong>
{t('privacy_policy.section2.point3.content')}
</li>
<li>
<strong>{t('privacy_policy.section2.point4.heading')}</strong>
{t('privacy_policy.section2.point4.content')}
</li>
<li>
<strong>{t('privacy_policy.section2.point5.heading')}</strong>
{t('privacy_policy.section2.point5.content')}
</li>
</ul>
<h2 id="info-sharing">{t('privacy_policy.section3.title')}</h2>
<p>{t('privacy_policy.section3.intro')}</p>
<ul>
<li>
<strong>{t('privacy_policy.section3.point1.heading')}</strong>
{t('privacy_policy.section3.point1.content')}
</li>
<li>
<strong>{t('privacy_policy.section3.point2.heading')}</strong>
{t('privacy_policy.section3.point2.content')}
</li>
<li>
<strong>{t('privacy_policy.section3.point3.heading')}</strong>
{t('privacy_policy.section3.point3.content')}
</li>
<li>
<strong>{t('privacy_policy.section3.point4.heading')}</strong>
{t('privacy_policy.section3.point4.content')}
</li>
</ul>
<h2 id="data-security">{t('privacy_policy.section4.title')}</h2>
<p>{t('privacy_policy.section4.content')}</p>
<h2 id="user-rights">{t('privacy_policy.section5.title')}</h2>
<p>{t('privacy_policy.section5.content')}</p>
<h2 id="policy-changes">{t('privacy_policy.section6.title')}</h2>
<p>{t('privacy_policy.section6.content')}</p>
<h2 id="contact-us">{t('privacy_policy.section7.title')}</h2>
<p>{t('privacy_policy.section7.content')}</p>
</article>
</div>
</div>
);
};
export default PrivacyPolicyPage;

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { Metadata } from 'next';
import { useTranslation } from '@/i18n';
import { languages } from '@/i18n/settings';
export async function generateMetadata({ params: { lang } }: { params: { lang: string } }): Promise<Metadata> {
const { t } = await useTranslation(lang, 'terms');
return {
title: t('terms_of_service.title'),
description: t('terms_of_service.description'),
alternates: {
canonical: `/${lang}/terms`,
},
};
}
const TermsOfServicePage: React.FC<{ params: { lang: string } }> = async ({ params: { lang } }) => {
const { t } = await useTranslation(lang, 'terms');
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">
{t('terms_of_service.heading')}
</h1>
<p className="text-gray-600">{t('terms_of_service.last_updated')}</p>
<p>
{t('terms_of_service.intro_paragraph')}
</p>
<h2 id="service-overview">{t('terms_of_service.section1.title')}</h2>
<p>
{t('terms_of_service.section1.content')}
</p>
<h2 id="user-account">{t('terms_of_service.section2.title')}</h2>
<p>
{t('terms_of_service.section2.content')}
</p>
<h2 id="user-conduct">{t('terms_of_service.section3.title')}</h2>
<p>{t('terms_of_service.section3.intro')}</p>
<ul>
<li>
{t('terms_of_service.section3.point1')}
</li>
<li>
{t('terms_of_service.section3.point2')}
</li>
<li>
{t('terms_of_service.section3.point3')}
</li>
<li>
{t('terms_of_service.section3.point4')}
</li>
<li>
{t('terms_of_service.section3.point5')}
</li>
</ul>
<h2 id="intellectual-property">{t('terms_of_service.section4.title')}</h2>
<p>
<strong>{t('terms_of_service.section4.point1.heading')}</strong>
{t('terms_of_service.section4.point1.content')}
</p>
<p>
<strong>{t('terms_of_service.section4.point2.heading')}</strong>
{t('terms_of_service.section4.point2.content')}
</p>
<p>
<strong>{t('terms_of_service.section4.point3.heading')}</strong>
{t('terms_of_service.section4.point3.content')}
</p>
<h2 id="limitation-of-liability">{t('terms_of_service.section5.title')}</h2>
<p>
{t('terms_of_service.section5.point1')}
</p>
<p>
{t('terms_of_service.section5.point2')}
</p>
<h2 id="termination">{t('terms_of_service.section6.title')}</h2>
<p>
{t('terms_of_service.section6.content')}
</p>
<h2 id="modification">{t('terms_of_service.section7.title')}</h2>
<p>
{t('terms_of_service.section7.content')}
</p>
<h2 id="governing-law">{t('terms_of_service.section8.title')}</h2>
<p>
{t('terms_of_service.section8.content')}
</p>
<h2 id="contact">{t('terms_of_service.section9.title')}</h2>
<p>
{t('terms_of_service.section9.content')}
</p>
</article>
</div>
</div>
);
};
export default TermsOfServicePage;

View File

@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAudioInfo, getUserInfo } from '@/lib/podcastApi';
import { useTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils';
/**
@@ -7,6 +9,8 @@ import { getAudioInfo, getUserInfo } from '@/lib/podcastApi';
* 查询参数file_name
*/
export async function GET(req: NextRequest) {
const lang = getLanguageFromRequest(req);
const { t } = await useTranslation(lang, 'errors');
// 从请求 URL 中获取查询参数
const { searchParams } = new URL(req.url);
const fileName = searchParams.get('file_name');
@@ -14,14 +18,14 @@ export async function GET(req: NextRequest) {
// 检查是否提供了 fileName 参数
if (!fileName) {
return NextResponse.json(
{ success: false, error: '缺少 file_name 查询参数' },
{ success: false, error: t('missing_file_name_parameter') },
{ status: 400 }
);
}
try {
// 调用前端的 podcastApi 模块中的 getAudioInfo 函数
const result = await getAudioInfo(fileName);
const result = await getAudioInfo(fileName, lang);
if (!result.success) {
// 转发 getAudioInfo 返回的错误信息和状态码
@@ -36,7 +40,7 @@ export async function GET(req: NextRequest) {
if (authId) {
const userInfo = await getUserInfo(authId);
const userInfo = await getUserInfo(authId, lang);
if (userInfo.success && userInfo.data) {
userInfoData = {
name: userInfo.data.name,
@@ -58,7 +62,7 @@ export async function GET(req: NextRequest) {
} catch (error) {
console.error('代理 /api/audio-info 失败:', error);
return NextResponse.json(
{ success: false, error: '内部服务器错误或无法连接到后端服务' },
{ success: false, error: t('internal_server_error_backend_connection') },
{ status: 500 }
);
}

View File

@@ -1,14 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLanguageFromRequest } from '@/lib/utils';
import { useTranslation } from '@/i18n';
import path from 'path';
import fs from 'fs';
export async function GET(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors');
const filename = request.nextUrl.searchParams.get('filename');
// 验证文件名安全性
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return NextResponse.json(
{ error: '无效的文件名' },
{ error: t('invalid_filename')},
{ status: 400 }
);
}
@@ -27,7 +31,7 @@ export async function GET(request: NextRequest) {
if (!allowedExtensions.includes(ext)) {
return NextResponse.json(
{ error: '不支持的文件类型' },
{ error: t('unsupported_file_type')},
{ status: 400 }
);
}
@@ -86,13 +90,13 @@ export async function GET(request: NextRequest) {
} catch (error: any) { // 明确指定 error 类型
if (error.code === 'ENOENT') {
return NextResponse.json(
{ error: '文件不存在' },
{ error: t('file_not_found') },
{ status: 404 }
);
}
console.error('Error serving audio file:', error);
return NextResponse.json(
{ error: '服务器内部错误' },
{ error: t('internal_server_error') },
{ status: 500 }
);
}

View File

@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
import type { TTSConfig } from '@/types';
import { useTranslation } from '@/i18n'; // 导入 useTranslation
import { getLanguageFromRequest } from '@/lib/utils';
// 缓存对象,存储响应数据和时间戳
const cache = new Map<string, { data: any; timestamp: number }>();
@@ -34,8 +36,11 @@ const TTS_PROVIDER_ORDER = [
];
// 获取配置文件列表
export async function GET() {
const cacheKey = 'config_files_list';
export async function GET(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors'); // 加载 'errors' 命名空间的翻译
const cacheKey = `config_files_list_${lang}`; // 缓存键中包含语言
const cachedData = getCache(cacheKey);
if (cachedData) {
@@ -77,12 +82,13 @@ export async function GET() {
return NextResponse.json({
success: true,
message: t('config_files_list_success'), // 添加多语言消息
data: configFiles,
});
} catch (error) {
console.error('Error reading config directory:', error);
return NextResponse.json(
{ success: false, error: '无法读取配置目录' },
{ success: false, error: t('config_files_list_error') }, // 使用翻译的错误消息
{ status: 500 }
);
}
@@ -90,8 +96,11 @@ export async function GET() {
// 获取特定配置文件内容
export async function POST(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors'); // 加载 'errors' 命名空间的翻译
const { configFile } = await request.json();
const cacheKey = `config_file_${configFile}`;
const cacheKey = `config_file_${configFile}_${lang}`; // 缓存键中包含语言
const cachedData = getCache(cacheKey);
if (cachedData) {
@@ -105,7 +114,7 @@ export async function POST(request: NextRequest) {
try {
if (!configFile || !configFile.endsWith('.json')) {
return NextResponse.json(
{ success: false, error: '无效的配置文件名' },
{ success: false, error: t('invalid_config_file_name') }, // 使用翻译的错误消息
{ status: 400 }
);
}
@@ -118,12 +127,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
message: t('config_file_read_success'), // 添加多语言消息
data: config,
});
} catch (error) {
console.error('Error reading config file:', error);
return NextResponse.json(
{ success: false, error: '无法读取配置文件' },
{ success: false, error: t('read_config_file_error') }, // 使用翻译的错误消息
{ status: 500 }
);
}

View File

@@ -4,16 +4,21 @@ import type { PodcastGenerationRequest } from '@/types'; // 导入 SettingsFormD
import { getSessionData } from '@/lib/server-actions';
import { getUserPoints } from '@/lib/points'; // 导入 getUserPoints
import { fetchAndCacheProvidersLocal } from '@/lib/config-local'; // 导入 getTTSProviders
import { useTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils';
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true'; // 定义环境变量
export async function POST(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors');
const session = await getSessionData();
const userId = session.user?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: '用户未登录或会话已过期' },
{ success: false, error: t('user_not_logged_in_or_session_expired') },
{ status: 403 }
);
}
@@ -24,13 +29,13 @@ export async function POST(request: NextRequest) {
// 参数校验
if (!body.input_txt_content || body.input_txt_content.trim().length === 0) {
return NextResponse.json(
{ success: false, error: '请求正文不能为空' },
{ success: false, error: t('request_body_cannot_be_empty') },
{ status: 400 }
);
}
if (!body.tts_provider || body.tts_provider.trim().length === 0) {
return NextResponse.json(
{ success: false, error: 'TTS服务提供商不能为空' },
{ success: false, error: t('tts_provider_cannot_be_empty') },
{ status: 400 }
);
}
@@ -39,13 +44,13 @@ export async function POST(request: NextRequest) {
podUsers = JSON.parse(body.podUsers_json_content || '[]');
if (podUsers.length === 0) {
return NextResponse.json(
{ success: false, error: '请至少选择一位播客说话人' },
{ success: false, error: t('please_select_at_least_one_speaker') },
{ status: 400 }
);
}
} catch (e) {
return NextResponse.json(
{ success: false, error: '播客说话人配置格式无效' },
{ success: false, error: t('invalid_speaker_config_format') },
{ status: 400 }
);
}
@@ -57,7 +62,7 @@ export async function POST(request: NextRequest) {
// 2. 检查积分是否足够
if (currentPoints === null || currentPoints < POINTS_PER_PODCAST) {
return NextResponse.json(
{ success: false, error: `积分不足,生成一个播客需要 ${POINTS_PER_PODCAST} 积分,您当前只有 ${currentPoints || 0} 积分。` },
{ success: false, error: t('insufficient_points_for_podcast', { pointsNeeded: POINTS_PER_PODCAST, currentPoints: currentPoints || 0 }) },
{ status: 402 } // 402 Forbidden - 权限不足,因为积分不足
);
}
@@ -66,7 +71,7 @@ export async function POST(request: NextRequest) {
const allowedLanguages = ['Chinese', 'English', 'Japanese'];
if (!body.output_language || !allowedLanguages.includes(body.output_language)) {
return NextResponse.json(
{ success: false, error: '无效的输出语言' },
{ success: false, error: t('invalid_output_language') },
{ status: 400 }
);
}
@@ -74,7 +79,7 @@ export async function POST(request: NextRequest) {
const allowedDurations = ['Under 5 minutes', '5-10 minutes', '10-15 minutes'];
if (!body.usetime || !allowedDurations.includes(body.usetime)) {
return NextResponse.json(
{ success: false, error: '无效的播客时长' },
{ success: false, error: t('invalid_podcast_duration') },
{ status: 400 }
);
}
@@ -85,7 +90,7 @@ export async function POST(request: NextRequest) {
// 如果启用配置页面,则直接使用前端传入的 body
if (body.tts_providers_config_content === undefined || body.api_key === undefined || body.base_url === undefined || body.model === undefined) {
return NextResponse.json(
{ success: false, error: '缺少前端传入的TTS配置信息' },
{ success: false, error: t('missing_frontend_tts_config') },
{ status: 400 }
);
}
@@ -93,10 +98,10 @@ export async function POST(request: NextRequest) {
} else {
// 如果未启用配置页面,则在后端获取 TTS 配置
const settings = await fetchAndCacheProvidersLocal();
const settings = await fetchAndCacheProvidersLocal(lang);
if (!settings || !settings.apikey || !settings.model) {
return NextResponse.json(
{ success: false, error: '后端TTS配置不完整请检查后端配置文件。' },
{ success: false, error: t('incomplete_backend_tts_config') },
{ status: 500 }
);
}
@@ -117,7 +122,7 @@ export async function POST(request: NextRequest) {
const callback_url = process.env.NEXT_PUBLIC_PODCAST_CALLBACK_URL || "" // 从环境变量获取
finalRequest.callback_url = callback_url;
// 积分足够,继续生成播客
const result = await startPodcastGenerationTask(finalRequest, userId);
const result = await startPodcastGenerationTask(finalRequest, userId, lang);
if (result.success) {
return NextResponse.json({
@@ -135,7 +140,7 @@ export async function POST(request: NextRequest) {
console.error('Error in generate-podcast API:', error);
const statusCode = error.statusCode || 500; // 假设 HttpError 会有 statusCode 属性
return NextResponse.json(
{ success: false, error: error.message || '服务器内部错误' },
{ success: false, error: error.message || t('internal_server_error_default') },
{ status: statusCode }
);
}

View File

@@ -1,20 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPodcastStatus } from '@/lib/podcastApi';
import { getSessionData } from '@/lib/server-actions';
import { useTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils';
export const revalidate = 0; // 等同于 `cache: 'no-store'`
export async function GET(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors');
const session = await getSessionData();
const userId = session.user?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: '用户未登录或会话已过期' },
{ success: false, error: t('user_not_logged_in_or_session_expired') },
{ status: 403 }
);
}
const result = await getPodcastStatus(userId);
const result = await getPodcastStatus(userId, lang);
if (result.success) {
return NextResponse.json({
success: true,
@@ -23,7 +28,7 @@ export async function GET(request: NextRequest) {
} else {
console.log('获取任务状态失败', result);
return NextResponse.json(
{ success: false, error: result.error || '获取任务状态失败' },
{ success: false, error: result.error || t('failed_to_get_task_status') },
{ status: result.statusCode || 500 }
);
}

View File

@@ -1,11 +1,15 @@
import { getUserPoints, deductUserPoints, addPointsToUser, hasUserSignedToday } from "@/lib/points"; // 导入 deductUserPoints, addPointsToUser, hasUserSignedToday
import { NextResponse, NextRequest } from "next/server"; // 导入 NextRequest
import { getSessionData } from "@/lib/server-actions"; // 导入 getSessionData
import { useTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils'; // 导入 getLanguageFromRequest
export async function GET() {
export async function GET(request: NextRequest) { // GET 函数接收 request
const session = await getSessionData(); // 使用 getSessionData 获取 session
const lang = getLanguageFromRequest(request); // 获取语言
const { t } = await useTranslation(lang, 'errors'); // 初始化翻译
if (!session || !session.user || !session.user.id) {
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
return NextResponse.json({ success: false, error: t("unauthorized") }, { status: 401 });
}
try {
@@ -21,28 +25,30 @@ export async function GET() {
return NextResponse.json({ success: true, points: points });
} catch (error) {
console.error("Error fetching user points:", error);
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
return NextResponse.json({ success: false, error: t("internal_server_error") }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const { task_id, auth_id, timestamp, status } = await request.json();
const lang = getLanguageFromRequest(request); // 获取语言
const { t } = await useTranslation(lang, 'errors'); // 初始化翻译
try {
if(status !== 'completed') {
return NextResponse.json({ success: false, error: "Invalid status" }, { status: 400 });
return NextResponse.json({ success: false, error: t("invalid_status") }, { status: 400 });
}
// 1. 参数校验
if (!task_id || !auth_id || typeof timestamp !== 'number') {
console.log(task_id, auth_id, timestamp)
return NextResponse.json({ success: false, error: "Invalid request parameters" }, { status: 400 });
return NextResponse.json({ success: false, error: t("invalid_request_parameters") }, { status: 400 });
}
// 2. 时间戳校验 (10秒内)
const currentTime = Math.floor(Date.now() / 1000); // 秒级时间戳
if (Math.abs(currentTime - timestamp) > 10) {
console.log(currentTime, timestamp)
return NextResponse.json({ success: false, error: "Request too old or in the future" }, { status: 400 });
return NextResponse.json({ success: false, error: t("request_too_old_or_future") }, { status: 400 });
}
// 3. 校验是否重复请求 (这里需要一个机制来判断 task_id 是否已被处理)
@@ -59,54 +65,57 @@ export async function PUT(request: NextRequest) {
// 5. 扣减积分
const pointsToDeduct = parseInt(process.env.POINTS_PER_PODCAST || '10', 10); // 从环境变量获取默认10
const reasonCode = "podcast_generation";
const description = `播客生成任务:${task_id}`;
const description = `播客生成任务:${task_id}`; // Keep this as is if task_id is dynamic or not translatable
await deductUserPoints(userId, pointsToDeduct, reasonCode, description);
return NextResponse.json({ success: true, message: "Points deducted successfully" });
return NextResponse.json({ success: true, message: t("points_deducted_successfully") });
} catch (error) {
console.error("Error deducting points:", error);
if (error instanceof Error) {
// 区分积分不足的错误
if (error.message.includes("积分不足")) {
if (error.message.includes(t("insufficient_points_raw"))) { // 使用翻译键来判断
console.error("积分不足错误: %s %s %s", auth_id, task_id, error);
return NextResponse.json({ success: false, error: error.message }, { status: 402 }); // Forbidden
return NextResponse.json({ success: false, error: t("insufficient_points") }, { status: 402 }); // Forbidden
}
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
return NextResponse.json({ success: false, error: t("internal_server_error") }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const session = await getSessionData();
const lang = getLanguageFromRequest(request); // 获取语言
console.log(lang)
const { t } = await useTranslation(lang, 'errors'); // 初始化翻译
if (!session || !session.user || !session.user.id) {
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
return NextResponse.json({ success: false, error: t("unauthorized") }, { status: 401 });
}
const userId = session.user.id;
const fixedPointsToAdd = parseInt(process.env.POINTS_PER_SIGN_IN || '5', 10); // 签到积分固定从环境变量获取默认5分
const fixedReasonCode = "sign_in";
const description = "每日签到"; // 描述固定
const description = t("daily_sign_in"); // 描述固定
try {
// 1. 判断今日是否已签到
const hasSignedToday = await hasUserSignedToday(userId, fixedReasonCode);
if (hasSignedToday) {
return NextResponse.json({ success: false, error: "Already signed in today" }, { status: 400 });
return NextResponse.json({ success: false, error: t("already_signed_in_today") }, { status: 400 });
}
// 2. 调用增加积分的方法
await addPointsToUser(userId, fixedPointsToAdd, fixedReasonCode, description);
return NextResponse.json({ success: true, message: "Points added successfully" });
return NextResponse.json({ success: true, message: t("points_added_successfully") });
} catch (error) {
console.error("Error adding points:", error);
if (error instanceof Error) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
return NextResponse.json({ success: false, error: t("internal_server_error") }, { status: 500 });
}
}

View File

@@ -1,11 +1,16 @@
import { getUserPointsTransactions } from "@/lib/points";
import { NextResponse, NextRequest } from "next/server";
import { getSessionData } from "@/lib/server-actions";
import { useTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils';
export async function GET(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors');
const session = await getSessionData();
if (!session || !session.user || !session.user.id) {
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
return NextResponse.json({ success: false, error: t("unauthorized") }, { status: 401 });
}
try {
@@ -16,7 +21,7 @@ export async function GET(request: NextRequest) {
// 校验 page 和 pageSize 是否为有效数字
if (isNaN(page) || page < 1 || isNaN(pageSize) || pageSize < 1) {
return NextResponse.json({ success: false, error: "Invalid pagination parameters" }, { status: 400 });
return NextResponse.json({ success: false, error: t("invalid_pagination_parameters") }, { status: 400 });
}
const transactions = await getUserPointsTransactions(userId, page, pageSize);
@@ -24,6 +29,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ success: true, transactions });
} catch (error) {
console.error("Error fetching user points transactions:", error);
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
return NextResponse.json({ success: false, error: t("internal_server_error") }, { status: 500 });
}
}

View File

@@ -1,14 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchAndCacheProvidersLocal } from '@/lib/config-local';
import { useTranslation } from '@/i18n';
import { getLanguageFromRequest } from '@/lib/utils';
// 获取 tts_providers.json 文件内容
export async function GET() {
export async function GET(request: NextRequest) {
const lang = getLanguageFromRequest(request);
const { t } = await useTranslation(lang, 'errors');
try {
const config = await fetchAndCacheProvidersLocal();
const config = await fetchAndCacheProvidersLocal(lang);
console.log('重新加载并缓存 tts_providers.json 数据');
if (!config) {
return NextResponse.json(
{ success: false, error: '无法读取TTS提供商配置文件' },
{ success: false, error: t('cannot_read_tts_provider_config') },
{ status: 500 }
);
}
@@ -20,7 +26,7 @@ export async function GET() {
} catch (error) {
console.error('Error reading tts_providers.json:', error);
return NextResponse.json(
{ success: false, error: '无法读取TTS提供商配置文件' },
{ success: false, error: t('cannot_read_tts_provider_config') },
{ status: 500 }
);
}

View File

@@ -1,68 +0,0 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import FooterLinks from '../components/FooterLinks';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = {
metadataBase: new URL('https://www.podcasthub.com'),
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
description: 'PodcastHub 利用尖端AI技术为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频支持多种个性化语音和风格选择。立即体验高效创作让您的声音在全球范围内传播吸引更多听众并简化您的播客制作流程。',
keywords: ['播客', 'AI', '语音合成', 'TTS', '音频生成'],
authors: [{ name: 'PodcastHub Team' }],
icons: {
icon: '/favicon.webp',
apple: '/favicon.webp',
},
openGraph: {
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
description: 'PodcastHub 利用尖端AI技术为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频支持多种个性化语音和风格选择。立即体验高效创作让您的声音在全球范围内传播吸引更多听众并简化您的播客制作流程。',
type: 'website',
locale: 'zh_CN',
},
twitter: {
card: 'summary_large_image',
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
},
alternates: {
canonical: '/',
},
};
export const viewport = {
themeColor: '#000000',
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" className={inter.variable}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className={`${inter.className} antialiased`}>
<div id="root" className="min-h-screen bg-white">
{children}
</div>
{/* Toast容器 */}
<div id="toast-root" />
{/* Modal容器 */}
<div id="modal-root" />
<footer className="py-8">
<FooterLinks />
</footer>
</body>
</html>
);
}

View File

@@ -1,30 +0,0 @@
import { Metadata } from 'next';
import PodcastContent from '@/components/PodcastContent';
export async function generateMetadata({ params }: PodcastDetailPageProps): Promise<Metadata> {
const fileName = decodeURIComponent(params.fileName);
const title = `播客详情 - ${fileName}`;
const description = `收听 ${fileName} 的播客。`;
return {
title,
description,
alternates: {
canonical: `/podcast/${fileName}`,
},
};
}
interface PodcastDetailPageProps {
params: {
fileName: string;
};
}
export default async function PodcastDetailPage({ params }: PodcastDetailPageProps) {
return (
<div className="bg-white text-gray-800 font-sans">
<PodcastContent fileName={decodeURIComponent(params.fileName)} />
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { Metadata } from 'next';
import React from 'react';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
export const metadata: Metadata = {
title: '定价 - PodcastHub',
description: '查看 PodcastHub 的灵活定价方案,找到最适合您的播客创作计划。',
alternates: {
canonical: '/pricing',
},
};
const PricingPage: React.FC = () => {
return (
<PricingSection />
);
};
export default PricingPage;

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import AudioVisualizer from './AudioVisualizer';
import { useIsSmallScreen } from '@/hooks/useMediaQuery'; // 导入新的 Hook
import type { AudioPlayerState, PodcastItem } from '@/types';
import { useToast, ToastContainer } from '@/components/Toast';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface AudioPlayerProps {
podcast: PodcastItem;
@@ -25,6 +26,7 @@ interface AudioPlayerProps {
onPlayPause: () => void;
onEnded: () => void;
className?: string;
lang: string; // 新增 lang 属性
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({
@@ -33,7 +35,9 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
onPlayPause,
onEnded,
className,
lang, // 解构 lang 属性
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const audioRef = useRef<HTMLAudioElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const { toasts, success: toastSuccess, removeToast } = useToast(); // 使用 useToast Hook
@@ -190,8 +194,8 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
// 从 podcast.audioUrl 中提取文件名
const audioFileName = podcast.file_name;
if (!audioFileName) {
console.error("无法获取音频文件名进行分享。");
toastSuccess('分享失败:无法获取音频文件名。');
console.error(t('audioPlayer.cannotGetAudioFileName'));
toastSuccess(t('audioPlayer.shareFailed'));
return;
}
@@ -212,7 +216,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
// 降级到复制音频链接
await navigator.clipboard.writeText(shareUrl); // 使用构建的分享链接
// 使用Toast提示
toastSuccess('播放链接已复制到剪贴板!');
toastSuccess(t('audioPlayer.playLinkCopied'));
}
};
@@ -239,7 +243,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
onClick={togglePlayPause}
disabled={isLoading}
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 ? "暂停" : "播放"}
title={isPlaying ? t('audioPlayer.pause') : t('audioPlayer.play')}
>
{isLoading ? (
<div className="w-8 h-8 border-2 border-black border-t-transparent rounded-full animate-spin" />
@@ -304,14 +308,14 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
<button
onClick={() => skipTime(-10)}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="后退10秒"
title={t('audioPlayer.backward10s')}
>
<AiOutlineStepBackward className="w-4 h-4" />
</button>
<button
onClick={() => skipTime(10)}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="前进10秒"
title={t('audioPlayer.forward10s')}
>
<AiOutlineStepForward className="w-4 h-4" />
</button>
@@ -328,7 +332,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
}
}}
className="p-1 text-neutral-600 hover:text-black transition-colors min-w-[40px] text-xs"
title={`当前倍速: ${currentPlaybackRate.toFixed(2)}x`}
title={`${t('audioPlayer.currentPlaybackRate')}: ${currentPlaybackRate.toFixed(2)}x`}
>
{currentPlaybackRate.toFixed(2)}x
</button>
@@ -338,7 +342,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
<button
onClick={toggleMute}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title={isMuted ? "取消静音" : "静音"}
title={isMuted ? t('audioPlayer.unmute') : t('audioPlayer.mute')}
>
{isMuted || playerState.volume === 0 ? (
<AiOutlineMuted className="w-4 h-4" />
@@ -361,7 +365,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
<button
onClick={handleShare}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="分享"
title={t('audioPlayer.share')}
>
<AiOutlineShareAlt className="w-4 h-4" />
</button>
@@ -369,7 +373,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
<button
onClick={handleDownload}
className="p-1 text-neutral-600 hover:text-black transition-colors"
title="下载"
title={t('audioPlayer.download')}
>
<AiOutlineCloudDownload className="w-4 h-4" />
</button>
@@ -389,7 +393,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
"p-1 text-neutral-400 hover:text-neutral-600 transition-colors flex-shrink-0",
{ "opacity-50 cursor-not-allowed": isSmallScreen && effectiveIsCollapsed } // 当 effectiveIsCollapsed 为 true 且是小屏幕时禁用 (因为此时按钮功能是展开,不允许)
)}
title={isSmallScreen && effectiveIsCollapsed ? "小于sm尺寸不可展开" : (effectiveIsCollapsed ? "展开播放器" : "收起播放器")}
title={isSmallScreen && effectiveIsCollapsed ? t('audioPlayer.lessThanSMSizeCannotExpand') : (effectiveIsCollapsed ? t('audioPlayer.expandPlayer') : t('audioPlayer.collapsePlayer'))}
disabled={isSmallScreen && effectiveIsCollapsed} // 当 effectiveIsCollapsed 为 true 且是小屏幕时禁用
>
{effectiveIsCollapsed ? ( // 根据 effectiveIsCollapsed 决定显示哪个图标

View File

@@ -1,55 +1,58 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { AiFillPlayCircle, AiFillPauseCircle } from 'react-icons/ai';
import { useState, useRef, useEffect } from 'react';
import { AiFillPlayCircle, AiFillPauseCircle } from 'react-icons/ai';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface AudioPlayerControlsProps {
audioUrl: string;
audioDuration?: string;
}
interface AudioPlayerControlsProps {
audioUrl: string;
audioDuration?: string;
lang: string; // 新增 lang 属性
}
export default function AudioPlayerControls({ audioUrl, audioDuration }: AudioPlayerControlsProps) {
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
export default function AudioPlayerControls({ audioUrl, audioDuration, lang }: AudioPlayerControlsProps) {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const togglePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
useEffect(() => {
const audio = audioRef.current;
if (audio) {
const onEnded = () => {
setIsPlaying(false);
};
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('ended', onEnded);
};
}
}, []);
useEffect(() => {
const audio = audioRef.current;
if (audio) {
const onEnded = () => {
setIsPlaying(false);
};
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('ended', onEnded);
};
}
}, []);
return (
<div className="flex justify-center my-8">
<button
onClick={togglePlayPause}
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 ? (
<AiFillPauseCircle className="w-5 h-5" />
) : (
<AiFillPlayCircle className="w-5 h-5" />
)}
<span>{isPlaying ? '暂停' : '播放'} ({audioDuration ?? '00:00'})</span>
</button>
<audio ref={audioRef} src={audioUrl} preload="auto" />
</div>
);
}
return (
<div className="flex justify-center my-8">
<button
onClick={togglePlayPause}
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 ? (
<AiFillPauseCircle className="w-5 h-5" />
) : (
<AiFillPlayCircle className="w-5 h-5" />
)}
<span>{isPlaying ? t('audioPlayerControls.pause') : t('audioPlayerControls.play')} ({audioDuration ?? '00:00'})</span>
</button>
<audio ref={audioRef} src={audioUrl} preload="auto" />
</div>
);
}

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface BillingToggleProps {
billingPeriod: 'monthly' | 'annually';
onToggle: (period: 'monthly' | 'annually') => void;
lang: string; // 新增 lang 属性
}
const BillingToggle: React.FC<BillingToggleProps> = ({ billingPeriod, onToggle }) => {
const BillingToggle: React.FC<BillingToggleProps> = ({ billingPeriod, onToggle, lang }) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
return (
<div className="relative flex items-center justify-center p-1 bg-neutral-100 rounded-full shadow-inner-sm">
<button
@@ -16,7 +19,7 @@ const BillingToggle: React.FC<BillingToggleProps> = ({ billingPeriod, onToggle }
${billingPeriod === 'monthly' ? 'bg-white text-neutral-900 shadow-medium' : 'text-neutral-500'}
`}
>
{t('billingToggle.monthly')}
</button>
<button
type="button"
@@ -26,11 +29,11 @@ const BillingToggle: React.FC<BillingToggleProps> = ({ billingPeriod, onToggle }
${billingPeriod === 'annually' ? 'bg-white text-neutral-900 shadow-medium' : 'text-neutral-500'}
`}
>
{t('billingToggle.annually')}
</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%
{t('billingToggle.save20Percent')}
</span>
)}
</div>

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { AiOutlineCheck } from 'react-icons/ai';
import type { TTSConfig, Voice } from '@/types';
import { getTTSProviders } from '@/lib/config';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
interface ConfigFile {
@@ -15,12 +16,15 @@ interface ConfigFile {
interface ConfigSelectorProps {
onConfigChange?: (config: TTSConfig, name: string, voices: Voice[]) => void; // 添加 name 和 voices 参数
className?: string;
lang: string; // 新增 lang 属性
}
const ConfigSelector: React.FC<ConfigSelectorProps> = ({
onConfigChange,
className
className,
lang
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const [configFiles, setConfigFiles] = useState<ConfigFile[]>([]);
const [selectedConfig, setSelectedConfig] = useState<string>('');
const [currentConfig, setCurrentConfig] = useState<TTSConfig | null>(null);
@@ -61,6 +65,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-next-locale': lang,
},
body: JSON.stringify({ configFile }),
});
@@ -90,12 +95,17 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
loadConfigFilesCalled.current = true;
try {
const response = await fetch('/api/config');
const response = await fetch('/api/config', {
method: 'GET',
headers: {
'x-next-locale': lang,
},
});
const result = await response.json();
if (result.success && Array.isArray(result.data)) {
// 过滤出已配置的TTS选项
const settings = await getTTSProviders();
const settings = await getTTSProviders(lang);
const availableConfigs = result.data.filter((config: ConfigFile) =>
isTTSConfigured(config.name, settings)
);
@@ -161,7 +171,7 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
>
{/* <Settings className="w-4 h-4 text-neutral-500" /> */}
<span className="flex-1 text-left text-sm">
{isLoading ? '加载中...' : selectedConfigFile?.displayName || (configFiles.length === 0 ? '请先配置TTS' : '选择TTS配置')}
{isLoading ? t('configSelector.loading') : selectedConfigFile?.displayName || (configFiles.length === 0 ? t('configSelector.pleaseConfigTTS') : t('configSelector.selectTTSConfig'))}
</span>
{/* <ChevronDown className={cn(
"w-4 h-4 text-neutral-400 transition-transform",
@@ -189,8 +199,8 @@ const ConfigSelector: React.FC<ConfigSelectorProps> = ({
</button>
)) : (
<div className="px-4 py-3 text-sm text-neutral-500 text-center">
<div className="mb-1">TTS配置</div>
<div className="text-xs">TTS服务</div>
<div className="mb-1">{t('configSelector.noAvailableTTSConfig')}</div>
<div className="text-xs">{t('configSelector.pleaseConfigTTS')}</div>
</div>
)}
</div>

View File

@@ -4,6 +4,7 @@ import React, { useRef, useEffect } from 'react';
import { AiOutlineRight, AiOutlineReload } from 'react-icons/ai';
import PodcastCard from './PodcastCard';
import type { PodcastItem } from '@/types'; // 移除了 PodcastGenerationResponse
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface ContentSectionProps {
title: string;
@@ -19,6 +20,7 @@ interface ContentSectionProps {
onTitleClick?: (podcast: PodcastItem) => void; // 确保传入 onTitleClick
currentPodcast?: PodcastItem | null; // Keep this prop for PodcastCard
isPlaying?: boolean; // Keep this prop for PodcastCard
lang: string; // 新增 lang 属性
}
const ContentSection: React.FC<ContentSectionProps> = ({
@@ -34,8 +36,10 @@ const ContentSection: React.FC<ContentSectionProps> = ({
onRefresh,
onTitleClick, // 确保解构
currentPodcast, // 确保解构
isPlaying // 确保解构
isPlaying, // 确保解构
lang
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
if (loading) {
return (
@@ -87,13 +91,13 @@ const ContentSection: React.FC<ContentSectionProps> = ({
onClick={onViewAll}
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
>
{t('contentSection.viewAll')}
<AiOutlineRight className="w-4 h-4" />
</button>
)}
</div>
<div className="text-center py-12 text-neutral-500">
<p></p>
<p>{t('contentSection.noContent')}</p>
</div>
</div>
);
@@ -114,10 +118,10 @@ const ContentSection: React.FC<ContentSectionProps> = ({
<button
onClick={onRefresh}
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm group whitespace-nowrap"
title="刷新"
title={t('contentSection.refresh')}
>
<AiOutlineReload className="w-4 h-4" />
{t('contentSection.refresh')}
</button>
)}
{onViewAll && (
@@ -140,6 +144,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
onPlayPodcast={onPlayPodcast}
variant={variant}
onTitleClick={onTitleClick}
lang={lang}
/>
) : (
// 网格布局
@@ -157,6 +162,7 @@ const ContentSection: React.FC<ContentSectionProps> = ({
currentPodcast={currentPodcast}
isPlaying={isPlaying}
onTitleClick={onTitleClick}
lang={lang}
/>
))}
</div>
@@ -171,13 +177,15 @@ interface HorizontalScrollSectionProps {
onPlayPodcast?: (podcast: PodcastItem) => void;
variant?: 'default' | 'compact';
onTitleClick?: (podcast: PodcastItem) => void;
lang: string; // 新增 lang 属性
}
const HorizontalScrollSection: React.FC<HorizontalScrollSectionProps> = ({
items,
onPlayPodcast,
variant = 'default',
onTitleClick
onTitleClick,
lang // 解构 lang 属性
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -258,6 +266,7 @@ const HorizontalScrollSection: React.FC<HorizontalScrollSectionProps> = ({
variant === 'compact' ? 'w-80' : 'w-72'
}`}
onTitleClick={onTitleClick}
lang={lang}
/>
))}
</div>

View File

@@ -1,5 +1,9 @@
"use client";
import Link from 'next/link';
import { usePathname } from 'next/navigation'; // 导入 usePathname
import React from 'react';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
/**
* FooterLinks 组件用于展示页脚的法律和联系链接。
@@ -7,12 +11,15 @@ import React from 'react';
*
* @returns {React.FC} 包含链接布局的 React 函数组件。
*/
const FooterLinks: React.FC = () => {
const FooterLinks: React.FC<{ lang: string }> = ({ lang: initialLang }) => {
const { t } = useTranslation(initialLang, 'components'); // 初始化 useTranslation 并指定命名空间
const pathname = usePathname();
const lang = pathname === '/' ? '' : initialLang; // 如果是根目录lang赋值为空
const links = [
{ href: '/terms', label: '使用条款' },
{ href: '/privacy', label: '隐私政策' },
{ href: '/contact', label: '联系我们' },
{ href: '#', label: '© 2025 Hex2077' },
{ href: `${lang}/terms`, label: t('footerLinks.termsOfUse') },
{ href: `${lang}/privacy`, label: t('footerLinks.privacyPolicy') },
{ href: `${lang}/contact`, label: t('footerLinks.contactUs') },
{ href: '#', label: t('footerLinks.copyright') },
];
return (

View File

@@ -0,0 +1,47 @@
'use client';
import { useRouter, usePathname } from 'next/navigation'; // 导入 usePathname
import { useTranslation } from '../i18n/client'; // 导入自定义的 useTranslation
interface LanguageSwitcherProps {
lang: string; // 新增 lang 属性
}
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ lang }) => {
const { t, i18n } = useTranslation(lang, 'components'); // 初始化 useTranslation
const router = useRouter();
const switchLanguage = (locale: string) => {
// 获取当前路径,并替换语言部分
const currentPath = usePathname(); // 使用 usePathname 获取当前路径
const newPath = `/${locale}${currentPath.substring(currentPath.indexOf('/', 1))}`;
router.push(newPath);
};
return (
<div className="flex items-center space-x-2">
<button
onClick={() => switchLanguage('zh-CN')}
className={`px-3 py-1 rounded-md text-sm font-medium ${
i18n.language === 'zh-CN'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{t('languageSwitcher.chinese')}
</button>
<button
onClick={() => switchLanguage('en')}
className={`px-3 py-1 rounded-md text-sm font-medium ${
i18n.language === 'en'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{t('languageSwitcher.english')}
</button>
</div>
);
};
export default LanguageSwitcher;

View File

@@ -6,13 +6,16 @@ import { signIn } from '@/lib/auth-client';
import { createPortal } from "react-dom";
import { XMarkIcon } from "@heroicons/react/24/outline"; // 导入关闭图标
import { AiOutlineChrome, AiOutlineGithub } from "react-icons/ai"; // 从 react-icons/ai 导入 Google 和 GitHub 图标
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
lang: string; // 新增 lang 属性
}
const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose, lang }) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const modalRef = useRef<HTMLDivElement>(null);
// 点击背景关闭模态框
@@ -50,7 +53,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
</button>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
{t('loginModal.loginToYourAccount')}
</h2>
<div className="space-y-4">
@@ -59,7 +62,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
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"
>
<AiOutlineChrome className="h-6 w-6" />
<span className="text-lg">使 Google </span>
<span className="text-lg">{t('loginModal.signInWithGoogle')}</span>
</button>
<button
@@ -67,7 +70,7 @@ const LoginModal: FC<LoginModalProps> = ({ isOpen, onClose }) => {
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"
>
<AiOutlineGithub className="h-6 w-6" />
<span className="text-lg">使 GitHub </span>
<span className="text-lg">{t('loginModal.signInWithGitHub')}</span>
</button>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import Image from 'next/image';
import { AiFillPlayCircle, AiFillPauseCircle, AiOutlineClockCircle, AiOutlineEye, AiOutlineUser, AiFillHeart, AiOutlineEllipsis } from 'react-icons/ai';
import { cn, formatTime, formatRelativeTime } from '@/lib/utils';
import type { PodcastItem } from '@/types';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface PodcastCardProps {
podcast: PodcastItem;
@@ -14,6 +15,7 @@ interface PodcastCardProps {
currentPodcast?: PodcastItem | null;
isPlaying?: boolean;
onTitleClick?: (podcast: PodcastItem) => void; // 新增 onTitleClick 回调
lang: string; // 新增 lang 属性
}
const PodcastCard: React.FC<PodcastCardProps> = ({
@@ -24,7 +26,9 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
currentPodcast,
isPlaying,
onTitleClick, // 解构 onTitleClick
lang
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const [isLiked, setIsLiked] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -118,7 +122,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
{(podcast.status === 'pending' || podcast.status === 'running') && (
<div className="absolute inset-0 bg-black/100 z-10 flex flex-col items-center justify-center text-white text-lg font-semibold p-4 text-center">
<p className="mb-2">
{podcast.status === 'pending' ? '播客生成排队中...' : '播客生成中...'}
{podcast.status === 'pending' ? t('podcastCard.podcastGenerationQueued') : t('podcastCard.podcastGenerating')}
</p>
</div>
)}
@@ -190,6 +194,7 @@ const PodcastCard: React.FC<PodcastCardProps> = ({
<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"
title={t('podcastCard.moreOperations')}
>
<AiOutlineEllipsis className="w-4 h-4" />
</button>

View File

@@ -3,11 +3,13 @@ import { getAudioInfo, getUserInfo } from '@/lib/podcastApi';
import AudioPlayerControls from './AudioPlayerControls';
import PodcastTabs from './PodcastTabs';
import ShareButton from './ShareButton'; // 导入 ShareButton 组件
import { useTranslation } from '../i18n'; // 从正确路径导入 useTranslation
// 脚本解析函数 (与 page.tsx 中保持一致)
const parseTranscript = (
transcript: { speaker_id: number; dialog: string }[] | undefined,
podUsers: { role: string; code: string; name: string; usedname: string }[] | undefined
podUsers: { role: string; code: string; name: string; usedname: string }[] | undefined,
t: (key: string, options?: any) => string // 传递 t 函数
) => {
if (!transcript) return [];
@@ -16,7 +18,7 @@ const parseTranscript = (
if (podUsers && podUsers[item.speaker_id]) {
speakerName = podUsers[item.speaker_id].usedname; // 使用 podUsers 中的 usedname 字段作为 speakerName
} else {
speakerName = `Speaker ${item.speaker_id}`; // 回退到 Speaker ID
speakerName = `${t('podcastContent.speaker')} ${item.speaker_id}`; // 回退到 Speaker ID
}
return { id: index, speaker: speakerName, dialogue: item.dialog };
});
@@ -24,18 +26,20 @@ const parseTranscript = (
interface PodcastContentProps {
fileName: string;
lang: string; // 新增 lang 属性
}
export default async function PodcastContent({ fileName }: PodcastContentProps) {
const result = await getAudioInfo(fileName);
export default async function PodcastContent({ fileName, lang }: PodcastContentProps) {
const { t } = await useTranslation(lang, 'components'); // 初始化 useTranslation
const result = await getAudioInfo(fileName, lang);
if (!result.success || !result.data || result.data.status!='completed') {
return (
<div className="flex flex-col justify-center items-center h-screen text-gray-800">
<p className="text-red-500 text-lg">{result.error || '未知错误'}</p>
<p className="text-red-500 text-lg">{t('podcastContent.cannotLoadPodcastDetails')}{result.error || t('podcastContent.unknownError')}</p>
<a href="/" className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 transition-colors">
{t('podcastContent.returnToHomepage')}
</a>
</div>
);
@@ -44,7 +48,7 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
const authId = result.data?.auth_id; // 确保 auth_id 存在且安全访问
let userInfoData = null;
if (authId) {
const userInfo = await getUserInfo(authId);
const userInfo = await getUserInfo(authId, lang);
if (userInfo.success && userInfo.data) {
userInfoData = {
name: userInfo.data.name,
@@ -59,7 +63,7 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
};
const audioInfo = responseData;
const parsedScript = parseTranscript(audioInfo.podcast_script?.podcast_transcripts || [], audioInfo.podUsers);
const parsedScript = parseTranscript(audioInfo.podcast_script?.podcast_transcripts || [], audioInfo.podUsers, t); // 传递 t 函数
return (
<main className="max-w-3xl mx-auto px-6 py-10">
@@ -70,16 +74,16 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
>
<AiOutlineArrowLeft className="w-5 h-5 mr-1" />
{t('podcastContent.returnToHomepage')}
</a>
<div className="flex items-center gap-4"> {/* 使用 flex 容器包裹分享和下载按钮 */}
<ShareButton /> {/* 添加分享按钮 */}
<ShareButton lang={lang} /> {/* 添加分享按钮 */}
{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="下载音频"
aria-label={t('podcastContent.downloadAudio')}
>
<AiOutlineCloudDownload className="w-5 h-5" />
</a>
@@ -134,12 +138,14 @@ export default async function PodcastContent({ fileName }: PodcastContentProps)
<AudioPlayerControls
audioUrl={audioInfo.audioUrl || ''}
audioDuration={audioInfo.audio_duration}
lang={lang}
/>
{/* 3. 内容导航区和内容展示区 - 使用客户端组件 */}
<PodcastTabs
parsedScript={parsedScript}
overviewContent={audioInfo.overview_content ? audioInfo.overview_content.split('\n').slice(2).join('\n') : ''}
lang={lang}
/>
</main>
);

View File

@@ -22,6 +22,7 @@ import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
import { useSession } from '@/lib/auth-client'; // 引入 useSession
import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types';
import { Satisfy } from 'next/font/google'; // 导入艺术字体 Satisfy
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
// 定义艺术字体,预加载并设置 fallback
const satisfy = Satisfy({
@@ -37,6 +38,7 @@ interface PodcastCreatorProps {
settings: SettingsFormData | null; // 新增 settings 属性
onSignInSuccess: () => void; // 新增 onSignInSuccess 属性
enableTTSConfigPage: boolean; // 新增 enableTTSConfigPage 属性
lang: string; // 新增 lang 属性
}
const PodcastCreator: React.FC<PodcastCreatorProps> = ({
@@ -45,19 +47,21 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
credits,
settings, // 解构 settings 属性
onSignInSuccess, // 解构 onSignInSuccess 属性
enableTTSConfigPage // 解构 enableTTSConfigPage 属性
enableTTSConfigPage, // 解构 enableTTSConfigPage 属性
lang
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const languageOptions = [
{ value: 'Chinese', label: '简体中文' },
{ value: 'English', label: 'English' },
{ value: 'Japanese', label: '日本語' },
{ value: 'Chinese', label: t('podcastCreator.chinese') },
{ value: 'English', label: t('podcastCreator.english') },
// { value: 'Japanese', label: t('podcastCreator.japanese') },
];
const durationOptions = [
{ value: 'Under 5 minutes', label: '5分钟以内' },
{ value: '5-10 minutes', label: '5-10分钟' },
{ value: '10-15 minutes', label: '10-15分钟' },
{ value: 'Under 5 minutes', label: t('podcastCreator.under5Minutes') },
{ value: '5-10 minutes', label: t('podcastCreator.between5And10Minutes') },
{ value: '10-15 minutes', label: t('podcastCreator.between10And15Minutes') },
];
const [topic, setTopic] = useState('');
@@ -75,7 +79,21 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setCustomInstructions(cachedCustomInstructions);
}
}, []);
const [language, setLanguage] = useState(languageOptions[0].value);
const getInitialLanguage = (currentLang: string) => {
if (currentLang.startsWith('zh')) {
return 'Chinese';
}
if (currentLang.startsWith('en')) {
return 'English';
}
if (currentLang.startsWith('ja')) {
return 'Japanese';
}
return languageOptions[0].value; // 默认选中第一个选项
};
const [language, setLanguage] = useState(getInitialLanguage(lang));
const [duration, setDuration] = useState(durationOptions[0].value);
const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示
@@ -98,16 +116,16 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
return;
}
if (!topic.trim()) {
error("主题不能为空", "请输入播客主题。"); // 使用 toast.error
error(t('podcastCreator.topicCannotBeEmpty'), t('podcastCreator.pleaseEnterPodcastTopic'));
return;
}
if (!selectedConfig) {
error("TTS配置未选择", "请选择一个TTS配置。"); // 使用 toast.error
error(t('podcastCreator.ttsConfigNotSelected'), t('podcastCreator.pleaseSelectTTSConfig'));
return;
}
if (!selectedPodcastVoices[selectedConfigName] || selectedPodcastVoices[selectedConfigName].length === 0) {
error("请选择说话人", "请至少选择一位播客说话人。"); // 使用 toast.error
error(t('podcastCreator.pleaseSelectSpeaker'), t('podcastCreator.pleaseSelectAtLeastOneSpeaker'));
return;
}
@@ -138,7 +156,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setCustomInstructions('');
setItem('podcast-custom-instructions', '');
} catch (err) {
console.error("播客生成失败:", err);
console.error(t('podcastCreator.podcastGenerationFailed'), err);
}
};
@@ -153,20 +171,21 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-next-locale': lang,
},
});
const data = await response.json();
if (data.success) {
success("签到成功", data.message);
success(t('podcastCreator.checkInSuccess'), data.message);
onSignInSuccess(); // 签到成功后调用回调
} else {
error("签到失败", data.error);
error(t('podcastCreator.checkInFailed'), data.error);
}
} catch (err) {
console.error("签到请求失败:", err);
error("签到失败", "网络错误或服务器无响应");
error(t('podcastCreator.checkInFailed'), t('podcastCreator.networkError'));
}
};
@@ -248,7 +267,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
</svg>
</div>
<h1 className="text-2xl sm:text-3xl text-black mb-6 break-words">
{t('podcastCreator.giveVoiceToCreativity')}
</h1>
{/* 模式切换按钮 todo */}
@@ -290,7 +309,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setTopic(e.target.value);
setItem('podcast-topic', e.target.value); // 实时保存到 localStorage
}}
placeholder="输入文字支持Markdown格式..."
placeholder={t('podcastCreator.enterTextPlaceholder')}
className="w-full h-32 resize-none border-none outline-none text-lg placeholder-neutral-400 bg-white"
disabled={isGenerating}
/>
@@ -304,7 +323,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setCustomInstructions(e.target.value);
setItem('podcast-custom-instructions', e.target.value); // 实时保存到 localStorage
}}
placeholder="添加自定义指令(可选)... 例如:固定的开场白和结束语,文案脚本语境,输出内容的重点"
placeholder={t('podcastCreator.addCustomInstructions')}
className="w-full h-16 resize-none border-none outline-none text-sm placeholder-neutral-400 bg-white"
disabled={isGenerating}
/>
@@ -325,6 +344,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
setVoices(newVoices); // 更新 voices 状态
}}
className="w-full"
lang={lang} // 传递 lang
/></div>
{/* 说话人按钮 */}
@@ -339,7 +359,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
)}
disabled={isGenerating || !selectedConfig}
>
{t('podcastCreator.speaker')}
</button></div>
{/* 语言选择 */}
@@ -383,7 +403,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
{/* <button
onClick={() => fileInputRef.current?.click()}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="上传文件"
title={t('podcastCreator.fileUpload')}
disabled={isGenerating}
>
<AiOutlineUpload className="w-4 h-4 sm:w-5 sm:h-5" />
@@ -400,7 +420,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
{/* <button
onClick={handlePaste}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="粘贴内容"
title={t('podcastCreator.pasteContent')}
disabled={isGenerating}
>
<AiOutlineLink className="w-4 h-4 sm:w-5 sm:h-5" />
@@ -410,7 +430,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
{/* <button
onClick={() => navigator.clipboard.writeText(topic)}
className="p-1 sm:p-2 text-neutral-500 hover:text-black transition-colors"
title="复制内容"
title={t('podcastCreator.copyContent')}
disabled={isGenerating || !topic}
>
<AiOutlineCopy className="w-4 h-4 sm:w-5 sm:h-5" />
@@ -430,11 +450,11 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onClick={handleSignIn}
disabled={isGenerating}
className={cn(
"btn-secondary flex items-center gap-1 text-sm sm:text-base px-3 py-2 sm:px-4 sm:py-2",
"btn-secondary flex items-center gap-1 text-sm px-3 py-2 sm:px-4 sm:py-2",
isGenerating && "opacity-50 cursor-not-allowed"
)}
>
{t('podcastCreator.checkIn')}
</button>
<div className="flex flex-col items-center gap-1">
@@ -443,19 +463,19 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
onClick={handleSubmit}
disabled={!topic.trim() || isGenerating}
className={cn(
"btn-primary flex items-center gap-1 text-sm sm:text-base px-3 py-2 sm:px-4 sm:py-2",
"btn-primary flex items-center gap-1 text-sm px-3 py-2 sm:px-4 sm:py-2",
(!topic.trim() || isGenerating) && "opacity-50 cursor-not-allowed"
)}
>
{isGenerating ? (
<>
<AiOutlineLoading3Quarters className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<span className=" xs:inline">Biu!</span>
<span className=" xs:inline">{t('podcastCreator.biu')}</span>
</>
) : (
<>
<Wand2 className="w-3 h-3 sm:w-4 sm:h-4" />
<span className=" xs:inline"></span>
<span className=" xs:inline">{t('podcastCreator.create')}</span>
</>
)}
</button>
@@ -492,12 +512,14 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
return newState;
});
}}
lang={lang}
/>
)}
{/* Login Modal */}
<LoginModal
isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)}
lang={lang}
/>
<ToastContainer

View File

@@ -2,13 +2,16 @@
import { useState } from 'react';
import MarkdownRenderer from './MarkdownRenderer';
import { useTranslation } from '../i18n/client';
interface PodcastTabsProps {
parsedScript: { id: number; speaker: string | null; dialogue: string }[];
overviewContent?: string;
lang: string;
}
export default function PodcastTabs({ parsedScript, overviewContent }: PodcastTabsProps) {
export default function PodcastTabs({ parsedScript, overviewContent, lang }: PodcastTabsProps) {
const { t } = useTranslation(lang, 'components');
const [activeTab, setActiveTab] = useState<'script' | 'overview'>('script');
return (
@@ -24,7 +27,7 @@ export default function PodcastTabs({ parsedScript, overviewContent }: PodcastTa
}`}
onClick={() => setActiveTab('script')}
>
{t('podcastTabs.script')}
</button>
{/* 大纲 */}
<button
@@ -33,7 +36,7 @@ export default function PodcastTabs({ parsedScript, overviewContent }: PodcastTa
}`}
onClick={() => setActiveTab('overview')}
>
{t('podcastTabs.outline')}
</button>
</div>
</div>
@@ -56,7 +59,7 @@ export default function PodcastTabs({ parsedScript, overviewContent }: PodcastTa
overviewContent ? (
<MarkdownRenderer content={overviewContent} />
) : (
<p></p>
<p>{t('podcastTabs.noOutlineContent')}</p>
)
)}
</article>

View File

@@ -1,6 +1,7 @@
// web/src/components/PointsOverview.tsx
import React from 'react';
import { UserCircleIcon, WalletIcon } from '@heroicons/react/24/outline';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface PointEntry {
transactionId: string;
@@ -18,13 +19,16 @@ interface PointsOverviewProps {
image: string;
};
pointHistory: PointEntry[];
lang: string; // 新增 lang 属性
}
const PointsOverview: React.FC<PointsOverviewProps> = ({
totalPoints,
user,
pointHistory,
lang
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
return (
<div className="w-9/10 sm:w-3/5 lg:w-1/3 mx-auto flex flex-col gap-6 p-6 md:p-8 lg:p-10 bg-gray-50 dark:bg-gray-800 rounded-lg shadow-xl">
{/* Upper Section: Total Points and User Info */}
@@ -41,23 +45,23 @@ const PointsOverview: React.FC<PointsOverviewProps> = ({
</div>
</div>
<div className="mt-4 md:mt-0 text-center sm:text-right">
<p className="text-blue-200 dark:text-blue-300 text-sm uppercase"></p>
<p className="text-blue-200 dark:text-blue-300 text-sm uppercase">{t('pointsOverview.totalPoints')}</p>
<p className="text-5xl font-extrabold tracking-tighter">{totalPoints}</p>
<WalletIcon className="h-8 w-8 text-white inline-block ml-2" />
</div>
</div>
{/* Small text for mobile view only */}
<p className="text-center text-sm text-blue-500 dark:text-blue-300">
20
{t('pointsOverview.last20EntriesOnly')}
</p>
{/* Lower Section: Point Details */}
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-lg h-[60vh] sm:h-[70vh] overflow-y-auto">
<h3 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4 border-b pb-2 border-gray-200 dark:border-gray-700">
{t('pointsOverview.pointDetails')}
</h3>
{pointHistory.length === 0 ? (
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 text-center py-4"></p>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 text-center py-4">{t('pointsOverview.noPointDetails')}</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{pointHistory

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { PricingPlan, Feature } from '../types'; // 导入之前定义的类型
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface PricingCardProps {
plan: PricingPlan;
lang: string; // 新增 lang 属性
}
const FeatureItem: React.FC<{ feature: Feature }> = ({ feature }) => (
@@ -49,7 +51,8 @@ const FeatureItem: React.FC<{ feature: Feature }> = ({ feature }) => (
</li>
);
const PricingCard: React.FC<PricingCardProps> = ({ plan }) => {
const PricingCard: React.FC<PricingCardProps> = ({ plan, lang }) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const isMostPopular = plan.isMostPopular;
return (
@@ -63,7 +66,7 @@ const PricingCard: React.FC<PricingCardProps> = ({ plan }) => {
>
{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">
{t('pricingCard.mostPopular')}
</div>
)}
<div
@@ -88,7 +91,7 @@ const PricingCard: React.FC<PricingCardProps> = ({ plan }) => {
{plan.price}
</span>
<span className="text-xl text-neutral-500 ml-2">
/{plan.period === 'monthly' ? '月' : '月'}
{t('pricingCard.perMonth')}
</span>
</div>

View File

@@ -4,99 +4,106 @@ import React, { useState } from 'react';
import PricingCard from './PricingCard'; // 修改导入路径
import BillingToggle from './BillingToggle'; // 修改导入路径
import { PricingPlan } from '../types';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface PricingSectionProps {
lang: string;
}
const PricingSection: React.FC<PricingSectionProps> = ({ lang }) => { // 重命名组件
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
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 },
{ name: t('pricingSection.monthlyCreatorFeatures.points'), included: true },
{ name: t('pricingSection.monthlyCreatorFeatures.aiVoiceSynthesis'), included: true },
{ name: t('pricingSection.monthlyCreatorFeatures.twoSpeakers'), included: true },
{ name: t('pricingSection.monthlyCreatorFeatures.commercialLicense'), included: true },
{ name: t('pricingSection.monthlyCreatorFeatures.audioDownload'), 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: '即将推出'},
{ name: t('pricingSection.monthlyProFeatures.points'), included: true },
{ name: t('pricingSection.monthlyProFeatures.aiVoiceSynthesis'), included: true },
{ name: t('pricingSection.monthlyProFeatures.multiSpeakers'), included: true },
{ name: t('pricingSection.monthlyProFeatures.commercialLicense'), included: true },
{ name: t('pricingSection.monthlyProFeatures.audioDownload'), included: true },
{ name: t('pricingSection.monthlyProFeatures.advancedVoices'), included: true },
{ name: t('pricingSection.monthlyProFeatures.storytellingMode'), included: true, notes: t('pricingSection.comingSoon')},
] 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: '即将推出' },
{ name: t('pricingSection.monthlyBusinessFeatures.points'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.aiVoiceSynthesis'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.multiSpeakers'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.commercialLicense'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.dedicatedAccountManager'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.audioDownload'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.advancedVoices'), included: true },
{ name: t('pricingSection.monthlyBusinessFeatures.storytellingMode'), included: true, notes: t('pricingSection.comingSoon')},
{ name: t('pricingSection.monthlyBusinessFeatures.apiAccess'), included: true, notes: t('pricingSection.comingSoon') },
] as const;
const monthlyPlans: PricingPlan[] = [
{
name: '创作者',
name: t('pricingSection.creator'),
price: 9.9,
currency: '$',
period: 'monthly',
features: MONTHLY_CREATOR_FEATURES,
ctaText: '立即开始',
ctaText: t('pricingCard.getStarted'),
buttonVariant: 'secondary',
},
{
name: '专业版',
name: t('pricingSection.pro'),
price: 19.9,
currency: '$',
period: 'monthly',
features: MONTHLY_PRO_FEATURES,
ctaText: '升级至专业版',
ctaText: t('pricingCard.upgradeToPro'),
buttonVariant: 'primary',
isMostPopular: true,
},
{
name: '商业版',
name: t('pricingSection.business'),
price: 39.9,
currency: '$',
period: 'monthly',
features: MONTHLY_BUSINESS_FEATURES,
ctaText: '升级至商业版',
ctaText: t('pricingCard.upgradeToBusiness'),
buttonVariant: 'secondary',
},
];
const annuallyPlans: PricingPlan[] = [
{
name: '创作者',
price: 8,
name: t('pricingSection.creator'),
price: 8,
currency: '$',
period: 'annually',
features: MONTHLY_CREATOR_FEATURES,
ctaText: '立即开始',
ctaText: t('pricingCard.getStarted'),
buttonVariant: 'secondary',
},
{
name: '专业版',
price: 16,
name: t('pricingSection.pro'),
price: 16,
currency: '$',
period: 'annually',
features: MONTHLY_PRO_FEATURES,
ctaText: '升级至专业版',
ctaText: t('pricingCard.upgradeToPro'),
buttonVariant: 'primary',
isMostPopular: true,
},
{
name: '商业版',
price: 32,
name: t('pricingSection.business'),
price: 32,
currency: '$',
period: 'annually',
features: MONTHLY_BUSINESS_FEATURES,
ctaText: '升级至商业版',
ctaText: t('pricingCard.upgradeToBusiness'),
buttonVariant: 'secondary',
},
];
@@ -107,26 +114,26 @@ const PricingSection: React.FC = () => { // 重命名组件
<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">
{t('pricingSection.chooseYourPlan')}
</h1>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">
{t('pricingSection.forIndividualsOrTeams')}
</p>
</div>
<div className="mb-12">
<BillingToggle billingPeriod={billingPeriod} onToggle={setBillingPeriod} />
<BillingToggle billingPeriod={billingPeriod} onToggle={setBillingPeriod} lang={lang} />
</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} />
<PricingCard key={plan.name} plan={plan} lang={lang} />
))}
</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">
访
<a href={`/${lang}/pricing`} target="_blank" className="flex items-center justify-center text-neutral-600 hover:text-neutral-900 transition-colors duration-200">
{t('pricingSection.visitPricingPage')}
<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>

View File

@@ -1,9 +1,9 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
KeyIcon,
CogIcon,
import {
KeyIcon,
CogIcon,
GlobeAltIcon,
SpeakerWaveIcon,
CheckIcon,
@@ -12,6 +12,7 @@ import {
EyeSlashIcon
} from '@heroicons/react/24/outline';
import { getItem, setItem } from '@/lib/storage';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
// 存储键名
const SETTINGS_STORAGE_KEY = 'podcast-settings';
@@ -64,9 +65,11 @@ interface SettingsFormProps {
onSave?: (data: SettingsFormData) => void;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
lang: string; // 新增 lang 属性
}
export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFormProps) {
export default function SettingsForm({ onSave, onSuccess, onError, lang }: SettingsFormProps) {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const [formData, setFormData] = useState<SettingsFormData>({
apikey: '',
model: '',
@@ -178,15 +181,15 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
if (onSave) {
onSave(processedData);
}
onSuccess?.('设置保存成功!');
} catch (error) {
console.error('Error saving settings:', error);
onError?.('保存设置时出现错误,请重试');
} finally {
setIsLoading(false);
}
};
onSuccess?.(t('settingsForm.settingsSavedSuccessfully'));
} catch (error) {
console.error('Error saving settings:', error);
onError?.(t('settingsForm.errorSavingSettings'));
} finally {
setIsLoading(false);
}
};
const renderPasswordInput = (
label: string,
@@ -283,8 +286,8 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
return (
<div className="max-w-4xl mx-auto px-6">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold text-black mb-2"></h1>
<p className="text-neutral-600 break-words">API设置和TTS服务</p>
<h1 className="text-3xl font-bold text-black mb-2">{t('settingsForm.settings')}</h1>
<p className="text-neutral-600 break-words">{t('settingsForm.apiSettingsDescription')}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
@@ -294,21 +297,21 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="p-2 bg-neutral-100 rounded-lg">
<CogIcon className="h-5 w-5 text-neutral-600" />
</div>
<h2 className="text-xl font-semibold text-black"></h2>
<h2 className="text-xl font-semibold text-black">{t('settingsForm.generalSettings')}</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderPasswordInput(
'API Key',
t('settingsForm.apiKey'),
'apikey',
formData.apikey,
'输入您的OpenAI API Key',
t('settingsForm.inputYourOpenAIAPIKey'),
true
)}
<div className="space-y-2 relative">
<label className="block text-sm font-medium text-neutral-700">
{t('settingsForm.model')}
<span className="text-red-500 ml-1">*</span>
</label>
<div className="relative">
@@ -317,7 +320,7 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
value={formData.model}
onChange={(e) => handleInputChange('model', e.target.value)}
onFocus={() => setIsModelDropdownOpen(true)}
placeholder="选择或输入模型名称"
placeholder={t('settingsForm.selectOrEnterModelName')}
className="input-primary w-full pr-8"
required
/>
@@ -347,7 +350,7 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
}}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 transition-colors text-sm"
>
{model}
{model === '输入自定义模型' ? t('settingsForm.customModelInput') : model}
</button>
))}
</div>
@@ -364,10 +367,10 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
</div>
{renderTextInput(
'Base URL',
t('settingsForm.baseURL'),
'baseurl',
formData.baseurl,
'可选自定义API基础URL',
t('settingsForm.optionalCustomBaseURL'),
false, // required
'md:col-span-2' // wrapperClassName
)}
@@ -380,7 +383,7 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="p-2 bg-neutral-100 rounded-lg">
<SpeakerWaveIcon className="h-5 w-5 text-neutral-600" />
</div>
<h2 className="text-xl font-semibold text-black">TTS服务设置</h2>
<h2 className="text-xl font-semibold text-black">{t('settingsForm.ttsServiceSettings')}</h2>
</div>
<div className="space-y-8">
@@ -388,16 +391,16 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="space-y-4">
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
<GlobeAltIcon className="h-5 w-5 text-blue-500" />
API TTS
{t('settingsForm.webAPITTSServices')}
</h3>
<div className="grid grid-cols-1 gap-8">
{/* Edge TTS */}
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Edge TTS
{t('settingsForm.edgeTTS')}
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">EdgeTTS免费服务</p>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">{t('settingsForm.edgeTTSDescription')}</p>
{renderTextInput(
'API URL',
'edge.api_url',
@@ -410,21 +413,21 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Doubao TTS
{t('settingsForm.doubaoTTS')}
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">baseUrl=https://openspeech.bytedance.com/api/v3/tts/unidirectional</p>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">{t('settingsForm.doubaoTTSDescription')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderTextInput(
'App ID',
t('settingsForm.appID'),
'doubao.X-Api-App-Id',
formData.doubao['X-Api-App-Id'],
'输入Doubao App ID'
t('settingsForm.inputDoubaoAppID')
)}
{renderPasswordInput(
'Access Key',
t('settingsForm.accessKey'),
'doubao.X-Api-Access-Key',
formData.doubao['X-Api-Access-Key'],
'输入Doubao Access Key'
t('settingsForm.inputDoubaoAccessKey')
)}
</div>
</div>
@@ -433,21 +436,21 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Minimax TTS
{t('settingsForm.minimaxTTS')}
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">Minimax提供支持的语音合成服务baseUrl=https://api.minimaxi.com/v1/t2a_v2</p>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">{t('settingsForm.minimaxTTSDescription')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderTextInput(
'Group ID',
t('settingsForm.groupID'),
'minimax.group_id',
formData.minimax.group_id,
'输入Minimax Group ID'
t('settingsForm.inputMinimaxGroupID')
)}
{renderPasswordInput(
'API Key',
t('settingsForm.apiKey'),
'minimax.api_key',
formData.minimax.api_key,
'输入Minimax API Key'
t('settingsForm.inputMinimaxAPIKey')
)}
</div>
</div>
@@ -456,15 +459,15 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Fish TTS
{t('settingsForm.fishTTS')}
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">FishAudio提供支持的语音合成服务baseUrl=https://api.fish.audio/v1/tts</p>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">{t('settingsForm.fishTTSDescription')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderPasswordInput(
'API Key',
t('settingsForm.apiKey'),
'fish.api_key',
formData.fish.api_key,
'输入Fish TTS API Key'
t('settingsForm.inputFishTTSAPIKey')
)}
</div>
</div>
@@ -473,15 +476,15 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="space-y-4 border-l-4 border-blue-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Gemini TTS
{t('settingsForm.geminiTTS')}
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">Google Gemini提供支持的语音合成服务baseUrl=https://generativelanguage.googleapis.com/v1beta/models</p>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">{t('settingsForm.geminiTTSDescription')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{renderPasswordInput(
'API Key',
t('settingsForm.apiKey'),
'gemini.api_key',
formData.gemini.api_key,
'输入Gemini API Key'
t('settingsForm.inputGeminiAPIKey')
)}
</div>
</div>
@@ -493,16 +496,16 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="space-y-4 mt-8">
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
<SpeakerWaveIcon className="h-5 w-5 text-purple-500" />
API TTS
{t('settingsForm.localAPITTSServices')}
</h3>
<div className="grid grid-cols-1 gap-8">
{/* Index TTS */}
<div className="space-y-4 border-l-4 border-purple-200 pl-4">
<h4 className="text-md font-medium text-black flex items-center gap-2">
<SpeakerWaveIcon className="h-4 w-4 text-neutral-500" />
Index TTS
{t('settingsForm.indexTTS')}
</h4>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">IndexTTS服务</p>
<p className="text-sm text-neutral-500 mt-1 mb-2 break-words">{t('settingsForm.indexTTSDescription')}</p>
{renderTextInput(
'API URL',
'index.api_url',
@@ -522,7 +525,7 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
onClick={handleReset}
className="px-6 py-3 border border-neutral-300 text-neutral-700 rounded-full font-medium transition-all duration-200 hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2"
>
{t('settingsForm.reset')}
</button>
<button
type="submit"
@@ -532,11 +535,11 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
...
{t('settingsForm.saving')}
</>
) : (
<>
{t('settingsForm.saveSettings')}
</>
)}
</button>
@@ -548,12 +551,12 @@ export default function SettingsForm({ onSave, onSuccess, onError }: SettingsFor
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-amber-800"></h3>
<h3 className="text-sm font-medium text-amber-800">{t('settingsForm.configurationNotes')}</h3>
<ul className="text-sm text-amber-700 space-y-1 break-words">
<li> API Key OpenAI服务生成播客脚本</li>
<li> TTS服务配置为可选项</li>
<li> null </li>
<li> </li>
<li> {t('settingsForm.apiKeyRequired')}</li>
<li> {t('settingsForm.ttsOptional')}</li>
<li> {t('settingsForm.emptyFieldsNull')}</li>
<li> {t('settingsForm.settingsApplyImmediately')}</li>
</ul>
</div>
</div>

View File

@@ -4,12 +4,15 @@ import React from 'react';
import { AiOutlineShareAlt } from 'react-icons/ai';
import { useToast, ToastContainer} from './Toast'; // 确保路径正确
import { usePathname } from 'next/navigation'; // next/navigation 用于获取当前路径
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface ShareButtonProps {
className?: string; // 允许外部传入样式
lang: string; // 新增 lang 属性
}
const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
const ShareButton: React.FC<ShareButtonProps> = ({ className, lang }) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const { toasts, success, error, removeToast } = useToast();
const pathname = usePathname(); // 获取当前路由路径
@@ -18,12 +21,12 @@ const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
try {
const currentUrl = window.location.origin + pathname; // 构建完整的当前页面 URL
await navigator.clipboard.writeText(currentUrl);
success('复制成功', '页面链接已复制到剪贴板!');
console.log('页面链接已复制:', currentUrl); // 添加成功日志
success(t('shareButton.copySuccess'), t('shareButton.pageLinkCopied'));
console.log(`${t('shareButton.pageLinkCopied')}:`, currentUrl); // 添加成功日志
} catch (err) {
console.error('复制失败:', err); // 保留原有错误日志
error('复制失败', '无法复制页面链接到剪贴板。');
console.error('无法复制页面链接到剪贴板,错误信息:', err); // 添加详细错误日志
console.error(`${t('shareButton.copyFailed')}:`, err); // 保留原有错误日志
error(t('shareButton.copyFailed'), t('shareButton.cannotCopyPageLink'));
console.error(`${t('shareButton.cannotCopyPageLink')}, ${t('shareButton.errorInfo')}:`, err); // 添加详细错误日志
}
};
@@ -32,7 +35,7 @@ const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
<button
onClick={handleShare}
className={`text-neutral-500 hover:text-black transition-colors text-sm ${className}`}
aria-label="分享页面"
aria-label={t('shareButton.sharePage')}
>
<AiOutlineShareAlt className="w-5 h-5" />
</button>

View File

@@ -20,6 +20,7 @@ import { getSessionData } from '@/lib/server-actions';
import { cn } from '@/lib/utils';
import LoginModal from './LoginModal'; // 导入 LoginModal 组件
import type { PodcastItem } from '@/types';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
interface SidebarProps {
@@ -31,6 +32,7 @@ interface SidebarProps {
credits: number; // 添加 credits 属性
onPodcastExplore: (podcasts: PodcastItem[]) => void; // 添加刷新播客函数
onCreditsChange: (newCredits: number) => void; // 添加 onCreditsChange 回调函数
lang: string; // 新增 lang 属性
}
interface NavItem {
@@ -49,7 +51,9 @@ const Sidebar: React.FC<SidebarProps> = ({
credits, // 解构 credits 属性
onPodcastExplore, // 解构刷新播客函数
onCreditsChange, // 解构 onCreditsChange 属性
lang // 解构 lang 属性
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示状态
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); // 控制注销确认模态框的显示状态
const [session, setSession] = useState<any>(null); // 使用 useState 管理 session
@@ -74,7 +78,7 @@ const Sidebar: React.FC<SidebarProps> = ({
const currentTime = new Date().getTime();
if (currentTime > expirationTime) {
console.log('Session expired, logging out...');
console.log(t('sidebar.sessionExpired'));
signOut({
fetchOptions: {
onSuccess: () => {
@@ -86,29 +90,29 @@ const Sidebar: React.FC<SidebarProps> = ({
});
}
}
}, [session, router, onCreditsChange]); // 监听 session 变化和 router因为 signOut 中使用了 router.push并添加 onCreditsChange
}, [session, router, onCreditsChange, t]); // 监听 session 变化和 router因为 signOut 中使用了 router.push并添加 onCreditsChange
// todo
const mainNavItems: NavItem[] = [
{ id: 'home', label: '首页', icon: AiOutlineHome },
{ id: 'home', label: t('sidebar.home'), icon: AiOutlineHome },
// 隐藏资料库和探索
// { id: 'library', label: '资料库', icon: Library },
// { id: 'explore', label: '探索', icon: Compass },
// { id: 'library', label: t('sidebar.library'), icon: Library },
// { id: 'explore', label: t('sidebar.explore'), icon: Compass },
];
const bottomNavItems: NavItem[] = [
// 隐藏定价和积分
// { id: 'pricing', label: '定价', icon: DollarSign },
{ id: 'credits', label: '积分', icon: AiOutlineMoneyCollect, badge: credits.toString() }, // 动态设置 badge
...(enableTTSConfigPage ? [{ id: 'settings', label: 'TTS设置', icon: AiOutlineSetting }] : [])
// { id: 'pricing', label: t('sidebar.pricing'), icon: DollarSign },
{ id: 'credits', label: t('sidebar.points'), icon: AiOutlineMoneyCollect, badge: credits.toString() }, // 动态设置 badge
...(enableTTSConfigPage ? [{ id: 'settings', label: t('sidebar.ttsSettings'), icon: AiOutlineSetting }] : [])
];
const socialLinks = [
{ 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' },
{ icon: AiOutlineGithub, href: 'https://github.com/justlovemaki', label: t('sidebar.github') },
{ icon: AiOutlineTwitter, href: 'https://x.com/justlikemaki', label: t('sidebar.twitter') },
{ icon: AiOutlineTikTok, href: 'https://cdn.jsdmirror.com/gh/justlovemaki/imagehub@main/logo/7fc30805eeb831e1e2baa3a240683ca3.md.png', label: t('sidebar.tiktok') },
{ icon: AiOutlineMail, href: 'mailto:justlikemaki@foxmail.com', label: t('sidebar.email') },
];
return (
@@ -125,10 +129,10 @@ const Sidebar: React.FC<SidebarProps> = ({
{collapsed ? (
/* 收起状态 - 只显示展开按钮 */
<div className="w-full flex justify-center">
<button
<button
onClick={onToggleCollapse}
className="w-8 h-8 gradient-brand rounded-lg flex items-center justify-center hover:opacity-80 transition-opacity"
title="展开侧边栏"
title={t('sidebar.expandSidebar')}
>
<AiOutlineMenuUnfold className="w-4 h-4 text-white" />
</button>
@@ -160,10 +164,10 @@ const Sidebar: React.FC<SidebarProps> = ({
{/* 收起按钮 */}
<div className="flex-shrink-0 ml-auto">
<button
<button
onClick={onToggleCollapse}
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="收起侧边栏"
title={t('sidebar.collapseSidebar')}
>
<AiOutlineMenuFold className="w-4 h-4 text-neutral-500" />
</button>
@@ -303,13 +307,13 @@ const Sidebar: React.FC<SidebarProps> = ({
collapsed ? "w-8 h-8" : "w-10 h-10",
!collapsed && "hover:opacity-80 transition-opacity" // 展开时添加悬停效果
)}
title={collapsed ? (session.user.name || session.user.email || '用户') : "点击头像注销"}
title={collapsed ? (session.user.name || session.user.email || t('sidebar.user')) : t('sidebar.clickAvatarToLogout')}
>
{session.user.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={session.user.image}
alt="User Avatar"
alt={t('sidebar.userAvatar')}
className="w-full h-full object-cover"
/>
) : (
@@ -328,7 +332,7 @@ const Sidebar: React.FC<SidebarProps> = ({
"whitespace-nowrap transition-all duration-500 ease-in-out transform-gpu",
collapsed ? "opacity-0 scale-x-0" : "opacity-100 scale-x-100 text-neutral-800 font-medium" // 收缩时文字也隐藏
)}>
{session.user.name || session.user.email || '用户'}
{session.user.name || session.user.email || t('sidebar.user')}
</span>
</div>
</div>
@@ -340,7 +344,7 @@ const Sidebar: React.FC<SidebarProps> = ({
"flex items-center rounded-lg transition-all duration-200",
collapsed ? "justify-center w-8 h-8 px-0 text-neutral-600 hover:text-black hover:bg-neutral-50" : "justify-center w-[95%] mx-auto py-2 bg-black text-white hover:opacity-80"
)}
title={collapsed ? "登录" : undefined}
title={collapsed ? t('sidebar.login') : undefined}
>
<AiOutlineLogin className="w-5 h-5 flex-shrink-0" />
<div className={cn(
@@ -350,7 +354,7 @@ const Sidebar: React.FC<SidebarProps> = ({
<span className={cn(
"text-sm whitespace-nowrap transition-all duration-500 ease-in-out transform-gpu",
collapsed ? "opacity-0 scale-x-0" : "opacity-100 scale-x-100"
)}></span>
)}>{t('sidebar.login')}</span>
</div>
</button>
)}
@@ -359,13 +363,13 @@ const Sidebar: React.FC<SidebarProps> = ({
{showLogoutConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl text-center">
<p className="mb-4 text-lg font-semibold"></p>
<p className="mb-4 text-lg font-semibold">{t('sidebar.areYouSureToLogout')}</p>
<div className="flex justify-center gap-4">
<button
onClick={() => setShowLogoutConfirm(false)}
className="px-4 py-2 rounded-lg bg-neutral-200 text-neutral-800 hover:bg-neutral-300 transition-colors"
>
{t('sidebar.cancel')}
</button>
<button
onClick={() => {
@@ -383,7 +387,7 @@ const Sidebar: React.FC<SidebarProps> = ({
}}
className="px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors"
>
{t('sidebar.confirmLogout')}
</button>
</div>
</div>
@@ -392,7 +396,7 @@ const Sidebar: React.FC<SidebarProps> = ({
</div>
{/* 登录模态框 */}
<LoginModal isOpen={showLoginModal} onClose={() => setShowLoginModal(false)} />
<LoginModal isOpen={showLoginModal} onClose={() => setShowLoginModal(false)} lang={lang} />
</div>
);
};

View File

@@ -153,16 +153,16 @@ export const useToast = () => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const success = (title: string, message?: string) =>
const success = (title: string, message?: string) =>
addToast({ type: 'success', title, message });
const error = (title: string, message?: string) =>
const error = (title: string, message?: string) =>
addToast({ type: 'error', title, message });
const warning = (title: string, message?: string) =>
const warning = (title: string, message?: string) =>
addToast({ type: 'warning', title, message });
const info = (title: string, message?: string) =>
const info = (title: string, message?: string) =>
addToast({ type: 'info', title, message });
return {

View File

@@ -3,6 +3,7 @@ import type { Voice } from '@/types';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { PlayIcon, PauseIcon } from '@heroicons/react/24/solid';
import { AiOutlineClose } from 'react-icons/ai';
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface VoicesModalProps {
isOpen: boolean;
@@ -12,9 +13,11 @@ interface VoicesModalProps {
initialSelectedVoices: Voice[];
currentSelectedVoiceIds: string[];
onRemoveVoice: (voiceCode: string) => void;
lang: string;
}
const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSelectVoices, initialSelectedVoices, currentSelectedVoiceIds, onRemoveVoice }) => {
const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSelectVoices, initialSelectedVoices, currentSelectedVoiceIds, onRemoveVoice, lang }) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const [inputValue, setInputValue] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const [genderFilter, setGenderFilter] = useState('');
@@ -50,8 +53,19 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
setInputValue('');
setDebouncedSearchTerm('');
setSelectedLocalVoices(initialSelectedVoices);
// 根据 lang 属性设置默认语言筛选
if (lang === 'zh-CN') {
setLanguageFilter('zh');
} else if (lang === 'en') {
setLanguageFilter('en');
} else if (lang === 'ja') {
setLanguageFilter('ja');
} else {
setLanguageFilter(''); // 其他语言默认不筛选
}
}
}, [isOpen, initialSelectedVoices]);
}, [isOpen, initialSelectedVoices, lang]);
const filteredVoices = useMemo(() => {
// 【修复】使用去重后的 uniqueVoices 数组进行过滤
@@ -74,7 +88,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
if (languageFilter) {
currentFilteredVoices = currentFilteredVoices.filter(voice =>
voice.locale && voice.locale.toLowerCase().includes(languageFilter.toLowerCase())
voice.locale && voice.locale.toLowerCase().startsWith(languageFilter.toLowerCase())
);
}
@@ -88,48 +102,48 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
<div className="bg-white border border-neutral-200 rounded-2xl p-6 md:w-[40vw] md:h-[70vh] w-[80%] h-[90%] relative flex flex-col shadow-large">
{/* Header and Filters */}
<div className="flex items-center mb-4 pr-10 flex-wrap gap-2 justify-end">
<div className="min-w-[220px] mr-auto"><h2 className="text-2xl font-bold text-black"> ({selectedLocalVoices.length}/{uniqueVoices.length})</h2></div>
<div className="min-w-[220px] mr-auto"><h2 className="text-2xl font-bold text-black">{t('voicesModal.selectSpeaker')} ({selectedLocalVoices.length}/{uniqueVoices.length})</h2></div>
{/* Filter buttons... */}
<button
onClick={() => { setGenderFilter(''); setLanguageFilter(''); }}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${genderFilter === '' && languageFilter === '' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
{t('voicesModal.all')}
</button>
<button
onClick={() => setGenderFilter('Male')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${genderFilter === 'Male' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
{t('voicesModal.male')}
</button>
<button
onClick={() => setGenderFilter('Female')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${genderFilter === 'Female' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
{t('voicesModal.female')}
</button>
<button
onClick={() => setLanguageFilter('zh')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${languageFilter === 'zh' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
(zh)
{t('voicesModal.chinese')}
</button>
<button
onClick={() => setLanguageFilter('en')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${languageFilter === 'en' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
(en)
{t('voicesModal.english')}
</button>
<button
onClick={() => setLanguageFilter('ja')}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${languageFilter === 'ja' ? 'bg-gradient-to-r from-brand-purple to-brand-pink text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'}`}
>
(ja)
{t('voicesModal.japanese')}
</button>
<button
onClick={onClose}
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="关闭"
aria-label={t('voicesModal.close')}
>
<AiOutlineClose className="w-5 h-5" />
</button>
@@ -137,11 +151,11 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
{/* Search Input */}
<div className="relative flex-shrink-0 mb-4">
<label htmlFor="voice-search" className="sr-only"></label>
<label htmlFor="voice-search" className="sr-only">{t('voicesModal.searchVoices')}</label>
<input
id="voice-search"
className="peer block w-full rounded-lg border border-neutral-200 py-3 pl-10 text-sm outline-none focus:border-black focus:ring-2 focus:ring-black/10 placeholder:text-neutral-500 transition-all duration-200"
placeholder="搜索声音..."
placeholder={t('voicesModal.searchVoicesPlaceholder')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
@@ -167,7 +181,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
if (selectedLocalVoices.length < 5) {
setSelectedLocalVoices(prev => [...prev, voice]);
} else {
alert('最多只能选择5个说话人。');
alert(t('voicesModal.maxVoicesAlert'));
}
}
}}
@@ -211,13 +225,13 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
)}
<div>
<h3 className="font-semibold text-lg break-words line-clamp-2 text-black">{voice.alias || voice.name}</h3>
<p className="text-sm text-neutral-600 break-words">: {voice.locale || '未知'}</p>
<p className="text-sm text-neutral-600 break-words">{t('voicesModal.language')}: {voice.locale || t('voicesModal.unknown')}</p>
</div>
</div>
</div>
))
) : (
<p className="col-span-full text-center text-neutral-500"></p>
<p className="col-span-full text-center text-neutral-500">{t('voicesModal.noMatchingVoices')}</p>
)}
</div>
@@ -238,7 +252,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
>
{index === 0 ? (
<div className="text-center leading-tight">
<div></div>
<div>{t('voicesModal.presenter')}</div>
<div className="text-xs">{voice.alias || voice.name}</div>
</div>
) : (
@@ -252,7 +266,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="删除"
aria-label={t('voicesModal.delete')}
>
<AiOutlineClose className="w-5 h-5" />
</button>
@@ -263,7 +277,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
<button
onClick={() => {
if (selectedLocalVoices.length > 5) {
alert('最多只能选择5个说话人。');
alert(t('voicesModal.maxVoicesAlert'));
return;
}
onSelectVoices(selectedLocalVoices);
@@ -271,7 +285,7 @@ const VoicesModal: React.FC<VoicesModalProps> = ({ isOpen, onClose, voices, onSe
}}
className="px-6 py-3 bg-gradient-to-r text-xs sm:text-lg from-brand-purple to-brand-pink text-white rounded-full font-medium hover:from-brand-purple-hover hover:to-brand-pink focus:outline-none focus:ring-2 focus:ring-brand-purple/20 transition-all duration-200 shadow-medium hover:shadow-large"
>
({selectedLocalVoices.length})
{t('voicesModal.confirmSelection')} ({selectedLocalVoices.length})
</button>
</div>
</div>

26
web/src/i18n/client.ts Normal file
View File

@@ -0,0 +1,26 @@
'use client'
import i18next, { i18n } from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { getOptions } from './settings'
// on client side the normal singleton is ok
i18next
.use(initReactI18next)
.use(resourcesToBackend((language: string, namespace: string) => import(`../../public/locales/${language}/${namespace}.json`)))
.init({
...getOptions(),
lng: undefined, // let detect the language on client side
ns: ['common', 'layout', 'home'], // 指定客户端需要加载的所有命名空间
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator'],
}
})
export function useTranslation(lng: string, ns?: string | string[], options?: {}) {
if (i18next.resolvedLanguage !== lng) {
i18next.changeLanguage(lng)
}
return useTranslationOrg(ns, options)
}

22
web/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createInstance, i18n } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'
const initI18next = async (lng: string, ns: string | string[] | undefined): Promise<i18n> => {
// on server side we create a new instance for each render, because during compilation everything seems to be executed in parallel
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
.use(resourcesToBackend((language: string, namespace: string) => import(`../../public/locales/${language}/${namespace}.json`)))
.init(getOptions(lng, ns))
return i18nInstance
}
export async function useTranslation(lng: string, ns: string | string[] | undefined = 'common', options: { keyPrefix?: string } = {}) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
i18n: i18nextInstance
}
}

16
web/src/i18n/settings.ts Normal file
View File

@@ -0,0 +1,16 @@
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'zh-CN']
export const defaultNS = 'common'
export const ns = ['common', 'layout', 'home', 'components', 'errors']
export function getOptions (lng = fallbackLng, _ns: string | string[] = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns: _ns
}
}

View File

@@ -8,7 +8,7 @@ let cacheTimestamp: number = 0;
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟单位毫秒
// 获取 tts_providers.json 文件内容
export async function fetchAndCacheProvidersLocal() {
export async function fetchAndCacheProvidersLocal(lang: string) {
try {
const now = Date.now();

View File

@@ -12,10 +12,14 @@ let ttsProvidersPromise: Promise<any> | null = null;
* 通过缓存Promise来防止并发请求并缓存成功的结果。
* @returns {Promise<any>} 返回包含配置信息的对象如果失败则返回null。
*/
const fetchAndCacheProviders = (): Promise<any> => {
const fetchAndCacheProviders = (lang: string): Promise<any> => {
return (async () => {
try {
const response = await fetch('/api/tts-providers');
const response = await fetch('/api/tts-providers', {
headers: {
'x-next-locale': lang,
},
});
if (!response.ok) {
console.error('Failed to fetch tts-providers, status:', response.status);
ttsProvidersPromise = null; // 失败时重置,以便重试
@@ -35,13 +39,13 @@ const fetchAndCacheProviders = (): Promise<any> => {
})();
};
export const getTTSProviders = async (): Promise<any> => {
export const getTTSProviders = async (lang: string): Promise<any> => {
if (enableTTSConfigPage) {
return getItem<any>(SETTINGS_STORAGE_KEY);
} else {
// 1. 如果没有并发请求,则发起新请求
if (!ttsProvidersPromise) {
ttsProvidersPromise = fetchAndCacheProviders();
ttsProvidersPromise = fetchAndCacheProviders(lang);
}
// 2. 返回 Promise后续调用将复用此 Promise 直到其解决

View File

@@ -9,7 +9,7 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_PODCAST_API_BASE_URL || 'http://192
/**
* 启动播客生成任务
*/
export async function startPodcastGenerationTask(body: PodcastGenerationRequest, userId: string): Promise<ApiResponse<PodcastGenerationResponse>> {
export async function startPodcastGenerationTask(body: PodcastGenerationRequest, userId: string, lang: string): Promise<ApiResponse<PodcastGenerationResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/generate-podcast`, {
@@ -41,7 +41,7 @@ export async function startPodcastGenerationTask(body: PodcastGenerationRequest,
/**
* 获取播客生成任务状态
*/
export async function getPodcastStatus(userId: string): Promise<ApiResponse<PodcastStatusResponse>> {
export async function getPodcastStatus(userId: string, lang: string): Promise<ApiResponse<PodcastStatusResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/podcast-status`, {
method: 'GET',
@@ -76,7 +76,7 @@ export async function getPodcastStatus(userId: string): Promise<ApiResponse<Podc
* @param req NextRequest 对象,包含请求信息,如查询参数 file_name。
* @returns 返回从 FastAPI 后端获取的音频信息,或错误响应。
*/
export async function getAudioInfo(fileName: string) {
export async function getAudioInfo(fileName: string, lang: string) {
if (!fileName) {
return { success: false, error: '缺少 file_name 查询参数', statusCode: 400 };
@@ -105,7 +105,7 @@ export async function getAudioInfo(fileName: string) {
* @param userId 用户ID
* @returns Promise<ApiResponse<typeof schema.user.$inferSelect | null>> 返回用户信息或null
*/
export async function getUserInfo(userId: string): Promise<ApiResponse<typeof schema.user.$inferSelect | null>> {
export async function getUserInfo(userId: string, lang: string): Promise<ApiResponse<typeof schema.user.$inferSelect | null>> {
try {
const userInfo = await db
.select()

View File

@@ -1,4 +1,5 @@
import { type ClassValue, clsx } from 'clsx';
import { NextRequest } from 'next/server';
import { twMerge } from 'tailwind-merge';
/**
@@ -157,4 +158,33 @@ export async function copyToClipboard(text: string): Promise<boolean> {
document.body.removeChild(textArea);
return success;
}
}
import { fallbackLng, languages } from '../i18n/settings';
/**
* 从 NextRequest 中获取语言
* 检查 'lang' 查询参数或 'Accept-Language' 请求头
*/
export function getLanguageFromRequest(request: NextRequest): string {
// 优先使用 URL 查询参数中的 'lang'
const langParam = request.nextUrl.searchParams.get('lang');
if (langParam && languages.includes(langParam)) {
return langParam;
}
// 否则,尝试从 'Accept-Language' 请求头中获取
const acceptLanguage = request.headers.get('x-next-locale');
if (acceptLanguage) {
const detectedLng = acceptLanguage
.split(',')
.map(l => l.split(';')[0])
.find(l => languages.includes(l));
if (detectedLng) {
return detectedLng;
}
}
console.log(fallbackLng)
// 如果未找到支持的语言,则返回默认语言
return fallbackLng;
}

28
web/src/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { fallbackLng, languages } from './i18n/settings';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 检查路径是否已经包含语言标识
const pathnameIsMissingLocale = languages.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// 如果路径缺少语言标识,则重定向到默认语言
if (pathnameIsMissingLocale) {
// e.g. incoming request is /products
// The new URL is now /en-US/products
return NextResponse.rewrite(
new URL(`/${fallbackLng}${pathname}`, request.url)
);
}
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|favicon.webp).*)'],
};