feat: 添加确认模态框并优化音频生成流程

- 新增确认生成模态框组件,支持多语言显示
- 调整音频时长选项为"5分钟左右"和"8-15分钟"
- 优化Docker配置,添加.env和config目录挂载
- 改进音频生成流程,增加静音修剪功能
- 更新多语言翻译文件,添加确认相关文本
- 修复播客内容组件中overview_content处理逻辑
- 优化中间件配置,排除robots.txt和sitemap.xml
- 完善Docker使用文档,补充挂载点说明
- 改进播客脚本提示词,增强对话深度要求
This commit is contained in:
hex2077
2025-08-26 21:38:00 +08:00
parent d7c4520a65
commit 7b641fdeff
15 changed files with 478 additions and 86 deletions

View File

@@ -3,10 +3,10 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
// 必须项,你的网站域名
siteUrl: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000',
siteUrl: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000/',
// (可选) 自动生成 robots.txt 文件,默认为 false
generateRobotsTxt: true,
generateRobotsTxt: true,
// (可选) 自定义 robots.txt 的内容
robotsTxtOptions: {
@@ -27,23 +27,89 @@ module.exports = {
},
// (可选) 排除特定的路由
exclude: ['/api/*'],
exclude: ['/api/*', '/_next/*', '/static/*'],
// 这个函数会在构建时执行
// additionalPaths: async (config) => {
// // 示例:从外部 API 获取所有博客文章的 slug
// const response = await fetch('https://api.example.com/posts');
// const posts = await response.json(); // 假设返回 [{ slug: 'post-1', updatedAt: '2023-01-01' }, ...]
// 支持多语言
i18n: {
locales: ['en', 'zh-CN', 'ja'],
defaultLocale: 'en',
},
// // 将文章数据转换为 next-sitemap 需要的格式
// const paths = posts.map(post => ({
// loc: `/blog/${post.slug}`, // URL 路径
// changefreq: 'weekly',
// priority: 0.7,
// lastmod: new Date(post.updatedAt).toISOString(), // 最后修改时间
// }));
// 包含静态页面
transform: async (config, path) => {
// 为动态路由设置默认值
if (path.includes('[fileName]')) {
return null; // 这些将在 additionalPaths 中处理
}
return {
loc: path,
changefreq: 'daily',
priority: path === '/' ? 1.0 : 0.8,
lastmod: new Date().toISOString(),
};
},
// // 返回一个 Promise解析为一个路径数组
// return paths;
// },
// 添加动态路由和多语言支持
additionalPaths: async (config) => {
const paths = [];
// 支持的语言
const languages = ['en', 'zh-CN', 'ja'];
// 添加静态页面路径(包含多语言版本)
const staticPaths = [
'/',
'/pricing',
'/contact',
'/privacy',
'/terms'
];
staticPaths.forEach(path => {
// 添加默认语言路径
paths.push({
loc: path,
changefreq: 'daily',
priority: path === '/' ? 1.0 : 0.8,
lastmod: new Date().toISOString(),
});
// 为每种语言添加本地化路径
languages.forEach(lang => {
const localizedPath = `/${lang}${path === '/' ? '' : path}`;
paths.push({
loc: localizedPath,
changefreq: 'daily',
priority: path === '/' ? 1.0 : 0.8,
lastmod: new Date().toISOString(),
});
});
});
// 如果有播客文件,可以在这里添加动态路径
// 示例:从数据库或文件系统获取播客文件名
// const podcastFiles = await getPodcastFiles(); // 你需要实现这个函数
// podcastFiles.forEach(fileName => {
// // 添加默认语言路径
// paths.push({
// loc: `/podcast/${fileName}`,
// changefreq: 'weekly',
// priority: 0.6,
// lastmod: new Date().toISOString(),
// });
//
// // 为每种语言添加本地化路径
// languages.forEach(lang => {
// paths.push({
// loc: `/${lang}/podcast/${fileName}`,
// changefreq: 'weekly',
// priority: 0.6,
// lastmod: new Date().toISOString(),
// });
// });
// });
return paths;
},
};

