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/
|
output/
|
||||||
excalidraw.log
|
excalidraw.log
|
||||||
config/tts_providers-local.json
|
config/tts_providers-local.json
|
||||||
|
config/tts_providers-prod.json
|
||||||
.claude
|
.claude
|
||||||
.serena
|
.serena
|
||||||
/node_modules
|
/node_modules
|
||||||
@@ -47,6 +47,7 @@ docker run -d -p 3200:3000 -v /opt/audio:/app/server/output --restart always --n
|
|||||||
* `-d`:在分离模式(detached mode)下运行容器,即在后台运行。
|
* `-d`:在分离模式(detached mode)下运行容器,即在后台运行。
|
||||||
* `-p 3200:3000`:将宿主机的 3200 端口映射到容器的 3000 端口。Next.js 应用程序在容器内部的 3000 端口上运行。
|
* `-p 3200:3000`:将宿主机的 3200 端口映射到容器的 3000 端口。Next.js 应用程序在容器内部的 3000 端口上运行。
|
||||||
* `-v /opt/audio:/app/server/output`:将宿主机的 `/opt/audio` 目录挂载到容器内的 `/app/server/output` 目录,用于音频文件的持久化存储。
|
* `-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`:设置容器的重启策略,确保容器在意外停止或系统重启后能自动重启。
|
* `--restart always`:设置容器的重启策略,确保容器在意外停止或系统重启后能自动重启。
|
||||||
* `--name podcast-web`:为运行中的容器指定一个名称,方便后续管理。
|
* `--name podcast-web`:为运行中的容器指定一个名称,方便后续管理。
|
||||||
* `simple-podcast-web`:指定要运行的 Docker 镜像名称。
|
* `simple-podcast-web`:指定要运行的 Docker 镜像名称。
|
||||||
@@ -54,7 +55,7 @@ docker run -d -p 3200:3000 -v /opt/audio:/app/server/output --restart always --n
|
|||||||
#### 运行 Server 应用容器
|
#### 运行 Server 应用容器
|
||||||
|
|
||||||
```bash
|
```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` 参数进行设置:
|
或者,如果您的应用程序需要配置环境变量(例如 `PODCAST_API_SECRET_KEY`),您可以使用 `-e` 参数进行设置:
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -197,27 +197,28 @@ curl -X POST "http://localhost:8000/generate-podcast" \
|
|||||||
|
|
||||||
## 🌍 国际化 (i18n) 支持
|
## 🌍 国际化 (i18n) 支持
|
||||||
|
|
||||||
本项目支持多语言界面,目前支持中文 (zh-CN) 和英文 (en)。
|
本项目支持多语言界面,目前支持英文 (en)、中文 (zh-CN) 和日文 (ja)。
|
||||||
|
|
||||||
### 📁 语言文件结构
|
### 📁 语言文件结构
|
||||||
|
|
||||||
语言文件位于 `web/public/locales/` 目录下,按照语言代码分组:
|
语言文件位于 `web/public/locales/` 目录下,按照语言代码分组:
|
||||||
- `web/public/locales/zh-CN/common.json` - 中文翻译
|
|
||||||
- `web/public/locales/en/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/`
|
1. 在 `web/public/locales/` 目录下创建新的语言文件夹,例如 `fr/`
|
||||||
2. 复制 `common.json` 文件到新文件夹中
|
2. 复制 `common.json` 文件到新文件夹中
|
||||||
3. 翻译文件中的所有键值对
|
3. 翻译文件中的所有键值对
|
||||||
4. 在 `web/next-i18next.config.js` 文件中添加新的语言代码到 `locales` 数组
|
4. 在 `web/src/i18n/settings.ts` 文件中更新 `languages` 变量
|
||||||
5. 在 `web/src/i18n.ts` 文件中更新 `languages` 变量
|
|
||||||
|
|
||||||
### 🌐 语言切换
|
### 🌐 语言切换
|
||||||
|
|
||||||
用户可以通过 URL 路径或浏览器语言设置自动切换语言:
|
用户可以通过 URL 路径或浏览器语言设置自动切换语言:
|
||||||
- `http://localhost:3000/zh-CN/` - 中文界面
|
|
||||||
- `http://localhost:3000/en/` - 英文界面
|
- `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
|
## ⚙️ Configuration File Details
|
||||||
|
|
||||||
### `config/[tts-provider].json` (TTS Character and Voice Configuration)
|
### `config/[tts-provider].json` (TTS Character and Voice Configuration)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3200:3000"
|
- "3200:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- audio-data:/app/server/output
|
- audio-data/output:/app/server/output
|
||||||
|
- audio-data/sqlite.db:/app/web/sqlite.db
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
@@ -24,7 +25,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3100:8000"
|
- "3100:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- audio-data:/app/server/output
|
- audio-data/output:/app/server/output
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- PODCAST_API_SECRET_KEY=your-production-api-secret-key
|
- PODCAST_API_SECRET_KEY=your-production-api-secret-key
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ const nextConfig = {
|
|||||||
removeConsole: process.env.NODE_ENV === 'production',
|
removeConsole: process.env.NODE_ENV === 'production',
|
||||||
},
|
},
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
devIndicators: false,
|
devIndicators: {
|
||||||
|
position: 'top-right', // 将挂件移动到右下角
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
@@ -42,7 +42,8 @@
|
|||||||
},
|
},
|
||||||
"languageSwitcher": {
|
"languageSwitcher": {
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "English"
|
"english": "English",
|
||||||
|
"japanese": "日本語"
|
||||||
},
|
},
|
||||||
"loginModal": {
|
"loginModal": {
|
||||||
"loginToYourAccount": "Login to Your Account",
|
"loginToYourAccount": "Login to Your Account",
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
"noOutlineContent": "No outline content."
|
"noOutlineContent": "No outline content."
|
||||||
},
|
},
|
||||||
"podcastCreator": {
|
"podcastCreator": {
|
||||||
"giveVoiceToCreativity": "Give voice to creativity",
|
"giveVoiceToCreativity": "Lend creativity an authentic voice",
|
||||||
"enterTextPlaceholder": "Enter text, Markdown format supported...",
|
"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",
|
"addCustomInstructions": "Add custom instructions (optional)... e.g. fixed opening and closing remarks, contextual text, key points of output content",
|
||||||
"ttsConfigSelection": "TTS Config Selection",
|
"ttsConfigSelection": "TTS Config Selection",
|
||||||
@@ -222,11 +223,12 @@
|
|||||||
"areYouSureToLogout": "Are you sure you want to log out?",
|
"areYouSureToLogout": "Are you sure you want to log out?",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirmLogout": "Logout",
|
"confirmLogout": "Logout",
|
||||||
"sessionExpired": "Session expired, logging out...",
|
"sessionExpired": "Session expired, logging out",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"clickAvatarToLogout": "Click avatar to logout",
|
"clickAvatarToLogout": "Click avatar to logout",
|
||||||
"lessThanSMSizeCannotExpand": "Cannot expand on screens smaller than sm size",
|
"lessThanSMSizeCannotExpand": "Cannot expand on screens smaller than sm size",
|
||||||
"showMore": "Show more"
|
"showMore": "Show more",
|
||||||
|
"generalSettings": "General Settings"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"title": "Notification",
|
"title": "Notification",
|
||||||
|
|||||||
@@ -45,5 +45,6 @@
|
|||||||
,
|
,
|
||||||
"invalid_pagination_parameters": "Invalid pagination parameters"
|
"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": {
|
"languageSwitcher": {
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "英語"
|
"english": "英語",
|
||||||
|
"japanese": "日本語"
|
||||||
},
|
},
|
||||||
"loginModal": {
|
"loginModal": {
|
||||||
"loginToYourAccount": "アカウントにログイン",
|
"loginToYourAccount": "アカウントにログイン",
|
||||||
@@ -228,7 +229,8 @@
|
|||||||
"user": "ユーザー",
|
"user": "ユーザー",
|
||||||
"clickAvatarToLogout": "アバターをクリックしてログアウト",
|
"clickAvatarToLogout": "アバターをクリックしてログアウト",
|
||||||
"lessThanSMSizeCannotExpand": "smサイズ未満の画面では展開できません",
|
"lessThanSMSizeCannotExpand": "smサイズ未満の画面では展開できません",
|
||||||
"showMore": "もっと見る"
|
"showMore": "もっと見る",
|
||||||
|
"generalSettings": "一般設定"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"title": "通知",
|
"title": "通知",
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
},
|
},
|
||||||
"languageSwitcher": {
|
"languageSwitcher": {
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "英文"
|
"english": "英文",
|
||||||
|
"japanese": "日文"
|
||||||
},
|
},
|
||||||
"loginModal": {
|
"loginModal": {
|
||||||
"loginToYourAccount": "登录您的账户",
|
"loginToYourAccount": "登录您的账户",
|
||||||
@@ -226,7 +227,8 @@
|
|||||||
"user": "用户",
|
"user": "用户",
|
||||||
"clickAvatarToLogout": "点击头像注销",
|
"clickAvatarToLogout": "点击头像注销",
|
||||||
"lessThanSMSizeCannotExpand": "小于sm尺寸不可展开",
|
"lessThanSMSizeCannotExpand": "小于sm尺寸不可展开",
|
||||||
"showMore": "显示更多"
|
"showMore": "显示更多",
|
||||||
|
"generalSettings": "通用设置"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"title": "通知",
|
"title": "通知",
|
||||||
|
|||||||
@@ -45,5 +45,6 @@
|
|||||||
,
|
,
|
||||||
"invalid_pagination_parameters": "无效的分页参数"
|
"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 React, { use } from 'react';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiFillMail } from 'react-icons/ai';
|
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> {
|
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'contact');
|
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'contact');
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
return {
|
return {
|
||||||
title: t('contact_us_title'),
|
title: t('contact_us_title'),
|
||||||
description: t('contact_us_description'),
|
description: t('contact_us_description'),
|
||||||
alternates: {
|
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');
|
const { t } = await useTranslation(lang, 'contact');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import FooterLinks from '../../components/FooterLinks';
|
|||||||
import { dir } from 'i18next';
|
import { dir } from 'i18next';
|
||||||
import { languages } from '../../i18n/settings';
|
import { languages } from '../../i18n/settings';
|
||||||
import { useTranslation } from '../../i18n';
|
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({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -20,6 +18,7 @@ const inter = Inter({
|
|||||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
const { t } = await useTranslation(lang, 'layout');
|
const { t } = await useTranslation(lang, 'layout');
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL('https://www.podcasthub.com'),
|
metadataBase: new URL('https://www.podcasthub.com'),
|
||||||
title: t('title'),
|
title: t('title'),
|
||||||
@@ -40,7 +39,7 @@ export async function generateMetadata({ params }: { params: { lang: string } })
|
|||||||
title: t('title'),
|
title: t('title'),
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${lang}`,
|
canonical: `${truePath}`,
|
||||||
languages: languages.reduce((acc: Record<string, string>, l) => {
|
languages: languages.reduce((acc: Record<string, string>, l) => {
|
||||||
acc[l] = `/${l}`;
|
acc[l] = `/${l}`;
|
||||||
return acc;
|
return acc;
|
||||||
@@ -64,6 +63,7 @@ export default async function RootLayout({
|
|||||||
lang: string;
|
lang: string;
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
return (
|
return (
|
||||||
<html lang={lang} dir={dir(lang)} className={inter.variable}>
|
<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 { getSessionData } from '@/lib/server-actions';
|
||||||
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
||||||
import { useTranslation } from '../../i18n/client';
|
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';
|
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) => {
|
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) => {
|
const handlePlayPodcast = (podcast: PodcastItem) => {
|
||||||
@@ -264,7 +268,8 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)) {
|
if (apiResponse.success && apiResponse.tasks && Array.isArray(apiResponse.tasks)) {
|
||||||
const newPodcasts = mapApiResponseToPodcasts(apiResponse.tasks);
|
const newPodcasts = mapApiResponseToPodcasts(apiResponse.tasks);
|
||||||
const reversedPodcasts = newPodcasts.reverse();
|
const reversedPodcasts = newPodcasts.reverse();
|
||||||
@@ -381,8 +386,8 @@ export default function HomePage({ params }: { params: Promise<{ lang: string }>
|
|||||||
/> */}
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'settings':
|
case 'tts-settings':
|
||||||
return (
|
return (
|
||||||
<SettingsForm
|
<SettingsForm
|
||||||
onSuccess={(message) => success(t('saveSuccessTitle'), message)}
|
onSuccess={(message) => success(t('saveSuccessTitle'), message)}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import PodcastContent from '@/components/PodcastContent';
|
import PodcastContent from '@/components/PodcastContent';
|
||||||
import { useTranslation } from '../../../../i18n'; // 导入 useTranslation
|
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 { t } = await useTranslation(lang);
|
||||||
const decodedFileName = decodeURIComponent(fileName);
|
const decodedFileName = decodeURIComponent(fileName);
|
||||||
const title = `${t('podcastContent.podcastDetails')} - ${decodedFileName}`;
|
const title = `${t('podcastContent.podcastDetails')} - ${decodedFileName}`;
|
||||||
const description = `${t('podcastContent.listenToPodcast')} ${decodedFileName}。`;
|
const description = `${t('podcastContent.listenToPodcast')} ${decodedFileName}。`;
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `/${lang}/podcast/${decodedFileName}`,
|
canonical: `${truePath}/podcast/${decodedFileName}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -25,7 +28,7 @@ interface PodcastDetailPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PodcastDetailPage({ params }: PodcastDetailPageProps) {
|
export default async function PodcastDetailPage({ params }: PodcastDetailPageProps) {
|
||||||
const { fileName, lang } = params; // 解构 lang
|
const { fileName, lang } = await params; // 解构 lang
|
||||||
return (
|
return (
|
||||||
<div className="bg-white text-gray-800 font-sans">
|
<div className="bg-white text-gray-800 font-sans">
|
||||||
<PodcastContent fileName={decodeURIComponent(fileName)} lang={lang} />
|
<PodcastContent fileName={decodeURIComponent(fileName)} lang={lang} />
|
||||||
|
|||||||
@@ -2,20 +2,24 @@ import { Metadata } from 'next';
|
|||||||
import React, { use } from 'react';
|
import React, { use } from 'react';
|
||||||
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getTruePathFromHeaders } from '../../../lib/utils';
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
export async function generateMetadata({ params }: { params: { lang: string } }): Promise<Metadata> {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'components');
|
const { t } = await (await import('../../../i18n')).useTranslation(lang, 'components');
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
return {
|
return {
|
||||||
title: t('pricing_page_title'),
|
title: t('pricing_page_title'),
|
||||||
description: t('pricing_page_description'),
|
description: t('pricing_page_description'),
|
||||||
alternates: {
|
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,我们在这里模拟加载
|
// 尽管 PricingSection 是客户端组件,为了使 PricingPage 成为服务器组件并加载服务端 i18n,我们在这里模拟加载
|
||||||
await useTranslation(lang, 'components');
|
await useTranslation(lang, 'components');
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { useTranslation } from '@/i18n';
|
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 { t } = await useTranslation(lang, 'privacy');
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
return {
|
return {
|
||||||
title: t('privacy_policy.title'),
|
title: t('privacy_policy.title'),
|
||||||
description: t('privacy_policy.description'),
|
description: t('privacy_policy.description'),
|
||||||
alternates: {
|
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');
|
const { t } = await useTranslation(lang, 'privacy');
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen py-12 sm:py-16">
|
<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">
|
<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">
|
<article className="prose max-w-full break-words">
|
||||||
<h1 className="text-4xl font-extrabold mb-6 text-gray-900 border-b pb-4">
|
<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>
|
</h1>
|
||||||
<p className="text-gray-600">{t('privacy_policy.last_updated')}</p>
|
<p className="text-gray-600">{t('privacy_policy.last_updated')}</p>
|
||||||
<p>{t('privacy_policy.intro_paragraph')}</p>
|
<p>{t('privacy_policy.intro_paragraph')}</p>
|
||||||
|
|||||||
@@ -2,19 +2,24 @@ import React from 'react';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { useTranslation } from '@/i18n';
|
import { useTranslation } from '@/i18n';
|
||||||
import { languages } from '@/i18n/settings';
|
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 { t } = await useTranslation(lang, 'terms');
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
return {
|
return {
|
||||||
title: t('terms_of_service.title'),
|
title: t('terms_of_service.title'),
|
||||||
description: t('terms_of_service.description'),
|
description: t('terms_of_service.description'),
|
||||||
alternates: {
|
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');
|
const { t } = await useTranslation(lang, 'terms');
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen py-12 sm:py-16">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
||||||
|
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
||||||
|
import { getTruePathFromPathname } from '../lib/utils'; // 导入新函数
|
||||||
|
import LanguageSwitcher from './LanguageSwitcher'; // 导入语言切换组件
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FooterLinks 组件用于展示页脚的法律和联系链接。
|
* FooterLinks 组件用于展示页脚的法律和联系链接。
|
||||||
@@ -14,26 +16,32 @@ import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
|||||||
const FooterLinks: React.FC<{ lang: string }> = ({ lang: initialLang }) => {
|
const FooterLinks: React.FC<{ lang: string }> = ({ lang: initialLang }) => {
|
||||||
const { t } = useTranslation(initialLang, 'components'); // 初始化 useTranslation 并指定命名空间
|
const { t } = useTranslation(initialLang, 'components'); // 初始化 useTranslation 并指定命名空间
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const lang = pathname === '/' ? '' : initialLang; // 如果是根目录,lang赋值为空
|
const truePath = getTruePathFromPathname(pathname, initialLang); // 使用公共方法获取 lang
|
||||||
|
// console.log('truePath:', truePath);
|
||||||
const links = [
|
const links = [
|
||||||
{ href: `${lang}/terms`, label: t('footerLinks.termsOfUse') },
|
{ href: `${truePath}/terms`, label: t('footerLinks.termsOfUse') },
|
||||||
{ href: `${lang}/privacy`, label: t('footerLinks.privacyPolicy') },
|
{ href: `${truePath}/privacy`, label: t('footerLinks.privacyPolicy') },
|
||||||
{ href: `${lang}/contact`, label: t('footerLinks.contactUs') },
|
{ href: `${truePath}/contact`, label: t('footerLinks.contactUs') },
|
||||||
{ href: '#', label: t('footerLinks.copyright') },
|
{ href: '/', label: t('footerLinks.copyright') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* 分隔符 */}
|
{/* 分隔符 */}
|
||||||
<div className="border-t border-gray-200 pt-6 mt-6"></div>
|
<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">
|
<div className="flex flex-col md:flex-col justify-between items-center">
|
||||||
{/* 遍历链接数组,为每个链接创建 Link 组件 */}
|
<nav className="flex flex-wrap justify-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||||
{links.map((link) => (
|
{/* 遍历链接数组,为每个链接创建 Link 组件 */}
|
||||||
<Link key={link.href} href={link.href} target="_blank" className="hover:text-gray-900 transition-colors duration-200">
|
{links.map((link) => (
|
||||||
{link.label}
|
<Link key={link.href} href={link.href} target="_blank" className="hover:text-gray-900 transition-colors duration-200">
|
||||||
</Link>
|
{link.label}
|
||||||
))}
|
</Link>
|
||||||
</nav>
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mt-4 md:mt-0">
|
||||||
|
<LanguageSwitcher lang={initialLang} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,11 +10,22 @@ interface LanguageSwitcherProps {
|
|||||||
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ lang }) => {
|
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ lang }) => {
|
||||||
const { t, i18n } = useTranslation(lang, 'components'); // 初始化 useTranslation
|
const { t, i18n } = useTranslation(lang, 'components'); // 初始化 useTranslation
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const currentPath = usePathname(); // 将 usePathname 移到组件顶层
|
||||||
|
|
||||||
const switchLanguage = (locale: string) => {
|
const switchLanguage = (locale: string) => {
|
||||||
// 获取当前路径,并替换语言部分
|
// 获取当前路径,并替换语言部分
|
||||||
const currentPath = usePathname(); // 使用 usePathname 获取当前路径
|
let newPath = currentPath;
|
||||||
const newPath = `/${locale}${currentPath.substring(currentPath.indexOf('/', 1))}`;
|
|
||||||
|
// 检查当前路径是否以语言代码开头
|
||||||
|
const langPattern = /^\/(en|zh-CN|ja)/;
|
||||||
|
if (langPattern.test(currentPath)) {
|
||||||
|
// 如果是,则替换为新的语言代码
|
||||||
|
newPath = currentPath.replace(langPattern, `/${locale}`);
|
||||||
|
} else {
|
||||||
|
// 如果不是,则在路径开头添加语言代码
|
||||||
|
newPath = `/${locale}${currentPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
router.push(newPath);
|
router.push(newPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,26 +33,36 @@ const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ lang }) => {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => switchLanguage('zh-CN')}
|
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'
|
i18n.language === 'zh-CN'
|
||||||
? 'bg-blue-500 text-white'
|
? 'text-gray-900 transition-colors duration-200'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
: 'hover:text-gray-900 transition-colors duration-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('languageSwitcher.chinese')}
|
{t('languageSwitcher.chinese')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => switchLanguage('en')}
|
onClick={() => switchLanguage('')}
|
||||||
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 === 'en'
|
i18n.language === 'en'
|
||||||
? 'bg-blue-500 text-white'
|
? 'text-gray-900 transition-colors duration-200'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
: 'hover:text-gray-900 transition-colors duration-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('languageSwitcher.english')}
|
{t('languageSwitcher.english')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageSwitcher;
|
export default LanguageSwitcher;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import AudioPlayerControls from './AudioPlayerControls';
|
|||||||
import PodcastTabs from './PodcastTabs';
|
import PodcastTabs from './PodcastTabs';
|
||||||
import ShareButton from './ShareButton'; // 导入 ShareButton 组件
|
import ShareButton from './ShareButton'; // 导入 ShareButton 组件
|
||||||
import { useTranslation } from '../i18n'; // 从正确路径导入 useTranslation
|
import { useTranslation } from '../i18n'; // 从正确路径导入 useTranslation
|
||||||
|
import { headers } from 'next/headers'; // 导入 usePathname
|
||||||
|
import { getTruePathFromHeaders } from '../lib/utils'; // 导入新函数
|
||||||
|
|
||||||
// 脚本解析函数 (与 page.tsx 中保持一致)
|
// 脚本解析函数 (与 page.tsx 中保持一致)
|
||||||
const parseTranscript = (
|
const parseTranscript = (
|
||||||
@@ -33,12 +35,13 @@ interface PodcastContentProps {
|
|||||||
export default async function PodcastContent({ fileName, lang }: PodcastContentProps) {
|
export default async function PodcastContent({ fileName, lang }: PodcastContentProps) {
|
||||||
const { t } = await useTranslation(lang, 'components'); // 初始化 useTranslation
|
const { t } = await useTranslation(lang, 'components'); // 初始化 useTranslation
|
||||||
const result = await getAudioInfo(fileName, lang);
|
const result = await getAudioInfo(fileName, lang);
|
||||||
|
const truePath = await getTruePathFromHeaders(await headers(), lang);
|
||||||
|
|
||||||
if (!result.success || !result.data || result.data.status!='completed') {
|
if (!result.success || !result.data || result.data.status!='completed') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center items-center h-screen text-gray-800">
|
<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>
|
<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')}
|
{t('podcastContent.returnToHomepage')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 */}
|
<div className="flex justify-between items-center mb-6"> {/* 修改为 justify-between 和 items-center */}
|
||||||
<a
|
<a
|
||||||
href="/"
|
href={`${truePath}/`}
|
||||||
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
|
className="flex items-center gap-1 text-neutral-500 hover:text-black transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<AiOutlineArrowLeft className="w-5 h-5 mr-1" />
|
<AiOutlineArrowLeft className="w-5 h-5 mr-1" />
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import PricingCard from './PricingCard'; // 修改导入路径
|
|||||||
import BillingToggle from './BillingToggle'; // 修改导入路径
|
import BillingToggle from './BillingToggle'; // 修改导入路径
|
||||||
import { PricingPlan } from '../types';
|
import { PricingPlan } from '../types';
|
||||||
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
|
||||||
|
import { usePathname } from 'next/navigation'; // 导入 usePathname
|
||||||
|
import { getTruePathFromPathname } from '../lib/utils'; // 导入新函数
|
||||||
|
|
||||||
interface PricingSectionProps {
|
interface PricingSectionProps {
|
||||||
lang: string;
|
lang: string;
|
||||||
@@ -109,6 +111,8 @@ const PricingSection: React.FC<PricingSectionProps> = ({ lang }) => { // 重命
|
|||||||
];
|
];
|
||||||
|
|
||||||
const currentPlans = billingPeriod === 'monthly' ? monthlyPlans : annuallyPlans;
|
const currentPlans = billingPeriod === 'monthly' ? monthlyPlans : annuallyPlans;
|
||||||
|
const pathname = usePathname();
|
||||||
|
const truePath = getTruePathFromPathname(pathname, lang);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen py-12 px-4 sm:px-6 lg:px-8">
|
<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>
|
||||||
|
|
||||||
<div className="mt-12 text-center text-neutral-500">
|
<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')}
|
{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">
|
<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>
|
<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 {
|
import {
|
||||||
AiOutlineHome,
|
AiOutlineHome,
|
||||||
AiOutlineSetting,
|
AiOutlineSetting,
|
||||||
|
AiOutlineAudio,
|
||||||
AiOutlineTwitter,
|
AiOutlineTwitter,
|
||||||
AiOutlineTikTok,
|
AiOutlineTikTok,
|
||||||
AiOutlineMail,
|
AiOutlineMail,
|
||||||
@@ -104,7 +105,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
// 隐藏定价和积分
|
// 隐藏定价和积分
|
||||||
// { id: 'pricing', label: t('sidebar.pricing'), icon: DollarSign },
|
// { id: 'pricing', label: t('sidebar.pricing'), icon: DollarSign },
|
||||||
{ id: 'credits', label: t('sidebar.points'), icon: AiOutlineMoneyCollect, badge: credits.toString() }, // 动态设置 badge
|
{ 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 fallbackLng = 'en'
|
||||||
export const languages = [fallbackLng, 'zh-CN']
|
export const languages = [fallbackLng, 'zh-CN', 'ja']
|
||||||
export const defaultNS = 'common'
|
export const defaultNS = 'common'
|
||||||
export const ns = ['common', 'layout', 'home', 'components', 'errors']
|
export const ns = ['common', 'layout', 'home', 'components', 'errors']
|
||||||
|
|
||||||
|
|||||||
@@ -187,4 +187,34 @@ export function getLanguageFromRequest(request: NextRequest): string {
|
|||||||
console.log(fallbackLng)
|
console.log(fallbackLng)
|
||||||
// 如果未找到支持的语言,则返回默认语言
|
// 如果未找到支持的语言,则返回默认语言
|
||||||
return 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<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) {
|
export function middleware(request: NextRequest) {
|
||||||
const pathname = request.nextUrl.pathname;
|
const pathname = request.nextUrl.pathname;
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
|
||||||
// 检查路径是否已经包含语言标识
|
// 检查路径是否已经包含语言标识
|
||||||
const pathnameIsMissingLocale = languages.every(
|
const pathnameIsMissingLocale = languages.every(
|
||||||
@@ -16,10 +17,26 @@ export function middleware(request: NextRequest) {
|
|||||||
if (pathnameIsMissingLocale) {
|
if (pathnameIsMissingLocale) {
|
||||||
// e.g. incoming request is /products
|
// e.g. incoming request is /products
|
||||||
// The new URL is now /en-US/products
|
// The new URL is now /en-US/products
|
||||||
|
|
||||||
|
requestHeaders.set('x-next-pathname', "");
|
||||||
return NextResponse.rewrite(
|
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 = {
|
export const config = {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export interface WebSocketMessage {
|
|||||||
// 用户界面状态
|
// 用户界面状态
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
currentView: 'home' | 'library' | 'explore' | 'settings' | 'credits';
|
currentView: 'home' | 'library' | 'explore' | 'tts-settings' | 'credits' | 'settings';
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user