diff --git a/.gitignore b/.gitignore index 4d2f669..a4a6456 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ output/ excalidraw.log config/tts_providers-local.json +config/tts_providers-prod.json .claude .serena /node_modules \ No newline at end of file diff --git a/DOCKER_USAGE.md b/DOCKER_USAGE.md index f16b513..ad80392 100644 --- a/DOCKER_USAGE.md +++ b/DOCKER_USAGE.md @@ -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` 参数进行设置: diff --git a/README.md b/README.md index 116fa6e..2583bed 100644 --- a/README.md +++ b/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/` - 日文界面 --- diff --git a/README_EN.md b/README_EN.md index c513630..6e576e3 100644 --- a/README_EN.md +++ b/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) diff --git a/docker-compose.yml b/docker-compose.yml index d0ba904..eaa8cbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/web/next.config.ts b/web/next.config.ts index f736174..919e3c8 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -9,7 +9,9 @@ const nextConfig = { removeConsole: process.env.NODE_ENV === 'production', }, output: 'standalone', - devIndicators: false, + devIndicators: { + position: 'top-right', // 将挂件移动到右下角 + }, }; module.exports = nextConfig; \ No newline at end of file diff --git a/web/public/locales/en/components.json b/web/public/locales/en/components.json index 93e5841..a785b29 100644 --- a/web/public/locales/en/components.json +++ b/web/public/locales/en/components.json @@ -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", diff --git a/web/public/locales/en/errors.json b/web/public/locales/en/errors.json index 2832573..4772f22 100644 --- a/web/public/locales/en/errors.json +++ b/web/public/locales/en/errors.json @@ -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" } \ No newline at end of file diff --git a/web/public/locales/ja/components.json b/web/public/locales/ja/components.json index f3f3e08..1b6b45e 100644 --- a/web/public/locales/ja/components.json +++ b/web/public/locales/ja/components.json @@ -42,7 +42,8 @@ }, "languageSwitcher": { "chinese": "中文", - "english": "英語" + "english": "英語", + "japanese": "日本語" }, "loginModal": { "loginToYourAccount": "アカウントにログイン", @@ -228,7 +229,8 @@ "user": "ユーザー", "clickAvatarToLogout": "アバターをクリックしてログアウト", "lessThanSMSizeCannotExpand": "smサイズ未満の画面では展開できません", - "showMore": "もっと見る" + "showMore": "もっと見る", + "generalSettings": "一般設定" }, "toast": { "title": "通知", diff --git a/web/public/locales/zh-CN/components.json b/web/public/locales/zh-CN/components.json index 1e06db5..b908b92 100644 --- a/web/public/locales/zh-CN/components.json +++ b/web/public/locales/zh-CN/components.json @@ -42,7 +42,8 @@ }, "languageSwitcher": { "chinese": "中文", - "english": "英文" + "english": "英文", + "japanese": "日文" }, "loginModal": { "loginToYourAccount": "登录您的账户", @@ -226,7 +227,8 @@ "user": "用户", "clickAvatarToLogout": "点击头像注销", "lessThanSMSizeCannotExpand": "小于sm尺寸不可展开", - "showMore": "显示更多" + "showMore": "显示更多", + "generalSettings": "通用设置" }, "toast": { "title": "通知", diff --git a/web/public/locales/zh-CN/errors.json b/web/public/locales/zh-CN/errors.json index 480510d..27eff0a 100644 --- a/web/public/locales/zh-CN/errors.json +++ b/web/public/locales/zh-CN/errors.json @@ -45,5 +45,6 @@ , "invalid_pagination_parameters": "无效的分页参数" , - "cannot_read_tts_provider_config": "无法读取TTS提供商配置文件" + "cannot_read_tts_provider_config": "无法读取TTS提供商配置文件", + "invalid_provider": "无效的TTS提供商" } \ No newline at end of file diff --git a/web/src/app/[lang]/contact/page.tsx b/web/src/app/[lang]/contact/page.tsx index 6a31a9d..28b98a2 100644 --- a/web/src/app/[lang]/contact/page.tsx +++ b/web/src/app/[lang]/contact/page.tsx @@ -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 { 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 ( diff --git a/web/src/app/[lang]/layout.tsx b/web/src/app/[lang]/layout.tsx index 2b168a8..6157a4b 100644 --- a/web/src/app/[lang]/layout.tsx +++ b/web/src/app/[lang]/layout.tsx @@ -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 { 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, l) => { acc[l] = `/${l}`; return acc; @@ -64,6 +63,7 @@ export default async function RootLayout({ lang: string; } }) { + const { lang } = await params; return ( diff --git a/web/src/app/[lang]/page.tsx b/web/src/app/[lang]/page.tsx index c407497..1d01500 100644 --- a/web/src/app/[lang]/page.tsx +++ b/web/src/app/[lang]/page.tsx @@ -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(); @@ -381,8 +386,8 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }> /> */} ); - - case 'settings': + + case 'tts-settings': return ( success(t('saveSuccessTitle'), message)} diff --git a/web/src/app/[lang]/podcast/[fileName]/page.tsx b/web/src/app/[lang]/podcast/[fileName]/page.tsx index 8b90902..19ec6f2 100644 --- a/web/src/app/[lang]/podcast/[fileName]/page.tsx +++ b/web/src/app/[lang]/podcast/[fileName]/page.tsx @@ -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 { +export async function generateMetadata({ params }: PodcastDetailPageProps): Promise { + 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 (
diff --git a/web/src/app/[lang]/pricing/page.tsx b/web/src/app/[lang]/pricing/page.tsx index bb18b75..783fc5f 100644 --- a/web/src/app/[lang]/pricing/page.tsx +++ b/web/src/app/[lang]/pricing/page.tsx @@ -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 { 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 ( diff --git a/web/src/app/[lang]/privacy/page.tsx b/web/src/app/[lang]/privacy/page.tsx index 1a916bc..1fee4af 100644 --- a/web/src/app/[lang]/privacy/page.tsx +++ b/web/src/app/[lang]/privacy/page.tsx @@ -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 { +export async function generateMetadata({ params }: { params: { lang: string } }): Promise { + 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 = async ({ params: { lang } }) => { +const PrivacyPolicyPage: React.FC = async ({ params }) => { + const { lang } = await params; const { t } = await useTranslation(lang, 'privacy'); return (

- {t('privacy_policy.title_long')} + {t('privacy_policy.title')}

{t('privacy_policy.last_updated')}

{t('privacy_policy.intro_paragraph')}

diff --git a/web/src/app/[lang]/terms/page.tsx b/web/src/app/[lang]/terms/page.tsx index 396e03f..f5ca07f 100644 --- a/web/src/app/[lang]/terms/page.tsx +++ b/web/src/app/[lang]/terms/page.tsx @@ -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 { +export async function generateMetadata({ params }: { params: { lang: string } }): Promise { + 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 (
diff --git a/web/src/app/api/audio-example/route.ts b/web/src/app/api/audio-example/route.ts new file mode 100644 index 0000000..13de251 --- /dev/null +++ b/web/src/app/api/audio-example/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/web/src/components/FooterLinks.tsx b/web/src/components/FooterLinks.tsx index d14411b..a0b02ba 100644 --- a/web/src/components/FooterLinks.tsx +++ b/web/src/components/FooterLinks.tsx @@ -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 (
{/* 分隔符 */}
- +
+ +
+ +
+
); }; diff --git a/web/src/components/LanguageSwitcher.tsx b/web/src/components/LanguageSwitcher.tsx index 37706f6..c0d91f8 100644 --- a/web/src/components/LanguageSwitcher.tsx +++ b/web/src/components/LanguageSwitcher.tsx @@ -10,11 +10,22 @@ interface LanguageSwitcherProps { const LanguageSwitcher: React.FC = ({ 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,26 +33,36 @@ const LanguageSwitcher: React.FC = ({ lang }) => {
+
); }; -export default LanguageSwitcher; \ No newline at end of file +export default LanguageSwitcher; diff --git a/web/src/components/PodcastContent.tsx b/web/src/components/PodcastContent.tsx index ce6902d..c7f40b8 100644 --- a/web/src/components/PodcastContent.tsx +++ b/web/src/components/PodcastContent.tsx @@ -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 (

{t('podcastContent.cannotLoadPodcastDetails')}{result.error || t('podcastContent.unknownError')}

- + {t('podcastContent.returnToHomepage')}
@@ -70,7 +73,7 @@ export default async function PodcastContent({ fileName, lang }: PodcastContentP {/* 返回首页按钮和分享按钮 */}
{/* 修改为 justify-between 和 items-center */} diff --git a/web/src/components/PricingSection.tsx b/web/src/components/PricingSection.tsx index 99d8d7b..7637935 100644 --- a/web/src/components/PricingSection.tsx +++ b/web/src/components/PricingSection.tsx @@ -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 = ({ lang }) => { // 重命 ]; const currentPlans = billingPeriod === 'monthly' ? monthlyPlans : annuallyPlans; + const pathname = usePathname(); + const truePath = getTruePathFromPathname(pathname, lang); return (
@@ -132,7 +136,7 @@ const PricingSection: React.FC = ({ lang }) => { // 重命
- + {t('pricingSection.visitPricingPage')} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 55760b4..1532f76 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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 = ({ // 隐藏定价和积分 // { 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 }] : []), ]; diff --git a/web/src/i18n/settings.ts b/web/src/i18n/settings.ts index a269e12..96c279b 100644 --- a/web/src/i18n/settings.ts +++ b/web/src/i18n/settings.ts @@ -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'] diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 1beeb25..f8ddfa0 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -187,4 +187,34 @@ export function getLanguageFromRequest(request: NextRequest): string { console.log(fallbackLng) // 如果未找到支持的语言,则返回默认语言 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 { + 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; } \ No newline at end of file diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 490aa2a..3e0842a 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -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 = { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 1a1aae6..e928a66 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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'; }