View File

@@ -81,6 +81,11 @@
"checkIn": "Check In",
"create": "Create",
"biu": "Biu!",
"confirm": "Confirm",
"cancel": "Cancel",
"close": "Close",
"confirmGeneration": "Confirm Generation",
"confirmGenerationMessage": "This operation will consume {{points}} points, continue?",
"checkInSuccess": "Check-in successful",
"checkInFailed": "Check-in failed",
"networkError": "Network error or server no response",
@@ -95,9 +100,8 @@
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"under5Minutes": "Under 5 minutes",
"between5And10Minutes": "5-10 minutes",
"between10And15Minutes": "10-15 minutes"
"under5Minutes": "5 minutes or less",
"between8And15Minutes": "8-15 minutes"
},
"podcastTabs": {
"script": "Script",
@@ -256,5 +260,11 @@
"maxVoicesAlert": "You can select up to 5 speakers.",
"delete": "Delete",
"presenter": "Presenter"
},
"newUser": {
"noPointsAccount": "User {{userId}} has no points account, initializing...",
"initialBonusDescription": "New user registration, initial points bonus",
"initError": "Failed to initialize user {{userId}} points account or record transaction: {{error}}",
"pointsAccountExists": "User {{userId}} already has a points account, no initialization required."
}
}

View File

@@ -81,6 +81,11 @@
"checkIn": "チェックイン",
"create": "作成",
"biu": "びゅう!",
"confirm": "確認",
"cancel": "キャンセル",
"close": "閉じる",
"confirmGeneration": "生成の確認",
"confirmGenerationMessage": "この操作では{{points}}ポイントが消費されます。続行しますか?",
"checkInSuccess": "チェックイン成功",
"checkInFailed": "チェックイン失敗",
"networkError": "ネットワークエラーまたはサーバー応答なし",
@@ -95,9 +100,8 @@
"chinese": "中国語",
"english": "英語",
"japanese": "日本語",
"under5Minutes": "5分未満",
"between5And10Minutes": "5〜10分",
"between10And15Minutes": "10〜15分"
"under5Minutes": "5分",
"between8And15Minutes": "8〜15分"
},
"podcastTabs": {
"script": "スクリプト",
@@ -256,5 +260,11 @@
"maxVoicesAlert": "最大5人のスピーカーを選択できます。",
"delete": "削除",
"presenter": "プレゼンター"
},
"newUser": {
"noPointsAccount": "ユーザー {{userId}} にポイントアカウントがありません。初期化しています...",
"initialBonusDescription": "新規ユーザー登録、初回ポイントボーナス",
"initError": "ユーザー {{userId}} のポイントアカウントの初期化またはトランザクションの記録に失敗しました: {{error}}",
"pointsAccountExists": "ユーザー {{userId}} はすでにポイントアカウントを持っています。初期化は不要です。"
}
}

View File

@@ -81,6 +81,11 @@
"checkIn": "签到",
"create": "创作",
"biu": "Biu!",
"confirm": "确认",
"cancel": "取消",
"close": "关闭",
"confirmGeneration": "确认生成",
"confirmGenerationMessage": "本次操作将消耗 {{points}} 积分,是否继续?",
"checkInSuccess": "签到成功",
"checkInFailed": "签到失败",
"networkError": "网络错误或服务器无响应",
@@ -95,9 +100,8 @@
"chinese": "中文",
"english": "英文",
"japanese": "日文",
"under5Minutes": "5分钟以内",
"between5And10Minutes": "5-10分钟",
"between10And15Minutes": "10-15分钟"
"under5Minutes": "5分钟左右",
"between8And15Minutes": "8-15分钟"
},
"podcastTabs": {
"script": "脚本",
@@ -256,5 +260,11 @@
"maxVoicesAlert": "最多只能选择5个说话人。",
"delete": "删除",
"presenter": "主讲人"
},
"newUser": {
"noPointsAccount": "用户 {{userId}} 不存在积分账户,正在初始化...",
"initialBonusDescription": "新用户注册,初始积分奖励",
"initError": "初始化用户 {{userId}} 积分账户或记录流水失败: {{error}}",
"pointsAccountExists": "用户 {{userId}} 已存在积分账户,无需初始化。"
}
}

