feat: 添加日语支持并优化国际化功能
refactor: 重构中间件和路由处理逻辑 fix: 修复音频示例API的错误处理 docs: 更新README和DOCKER_USAGE文档 style: 优化语言切换器样式 chore: 更新.gitignore添加生产环境配置文件
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ __pycache__/
|
||||
output/
|
||||
excalidraw.log
|
||||
config/tts_providers-local.json
|
||||
config/tts_providers-prod.json
|
||||
.claude
|
||||
.serena
|
||||
/node_modules
|
||||
@@ -47,6 +47,7 @@ docker run -d -p 3200:3000 -v /opt/audio:/app/server/output --restart always --n
|
||||
* `-d`:在分离模式(detached mode)下运行容器,即在后台运行。
|
||||
* `-p 3200:3000`:将宿主机的 3200 端口映射到容器的 3000 端口。Next.js 应用程序在容器内部的 3000 端口上运行。
|
||||
* `-v /opt/audio:/app/server/output`:将宿主机的 `/opt/audio` 目录挂载到容器内的 `/app/server/output` 目录,用于音频文件的持久化存储。
|
||||
* `-v /opt/sqlite.db:/app/web/sqlite.db`:将宿主机的 `/opt/sqlite.db` 文件挂载到容器内的 `/app/web/sqlite.db` 文件,用于数据库的持久化存储。
|
||||
* `--restart always`:设置容器的重启策略,确保容器在意外停止或系统重启后能自动重启。
|
||||
* `--name podcast-web`:为运行中的容器指定一个名称,方便后续管理。
|
||||
* `simple-podcast-web`:指定要运行的 Docker 镜像名称。
|
||||
@@ -54,7 +55,7 @@ docker run -d -p 3200:3000 -v /opt/audio:/app/server/output --restart always --n
|
||||
#### 运行 Server 应用容器
|
||||
|
||||
```bash
|
||||
docker run -d -p 3100:8000 -v /opt/audio:/app/server/output --restart always --name podcast-server simple-podcast-server
|
||||
docker run -d -p 3100:8000 -v /opt/audio:/app/server/output -v /opt/sqlite.db:/app/web/sqlite.db --restart always --name podcast-server simple-podcast-server
|
||||
```
|
||||
|
||||
或者,如果您的应用程序需要配置环境变量(例如 `PODCAST_API_SECRET_KEY`),您可以使用 `-e` 参数进行设置:
|
||||
|
||||
11
README.md
11
README.md
@@ -197,27 +197,28 @@ curl -X POST "http://localhost:8000/generate-podcast" \
|
||||
|
||||
## 🌍 国际化 (i18n) 支持
|
||||
|
||||
本项目支持多语言界面,目前支持中文 (zh-CN) 和英文 (en)。
|
||||
本项目支持多语言界面,目前支持英文 (en)、中文 (zh-CN) 和日文 (ja)。
|
||||
|
||||
### 📁 语言文件结构
|
||||
|
||||
语言文件位于 `web/public/locales/` 目录下,按照语言代码分组:
|
||||
- `web/public/locales/zh-CN/common.json` - 中文翻译
|
||||
- `web/public/locales/en/common.json` - 英文翻译
|
||||
- `web/public/locales/zh-CN/common.json` - 中文翻译
|
||||
- `web/public/locales/ja/common.json` - 日文翻译
|
||||
|
||||
### 🛠️ 添加新语言
|
||||
|
||||
1. 在 `web/public/locales/` 目录下创建新的语言文件夹,例如 `fr/`
|
||||
2. 复制 `common.json` 文件到新文件夹中
|
||||
3. 翻译文件中的所有键值对
|
||||
4. 在 `web/next-i18next.config.js` 文件中添加新的语言代码到 `locales` 数组
|
||||
5. 在 `web/src/i18n.ts` 文件中更新 `languages` 变量
|
||||
4. 在 `web/src/i18n/settings.ts` 文件中更新 `languages` 变量
|
||||
|
||||
### 🌐 语言切换
|
||||
|
||||
用户可以通过 URL 路径或浏览器语言设置自动切换语言:
|
||||
- `http://localhost:3000/zh-CN/` - 中文界面
|
||||
- `http://localhost:3000/en/` - 英文界面
|
||||
- `http://localhost:3000/zh-CN/` - 中文界面
|
||||
- `http://localhost:3000/ja/` - 日文界面
|
||||
|
||||
---
|
||||
|
||||
|
||||
27
README_EN.md
27
README_EN.md
@@ -194,6 +194,33 @@ This project supports deployment via Docker. For detailed information, please re
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Internationalization (i18n) Support
|
||||
|
||||
This project supports multilingual interfaces, currently supporting English (en), Chinese (zh-CN), and Japanese (ja).
|
||||
|
||||
### 📁 Language File Structure
|
||||
|
||||
Language files are located in the `web/public/locales/` directory, grouped by language code:
|
||||
- `web/public/locales/en/common.json` - English translation
|
||||
- `web/public/locales/zh-CN/common.json` - Chinese translation
|
||||
- `web/public/locales/ja/common.json` - Japanese translation
|
||||
|
||||
### 🛠️ Adding New Languages
|
||||
|
||||
1. Create a new language folder in the `web/public/locales/` directory, for example `fr/`
|
||||
2. Copy the `common.json` file to the new folder
|
||||
3. Translate all key-value pairs in the file
|
||||
4. Update the `languages` variable in the `web/src/i18n/settings.ts` file
|
||||
|
||||
### 🌐 Language Switching
|
||||
|
||||
Users can automatically switch languages through the URL path or browser language settings:
|
||||
- `http://localhost:3000/en/` - English interface
|
||||
- `http://localhost:3000/zh-CN/` - Chinese interface
|
||||
- `http://localhost:3000/ja/` - Japanese interface
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration File Details
|
||||
|
||||
### `config/[tts-provider].json` (TTS Character and Voice Configuration)
|
||||
|
||||
@@ -9,7 +9,8 @@ services:
|
||||
ports:
|
||||
- "3200:3000"
|
||||
volumes:
|
||||
- audio-data:/app/server/output
|
||||
- audio-data/output:/app/server/output
|
||||
- audio-data/sqlite.db:/app/web/sqlite.db
|
||||
restart: always
|
||||
depends_on:
|
||||
- server
|
||||
@@ -24,7 +25,7 @@ services:
|
||||
ports:
|
||||
- "3100:8000"
|
||||
volumes:
|
||||
- audio-data:/app/server/output
|
||||
- audio-data/output:/app/server/output
|
||||
restart: always
|
||||
environment:
|
||||
- PODCAST_API_SECRET_KEY=your-production-api-secret-key
|
||||
|
||||
@@ -9,7 +9,9 @@ const nextConfig = {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
output: 'standalone',
|
||||
devIndicators: false,
|
||||
devIndicators: {
|
||||
position: 'top-right', // 将挂件移动到右下角
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -42,7 +42,8 @@
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"chinese": "中文",
|
||||
"english": "English"
|
||||
"english": "English",
|
||||
"japanese": "日本語"
|
||||
},
|
||||
"loginModal": {
|
||||
"loginToYourAccount": "Login to Your Account",
|
||||
@@ -66,7 +67,7 @@
|
||||
"noOutlineContent": "No outline content."
|
||||
},
|
||||
"podcastCreator": {
|
||||
"giveVoiceToCreativity": "Give voice to creativity",
|
||||
"giveVoiceToCreativity": "Lend creativity an authentic voice",
|
||||
"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",
|
||||
@@ -222,11 +223,12 @@
|
||||
"areYouSureToLogout": "Are you sure you want to log out?",
|
||||
"cancel": "Cancel",
|
||||
"confirmLogout": "Logout",
|
||||
"sessionExpired": "Session expired, logging out...",
|
||||
"sessionExpired": "Session expired, logging out",
|
||||
"user": "User",
|
||||
"clickAvatarToLogout": "Click avatar to logout",
|
||||
"lessThanSMSizeCannotExpand": "Cannot expand on screens smaller than sm size",
|
||||
"showMore": "Show more"
|
||||
"showMore": "Show more",
|
||||
"generalSettings": "General Settings"
|
||||
},
|
||||
"toast": {
|
||||
"title": "Notification",
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
,
|
||||
"invalid_pagination_parameters": "Invalid pagination parameters"
|
||||
,
|
||||
"cannot_read_tts_provider_config": "Cannot read TTS provider configuration file"
|
||||
"cannot_read_tts_provider_config": "Cannot read TTS provider configuration file",
|
||||
"invalid_provider": "Invalid TTS provider"
|
||||
}
|
||||
@@ -42,7 +42,8 @@
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"chinese": "中文",
|
||||
"english": "英語"
|
||||
"english": "英語",
|
||||
"japanese": "日本語"
|
||||
},
|
||||
"loginModal": {
|
||||
"loginToYourAccount": "アカウントにログイン",
|
||||
@@ -228,7 +229,8 @@
|
||||
"user": "ユーザー",
|
||||
"clickAvatarToLogout": "アバターをクリックしてログアウト",
|
||||
"lessThanSMSizeCannotExpand": "smサイズ未満の画面では展開できません",
|
||||
"showMore": "もっと見る"
|
||||
"showMore": "もっと見る",
|
||||
"generalSettings": "一般設定"
|
||||
},
|
||||
"toast": {
|
||||
"title": "通知",
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
},
|
||||
"languageSwitcher": {
|
||||
"chinese": "中文",
|
||||
"english": "英文"
|
||||
"english": "英文",
|
||||
"japanese": "日文"
|
||||
},
|
||||
"loginModal": {
|
||||
"loginToYourAccount": "登录您的账户",
|
||||
@@ -226,7 +227,8 @@
|
||||
"user": "用户",
|
||||
"clickAvatarToLogout": "点击头像注销",
|
||||
"lessThanSMSizeCannotExpand": "小于sm尺寸不可展开",
|
||||
"showMore": "显示更多"
|
||||
"showMore": "显示更多",
|
||||
"generalSettings": "通用设置"
|
||||
},
|
||||
"toast": {
|
||||
"title": "通知",
|
||||
|
||||
@@ -45,5 +45,6 @@
|
||||
,
|
||||
"invalid_pagination_parameters": "无效的分页参数"
|
||||
,
|
||||
"cannot_read_tts_provider_config": "无法读取TTS提供商配置文件"
|
||||
"cannot_read_tts_provider_config": "无法读取TTS提供商配置文件",
|
||||
"invalid_provider": "无效的TTS提供商"
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import React, { use } from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiFillMail } from 'react-icons/ai';
|
||||
import { headers } from 'next/headers';
|
||||
import { getTruePathFromHeaders } from '../../../lib/utils';
|
||||
|
||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'contact');
|
||||
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||
return {
|
||||
title: t('contact_us_title'),
|
||||
description: t('contact_us_description'),
|
||||
alternates: {
|
||||
canonical: `/${lang}/contact`,
|
||||
canonical: `${truePath}/contact`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,7 +24,8 @@ import { useTranslation } from '../../../i18n'; // 导入服务端的 useTransla
|
||||
* 优化后的版本,移除了联系表单,专注于清晰地展示联系方式。
|
||||
* 采用单栏居中布局,设计简洁、现代。
|
||||
*/
|
||||
const ContactUsPage = async ({ params: { lang } }: { params: { lang: string } }) => {
|
||||
const ContactUsPage = async ({ params }: { params: { lang: string } }) => {
|
||||
const { lang } = await params;
|
||||
const { t } = await useTranslation(lang, 'contact');
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,11 +5,9 @@ import FooterLinks from '../../components/FooterLinks';
|
||||
import { dir } from 'i18next';
|
||||
import { languages } from '../../i18n/settings';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { headers } from 'next/headers';
|
||||
import { getTruePathFromHeaders } from '../../lib/utils';
|
||||
|
||||
// export async function generateStaticParams() {
|
||||
// const params = await languages.map((lng) => ({ lng }));
|
||||
// return params;
|
||||
// }
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
@@ -20,6 +18,7 @@ const inter = Inter({
|
||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const { t } = await useTranslation(lang, 'layout');
|
||||
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||
return {
|
||||
metadataBase: new URL('https://www.podcasthub.com'),
|
||||
title: t('title'),
|
||||
@@ -40,7 +39,7 @@ export async function generateMetadata({ params }: { params: { lang: string } })
|
||||
title: t('title'),
|
||||
},
|
||||
alternates: {
|
||||
canonical: `/${lang}`,
|
||||
canonical: `${truePath}`,
|
||||
languages: languages.reduce((acc: Record<string, string>, l) => {
|
||||
acc[l] = `/${l}`;
|
||||
return acc;
|
||||
@@ -64,6 +63,7 @@ export default async function RootLayout({
|
||||
lang: string;
|
||||
}
|
||||
}) {
|
||||
|
||||
const { lang } = await params;
|
||||
return (
|
||||
<html lang={lang} dir={dir(lang)} className={inter.variable}>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { getTTSProviders } from '@/lib/config';
|
||||
import { getSessionData } from '@/lib/server-actions';
|
||||
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
||||
import { useTranslation } from '../../i18n/client';
|
||||
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
||||
import { getTruePathFromPathname } from '../../lib/utils'; // 导入新函数
|
||||
|
||||
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true';
|
||||
|
||||
@@ -223,9 +225,11 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
}
|
||||
};
|
||||
|
||||
const pathname = usePathname();
|
||||
const truePath = getTruePathFromPathname(pathname, lang);
|
||||
// 处理播客标题点击
|
||||
const handleTitleClick = (podcast: PodcastItem) => {
|
||||
router.push(`/podcast/${podcast.file_name.split(".")[0]}`);
|
||||
router.push(`${truePath}/podcast/${podcast.file_name.split(".")[0]}`);
|
||||
};
|
||||
|
||||
const handlePlayPodcast = (podcast: PodcastItem) => {
|
||||
@@ -264,7 +268,8 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
}
|
||||
|
||||
try {
|
||||
const apiResponse: { success: boolean; tasks?: { message: string; tasks: PodcastGenerationResponse[]; }; error?: string } = result;
|
||||
// The API returns { success: true, tasks: [...] } directly
|
||||
const apiResponse: { success: boolean; tasks?: PodcastGenerationResponse[]; error?: string } = result;
|
||||
if (apiResponse.success && apiResponse.tasks && Array.isArray(apiResponse.tasks)) {
|
||||
const newPodcasts = mapApiResponseToPodcasts(apiResponse.tasks);
|
||||
const reversedPodcasts = newPodcasts.reverse();
|
||||
@@ -382,7 +387,7 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'settings':
|
||||
case 'tts-settings':
|
||||
return (
|
||||
<SettingsForm
|
||||
onSuccess={(message) => success(t('saveSuccessTitle'), message)}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Metadata } from 'next';
|
||||
import PodcastContent from '@/components/PodcastContent';
|
||||
import { useTranslation } from '../../../../i18n'; // 导入 useTranslation
|
||||
import { headers } from 'next/headers';
|
||||
import { getTruePathFromHeaders } from '../../../../lib/utils';
|
||||
|
||||
export async function generateMetadata({ params: { fileName, lang } }: PodcastDetailPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: PodcastDetailPageProps): Promise<Metadata> {
|
||||
const { fileName, lang } = await params;
|
||||
const { t } = await useTranslation(lang);
|
||||
const decodedFileName = decodeURIComponent(fileName);
|
||||
const title = `${t('podcastContent.podcastDetails')} - ${decodedFileName}`;
|
||||
const description = `${t('podcastContent.listenToPodcast')} ${decodedFileName}。`;
|
||||
|
||||
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${lang}/podcast/${decodedFileName}`,
|
||||
canonical: `${truePath}/podcast/${decodedFileName}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,7 +28,7 @@ interface PodcastDetailPageProps {
|
||||
}
|
||||
|
||||
export default async function PodcastDetailPage({ params }: PodcastDetailPageProps) {
|
||||
const { fileName, lang } = params; // 解构 lang
|
||||
const { fileName, lang } = await params; // 解构 lang
|
||||
return (
|
||||
<div className="bg-white text-gray-800 font-sans">
|
||||
<PodcastContent fileName={decodeURIComponent(fileName)} lang={lang} />
|
||||
|
||||
@@ -2,20 +2,24 @@ import { Metadata } from 'next';
|
||||
import React, { use } from 'react';
|
||||
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { headers } from 'next/headers';
|
||||
import { getTruePathFromHeaders } from '../../../lib/utils';
|
||||
|
||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'components');
|
||||
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||
return {
|
||||
title: t('pricing_page_title'),
|
||||
description: t('pricing_page_description'),
|
||||
alternates: {
|
||||
canonical: `/${lang}/pricing`,
|
||||
canonical: `${truePath}/pricing`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const PricingPage = async ({ params: { lang } }: { params: { lang: string } }) => {
|
||||
const PricingPage = async ({ params }: { params: { lang: string } }) => {
|
||||
const { lang } = await params;
|
||||
// 尽管 PricingSection 是客户端组件,为了使 PricingPage 成为服务器组件并加载服务端 i18n,我们在这里模拟加载
|
||||
await useTranslation(lang, 'components');
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { headers } from 'next/headers';
|
||||
import { getTruePathFromHeaders } from '../../../lib/utils';
|
||||
|
||||
/**
|
||||
* 设置页面元数据。
|
||||
*/
|
||||
export async function generateMetadata({ params: { lang } }: { params: { lang: string } }): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const { t } = await useTranslation(lang, 'privacy');
|
||||
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||
return {
|
||||
title: t('privacy_policy.title'),
|
||||
description: t('privacy_policy.description'),
|
||||
alternates: {
|
||||
canonical: `/${lang}/privacy`,
|
||||
canonical: `${truePath}/privacy`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,14 +31,15 @@ type PrivacyPolicyPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
const PrivacyPolicyPage: React.FC<PrivacyPolicyPageProps> = async ({ params: { lang } }) => {
|
||||
const PrivacyPolicyPage: React.FC<PrivacyPolicyPageProps> = async ({ params }) => {
|
||||
const { lang } = await params;
|
||||
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')}
|
||||
{t('privacy_policy.title')}
|
||||
</h1>
|
||||
<p className="text-gray-600">{t('privacy_policy.last_updated')}</p>
|
||||
<p>{t('privacy_policy.intro_paragraph')}</p>
|
||||
|
||||
@@ -2,19 +2,24 @@ import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { languages } from '@/i18n/settings';
|
||||
import { headers } from 'next/headers';
|
||||
import { getTruePathFromHeaders } from '../../../lib/utils';
|
||||
|
||||
export async function generateMetadata({ params: { lang } }: { params: { lang: string } }): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||
const { lang } = await params;
|
||||
const { t } = await useTranslation(lang, 'terms');
|
||||
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||
return {
|
||||
title: t('terms_of_service.title'),
|
||||
description: t('terms_of_service.description'),
|
||||
alternates: {
|
||||
canonical: `/${lang}/terms`,
|
||||
canonical: `${truePath}/terms`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TermsOfServicePage: React.FC<{ params: { lang: string } }> = async ({ params: { lang } }) => {
|
||||
const TermsOfServicePage: React.FC<{ params: { lang: string } }> = async ({ params }) => {
|
||||
const { lang } = await params;
|
||||
const { t } = await useTranslation(lang, 'terms');
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen py-12 sm:py-16">
|
||||
|
||||
86
web/src/app/api/audio-example/route.ts
Normal file
86
web/src/app/api/audio-example/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLanguageFromRequest } from '@/lib/utils';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { fetchAndCacheProvidersLocal } from '@/lib/config-local';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const lang = getLanguageFromRequest(request);
|
||||
const { t } = await useTranslation(lang, 'errors');
|
||||
|
||||
// 获取查询参数
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const text = searchParams.get('t');
|
||||
const voiceCode = searchParams.get('v');
|
||||
const providerKey = searchParams.get('p'); // 从URL参数p获取providerKey
|
||||
|
||||
// 验证必需参数
|
||||
if (!text || !voiceCode || !providerKey) {
|
||||
return NextResponse.json(
|
||||
{ error: t('invalid_request_parameters')},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取TTS提供商配置
|
||||
const config = await fetchAndCacheProvidersLocal(lang);
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{ error: t('cannot_read_tts_provider_config') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查配置中是否存在对应的提供商
|
||||
if (!config[providerKey] || !config[providerKey].api_url) {
|
||||
return NextResponse.json(
|
||||
{ error: t('invalid_provider')},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 构建目标URL
|
||||
const templateUrl = config[providerKey].api_url;
|
||||
const targetUrl = templateUrl
|
||||
.replace('{{text}}', encodeURIComponent(text))
|
||||
.replace('{{voiceCode}}', encodeURIComponent(voiceCode));
|
||||
|
||||
// 发起请求到目标服务器
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'PodcastHub/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
console.error(`TTS API error: ${response.status} ${response.statusText}`);
|
||||
return NextResponse.json(
|
||||
{ error: t('internal_server_error') },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取响应内容类型
|
||||
const contentType = response.headers.get('content-type') || 'audio/mpeg';
|
||||
|
||||
// 将响应转换为流并返回
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Disposition': 'inline',
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error in audio-example API:', error);
|
||||
return NextResponse.json(
|
||||
{ error: t('internal_server_error') },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
||||
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
||||
import { getTruePathFromPathname } from '../lib/utils'; // 导入新函数
|
||||
import LanguageSwitcher from './LanguageSwitcher'; // 导入语言切换组件
|
||||
|
||||
/**
|
||||
* FooterLinks 组件用于展示页脚的法律和联系链接。
|
||||
@@ -14,26 +16,32 @@ import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
||||
const FooterLinks: React.FC<{ lang: string }> = ({ lang: initialLang }) => {
|
||||
const { t } = useTranslation(initialLang, 'components'); // 初始化 useTranslation 并指定命名空间
|
||||
const pathname = usePathname();
|
||||
const lang = pathname === '/' ? '' : initialLang; // 如果是根目录,lang赋值为空
|
||||
const truePath = getTruePathFromPathname(pathname, initialLang); // 使用公共方法获取 lang
|
||||
// console.log('truePath:', truePath);
|
||||
const links = [
|
||||
{ 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') },
|
||||
{ href: `${truePath}/terms`, label: t('footerLinks.termsOfUse') },
|
||||
{ href: `${truePath}/privacy`, label: t('footerLinks.privacyPolicy') },
|
||||
{ href: `${truePath}/contact`, label: t('footerLinks.contactUs') },
|
||||
{ href: '/', label: t('footerLinks.copyright') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 分隔符 */}
|
||||
<div className="border-t border-gray-200 pt-6 mt-6"></div>
|
||||
<nav className="flex flex-wrap justify-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
{/* 遍历链接数组,为每个链接创建 Link 组件 */}
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href} target="_blank" className="hover:text-gray-900 transition-colors duration-200">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex flex-col md:flex-col justify-between items-center">
|
||||
<nav className="flex flex-wrap justify-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
{/* 遍历链接数组,为每个链接创建 Link 组件 */}
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href} target="_blank" className="hover:text-gray-900 transition-colors duration-200">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-4 md:mt-0">
|
||||
<LanguageSwitcher lang={initialLang} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,11 +10,22 @@ interface LanguageSwitcherProps {
|
||||
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ lang }) => {
|
||||
const { t, i18n } = useTranslation(lang, 'components'); // 初始化 useTranslation
|
||||
const router = useRouter();
|
||||
const currentPath = usePathname(); // 将 usePathname 移到组件顶层
|
||||
|
||||
const switchLanguage = (locale: string) => {
|
||||
// 获取当前路径,并替换语言部分
|
||||
const currentPath = usePathname(); // 使用 usePathname 获取当前路径
|
||||
const newPath = `/${locale}${currentPath.substring(currentPath.indexOf('/', 1))}`;
|
||||
let newPath = currentPath;
|
||||
|
||||
// 检查当前路径是否以语言代码开头
|
||||
const langPattern = /^\/(en|zh-CN|ja)/;
|
||||
if (langPattern.test(currentPath)) {
|
||||
// 如果是,则替换为新的语言代码
|
||||
newPath = currentPath.replace(langPattern, `/${locale}`);
|
||||
} else {
|
||||
// 如果不是,则在路径开头添加语言代码
|
||||
newPath = `/${locale}${currentPath}`;
|
||||
}
|
||||
|
||||
router.push(newPath);
|
||||
};
|
||||
|
||||
@@ -22,24 +33,34 @@ const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ lang }) => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => switchLanguage('zh-CN')}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium ${
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium text-gray-500 ${
|
||||
i18n.language === 'zh-CN'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
? 'text-gray-900 transition-colors duration-200'
|
||||
: 'hover:text-gray-900 transition-colors duration-200'
|
||||
}`}
|
||||
>
|
||||
{t('languageSwitcher.chinese')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchLanguage('en')}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium ${
|
||||
onClick={() => switchLanguage('')}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium text-gray-500 ${
|
||||
i18n.language === 'en'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
? 'text-gray-900 transition-colors duration-200'
|
||||
: 'hover:text-gray-900 transition-colors duration-200'
|
||||
}`}
|
||||
>
|
||||
{t('languageSwitcher.english')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchLanguage('ja')}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium text-gray-500 ${
|
||||
i18n.language === 'ja'
|
||||
? 'text-gray-900 transition-colors duration-200'
|
||||
: 'hover:text-gray-900 transition-colors duration-200'
|
||||
}`}
|
||||
>
|
||||
{t('languageSwitcher.japanese')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import AudioPlayerControls from './AudioPlayerControls';
|
||||
import PodcastTabs from './PodcastTabs';
|
||||
import ShareButton from './ShareButton'; // 导入 ShareButton 组件
|
||||
import { useTranslation } from '../i18n'; // 从正确路径导入 useTranslation
|
||||
import { headers } from 'next/headers'; // 导入 usePathname
|
||||
import { getTruePathFromHeaders } from '../lib/utils'; // 导入新函数
|
||||
|
||||
// 脚本解析函数 (与 page.tsx 中保持一致)
|
||||
const parseTranscript = (
|
||||
@@ -33,12 +35,13 @@ interface PodcastContentProps {
|
||||
export default async function PodcastContent({ fileName, lang }: PodcastContentProps) {
|
||||
const { t } = await useTranslation(lang, 'components'); // 初始化 useTranslation
|
||||
const result = await getAudioInfo(fileName, lang);
|
||||
const truePath = await getTruePathFromHeaders(await headers(), 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">{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">
|
||||
<a href={`${truePath}/`} 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>
|
||||
@@ -70,7 +73,7 @@ export default async function PodcastContent({ fileName, lang }: PodcastContentP
|
||||
{/* 返回首页按钮和分享按钮 */}
|
||||
<div className="flex justify-between items-center mb-6"> {/* 修改为 justify-between 和 items-center */}
|
||||
<a
|
||||
href="/"
|
||||
href={`${truePath}/`}
|
||||
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
|
||||
>
|
||||
<AiOutlineArrowLeft className="w-5 h-5 mr-1" />
|
||||
|
||||
@@ -5,6 +5,8 @@ import PricingCard from './PricingCard'; // 修改导入路径
|
||||
import BillingToggle from './BillingToggle'; // 修改导入路径
|
||||
import { PricingPlan } from '../types';
|
||||
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
||||
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
||||
import { getTruePathFromPathname } from '../lib/utils'; // 导入新函数
|
||||
|
||||
interface PricingSectionProps {
|
||||
lang: string;
|
||||
@@ -109,6 +111,8 @@ const PricingSection: React.FC<PricingSectionProps> = ({ lang }) => { // 重命
|
||||
];
|
||||
|
||||
const currentPlans = billingPeriod === 'monthly' ? monthlyPlans : annuallyPlans;
|
||||
const pathname = usePathname();
|
||||
const truePath = getTruePathFromPathname(pathname, lang);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen py-12 px-4 sm:px-6 lg:px-8">
|
||||
@@ -132,7 +136,7 @@ const PricingSection: React.FC<PricingSectionProps> = ({ lang }) => { // 重命
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center text-neutral-500">
|
||||
<a href={`/${lang}/pricing`} target="_blank" className="flex items-center justify-center text-neutral-600 hover:text-neutral-900 transition-colors duration-200">
|
||||
<a href={`${truePath}/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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef } from 'react'; // 导入 useState,
|
||||
import {
|
||||
AiOutlineHome,
|
||||
AiOutlineSetting,
|
||||
AiOutlineAudio,
|
||||
AiOutlineTwitter,
|
||||
AiOutlineTikTok,
|
||||
AiOutlineMail,
|
||||
@@ -104,7 +105,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
// 隐藏定价和积分
|
||||
// { 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 }] : [])
|
||||
...(enableTTSConfigPage ? [{ id: 'tts-settings', label: t('sidebar.ttsSettings'), icon: AiOutlineAudio }] : []),
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const fallbackLng = 'en'
|
||||
export const languages = [fallbackLng, 'zh-CN']
|
||||
export const languages = [fallbackLng, 'zh-CN', 'ja']
|
||||
export const defaultNS = 'common'
|
||||
export const ns = ['common', 'layout', 'home', 'components', 'errors']
|
||||
|
||||
|
||||
@@ -188,3 +188,33 @@ export function getLanguageFromRequest(request: NextRequest): string {
|
||||
// 如果未找到支持的语言,则返回默认语言
|
||||
return fallbackLng;
|
||||
}
|
||||
|
||||
import { ReadonlyHeaders } from 'next/dist/server/web/spec-extension/adapters/headers';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* 从请求头和参数中获取真实的路径
|
||||
*/
|
||||
export async function getTruePathFromHeaders(headersList: ReadonlyHeaders, langParam: string): Promise<string> {
|
||||
const pathname = headersList.get('x-next-pathname') || '';
|
||||
// console.log('Current pathname (from server):', pathname);
|
||||
// console.log('langParam:', langParam);
|
||||
if (pathname === '' || !languages.includes(pathname)) {
|
||||
return '';
|
||||
}
|
||||
return pathname !== langParam ? "/" : "/"+langParam;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 pathname 和初始语言获取正确的语言路径
|
||||
*/
|
||||
export function getTruePathFromPathname(pathname: string, initialLang: string): string {
|
||||
const rootPathname = pathname.split('/')[1];
|
||||
// console.log('rootPathname:', rootPathname);
|
||||
// 处理根路径
|
||||
if (pathname === '/' || !languages.includes(rootPathname)) {
|
||||
return '';
|
||||
}
|
||||
// 处理其他路径
|
||||
return pathname === '/' ? '/' : '/'+initialLang;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { fallbackLng, languages } from './i18n/settings';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
|
||||
// 检查路径是否已经包含语言标识
|
||||
const pathnameIsMissingLocale = languages.every(
|
||||
@@ -16,10 +17,26 @@ export function middleware(request: NextRequest) {
|
||||
if (pathnameIsMissingLocale) {
|
||||
// e.g. incoming request is /products
|
||||
// The new URL is now /en-US/products
|
||||
|
||||
requestHeaders.set('x-next-pathname', "");
|
||||
return NextResponse.rewrite(
|
||||
new URL(`/${fallbackLng}${pathname}`, request.url)
|
||||
new URL(`/${fallbackLng}${pathname}`, request.url),
|
||||
{
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
requestHeaders.set('x-next-pathname', pathname.split('/')[1]);
|
||||
return NextResponse.next(
|
||||
{
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -110,7 +110,7 @@ export interface WebSocketMessage {
|
||||
// 用户界面状态
|
||||
export interface UIState {
|
||||
sidebarCollapsed: boolean;
|
||||
currentView: 'home' | 'library' | 'explore' | 'settings' | 'credits';
|
||||
currentView: 'home' | 'library' | 'explore' | 'tts-settings' | 'credits' | 'settings';
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user