feat: 添加日语支持并优化国际化功能

refactor: 重构中间件和路由处理逻辑
fix: 修复音频示例API的错误处理
docs: 更新README和DOCKER_USAGE文档
style: 优化语言切换器样式
chore: 更新.gitignore添加生产环境配置文件
This commit is contained in:
hex2077
2025-08-25 19:17:16 +08:00
parent 0b00a3b0ae
commit f64cd498cf
28 changed files with 312 additions and 74 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ __pycache__/
output/
excalidraw.log
config/tts_providers-local.json
config/tts_providers-prod.json
.claude
.serena
/node_modules

View File

@@ -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` 参数进行设置:

View File

@@ -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/` - 日文界面
---

View File

@@ -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)

View File

@@ -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

View File

@@ -9,7 +9,9 @@ const nextConfig = {
removeConsole: process.env.NODE_ENV === 'production',
},
output: 'standalone',
devIndicators: false,
devIndicators: {
position: 'top-right', // 将挂件移动到右下角
},
};
module.exports = nextConfig;

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -42,7 +42,8 @@
},
"languageSwitcher": {
"chinese": "中文",
"english": "英語"
"english": "英語",
"japanese": "日本語"
},
"loginModal": {
"loginToYourAccount": "アカウントにログイン",
@@ -228,7 +229,8 @@
"user": "ユーザー",
"clickAvatarToLogout": "アバターをクリックしてログアウト",
"lessThanSMSizeCannotExpand": "smサイズ未満の画面では展開できません",
"showMore": "もっと見る"
"showMore": "もっと見る",
"generalSettings": "一般設定"
},
"toast": {
"title": "通知",

View File

@@ -42,7 +42,8 @@
},
"languageSwitcher": {
"chinese": "中文",
"english": "英文"
"english": "英文",
"japanese": "日文"
},
"loginModal": {
"loginToYourAccount": "登录您的账户",
@@ -226,7 +227,8 @@
"user": "用户",
"clickAvatarToLogout": "点击头像注销",
"lessThanSMSizeCannotExpand": "小于sm尺寸不可展开",
"showMore": "显示更多"
"showMore": "显示更多",
"generalSettings": "通用设置"
},
"toast": {
"title": "通知",

View File

@@ -45,5 +45,6 @@
,
"invalid_pagination_parameters": "无效的分页参数"
,
"cannot_read_tts_provider_config": "无法读取TTS提供商配置文件"
"cannot_read_tts_provider_config": "无法读取TTS提供商配置文件",
"invalid_provider": "无效的TTS提供商"
}

View File

@@ -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 (

View File

@@ -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}>

View File

@@ -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)}

View File

@@ -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} />

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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">

View 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 }
);
}
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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 }] : []),
];

View File

@@ -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']

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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';
}