View File

@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
);
}
const allowedDurations = ['Under 5 minutes', '5-10 minutes', '10-15 minutes'];
const allowedDurations = ['Under 5 minutes', '8-15 minutes'];
if (!body.usetime || !allowedDurations.includes(body.usetime)) {
return NextResponse.json(
{ success: false, error: t('invalid_podcast_duration') },

View File

@@ -1,8 +1,13 @@
import { NextResponse, NextRequest } from 'next/server';
import { getSessionData } from "@/lib/server-actions";
import { createPointsAccount, recordPointsTransaction, checkUserPointsAccount } from "@/lib/points"; // 导入新封装的函数
import { getTranslation } from '@/i18n';
import { fallbackLng } from '@/i18n/settings';
export async function GET(request: NextRequest) {
const lng = request.headers.get('x-next-pathname') || fallbackLng;
const { t } = await getTranslation(lng, 'components');
const sessionData = await getSessionData();
let baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "/";
const pathname = request.nextUrl.searchParams.get('pathname');
@@ -24,18 +29,18 @@ export async function GET(request: NextRequest) {
// 如果不存在积分账户,则初始化
if (!userHasPointsAccount) {
console.log(`用户 ${userId} 不存在积分账户,正在初始化...`);
console.log(t('newUser.noPointsAccount', { userId }));
try {
const pointsPerPodcastDay = parseInt(process.env.POINTS_PER_PODCAST_INIT || '100', 10);
await createPointsAccount(userId, pointsPerPodcastDay); // 调用封装的创建积分账户函数
await recordPointsTransaction(userId, pointsPerPodcastDay, "initial_bonus", "新用户注册,初始积分奖励"); // 调用封装的记录流水函数
await recordPointsTransaction(userId, pointsPerPodcastDay, "initial_bonus", t('newUser.initialBonusDescription')); // 调用封装的记录流水函数
} catch (error) {
console.error(`初始化用户 ${userId} 积分账户或记录流水失败:`, error);
console.error(t('newUser.initError', { userId, error }));
// 根据错误类型,可能需要更详细的错误处理或重定向
// 例如,如果 userId 无效,可以重定向到错误页面
}
} else {
console.log(`用户 ${userId} 已存在积分账户,无需初始化。`);
console.log(t('newUser.pointsAccountExists', { userId }));
}
// 创建一个 URL 对象,指向要重定向到的根目录

View File

@@ -0,0 +1,110 @@
// web/src/components/ConfirmModal.tsx
"use client"; // 标记为客户端组件,因为需要交互性
import React, { FC, MouseEventHandler, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { XMarkIcon } from "@heroicons/react/24/outline"; // 导入关闭图标
import { useTranslation } from '../i18n/client'; // 导入 useTranslation
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
points?: number; // 新增 points 属性
confirmText?: string;
cancelText?: string;
lang: string; // 新增 lang 属性
}
const ConfirmModal: FC<ConfirmModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
points,
confirmText,
cancelText,
lang
}) => {
const { t } = useTranslation(lang, 'components'); // 初始化 useTranslation 并指定命名空间
const modalRef = useRef<HTMLDivElement>(null);
// 点击背景关闭模态框
const handleOverlayClick: MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
},
[onClose]
);
const handleConfirm = () => {
onConfirm();
onClose();
};
if (!isOpen) return null;
// 使用 React Portal 将模态框渲染到 body 下避免Z-index问题和父组件样式影响
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-auto"
onClick={handleOverlayClick}
aria-modal="true"
role="dialog"
>
<div
ref={modalRef}
className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-4 sm:p-6 transform transition-all duration-300 ease-out scale-95 opacity-0 animate-scale-in"
// 使用 Tailwind CSS 动画来优化进入效果,确保布局健壮性
style={{ animationFillMode: 'forwards' }} // 动画结束后保持最终状态
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
aria-label={t('podcastCreator.close')}
>
<XMarkIcon className="h-6 w-6" />
</button>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 text-center">
{title}
</h2>
<p
className="text-gray-700 dark:text-gray-300 mb-6 text-center"
dangerouslySetInnerHTML={{
__html: message.replace('{{points}}',
points !== undefined ?
`<span class="font-bold text-brand-purple dark:text-brand-pink">${points}</span>` :
'{{points}}'
)
}}
/>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
{cancelText || t('podcastCreator.cancel')}
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 border border-transparent rounded-md shadow-sm font-medium text-white bg-gradient-to-r from-brand-purple to-brand-pink hover:from-brand-purple-hover hover:to-brand-pink focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-purple transition-all"
>
{confirmText || t('podcastCreator.confirm')}
</button>
</div>
</div>
</div>,
document.body // 渲染到 body 元素下
);
};
export default ConfirmModal;

View File

@@ -147,7 +147,7 @@ export default async function PodcastContent({ fileName, lang }: PodcastContentP
{/* 3. 内容导航区和内容展示区 - 使用客户端组件 */}
<PodcastTabs
parsedScript={parsedScript}
overviewContent={audioInfo.overview_content ? audioInfo.overview_content.split('\n').slice(2).join('\n') : ''}
overviewContent={audioInfo.overview_content}
lang={lang}
/>
</main>

View File

@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils';
import ConfigSelector from './ConfigSelector';
import VoicesModal from './VoicesModal'; // 引入 VoicesModal
import LoginModal from './LoginModal'; // 引入 LoginModal
import ConfirmModal from './ConfirmModal'; // 引入 ConfirmModal
import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container
import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
import { useSession } from '@/lib/auth-client'; // 引入 useSession
@@ -60,8 +61,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const durationOptions = [
{ value: 'Under 5 minutes', label: t('podcastCreator.under5Minutes') },
{ value: '5-10 minutes', label: t('podcastCreator.between5And10Minutes') },
{ value: '10-15 minutes', label: t('podcastCreator.between10And15Minutes') },
{ value: '8-15 minutes', label: t('podcastCreator.between8And15Minutes') },
];
const [topic, setTopic] = useState('');
@@ -97,6 +97,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const [duration, setDuration] = useState(durationOptions[0].value);
const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示
const [showConfirmModal, setShowConfirmModal] = useState(false); // 控制确认模态框的显示
const [voices, setVoices] = useState<Voice[]>([]); // 从 ConfigSelector 获取 voices
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => {
// 从 localStorage 读取缓存的说话人配置
@@ -129,6 +130,11 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
return;
}
// 显示确认对话框
setShowConfirmModal(true);
};
const handleConfirmGenerate = async () => {
let inputTxtContent = topic.trim();
if (customInstructions.trim()) {
inputTxtContent = "```custom-begin"+`\n${customInstructions.trim()}\n`+"```custom-end"+`\n${inputTxtContent}`;
@@ -526,6 +532,19 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
toasts={toasts}
onRemove={removeToast}
/>
{/* Confirm Modal */}
<ConfirmModal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirmGenerate}
title={t('podcastCreator.confirmGeneration')}
message={t('podcastCreator.confirmGenerationMessage')}
points={duration === '8-15 minutes' ?
parseInt(process.env.POINTS_PER_PODCAST || '20', 10) * 2 :
parseInt(process.env.POINTS_PER_PODCAST || '20', 10)}
lang={lang}
/>
</div>
);
};

View File

@@ -41,5 +41,5 @@ export function middleware(request: NextRequest) {
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|favicon.webp).*)'],
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|favicon.webp|robots.txt|sitemap.xml).*)'],
};