feat: 添加Docker支持并优化SEO和用户认证

refactor: 重构页面元数据以支持SEO规范链接
feat(web): 实现用户积分系统和登录验证
docs: 添加Docker使用指南和更新README
build: 添加Docker相关配置文件和脚本
chore: 更新依赖项并添加初始化SQL文件
This commit is contained in:
hex2077
2025-08-21 17:59:17 +08:00
parent d3bd3fdff2
commit 043b0e39f8
20 changed files with 862 additions and 26 deletions

47
web/.dockerignore Normal file
View File

@@ -0,0 +1,47 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Node.js
node_modules
.next/cache
dist
.pnp
.pnp.js
# Testing
/coverage
# Production
/build
# Misc
.DS_Store
*.pem
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
Thumbs.db
drizzle
.claude/
/.public/
# Docker specific
Dockerfile
.dockerignore

142
web/init.sql Normal file
View File

@@ -0,0 +1,142 @@
--
-- SQLiteStudio v3.4.17 生成的文件,周三 8月 20 17:58:34 2025
--
-- 所用的文本编码System
--
PRAGMA foreign_keys = off;
BEGIN TRANSACTION;
-- 表__drizzle_migrations
CREATE TABLE IF NOT EXISTS __drizzle_migrations (
id SERIAL PRIMARY KEY,
hash TEXT NOT NULL,
created_at NUMERIC
);
-- 表account
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY
NOT NULL,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (
user_id
)
REFERENCES user (id) ON UPDATE NO ACTION
ON DELETE CASCADE
);
-- 表points_accounts
CREATE TABLE IF NOT EXISTS points_accounts (-- 账户ID使用自增整数作为主键效率最高。
account_id INTEGER PRIMARY KEY AUTOINCREMENT,-- 关联的用户ID设置为 TEXT 类型以匹配您的设计。
/* 添加 UNIQUE 约束确保一个用户只有一个积分账户。 */user_id TEXT NOT NULL
UNIQUE,-- 当前总积分,非负,默认为 0。
total_points INTEGER NOT NULL
DEFAULT 0
CHECK (total_points >= 0),-- 最后更新时间,使用 TEXT 存储 ISO8601 格式的日期时间。
/* 在记录更新时,应由应用程序逻辑来更新此字段。 */updated_at TEXT NOT NULL
);
-- 表points_transactions
CREATE TABLE IF NOT EXISTS points_transactions (-- 流水ID使用自增整数主键。
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,-- 关联的用户ID。
user_id TEXT NOT NULL,-- 本次变动的积分数,正数代表增加,负数代表减少。
points_change INTEGER NOT NULL,-- 变动原因代码,方便程序进行逻辑判断。
reason_code TEXT NOT NULL,-- 变动原因的文字描述,可为空。
description TEXT,-- 记录创建时间,默认为当前时间戳。
created_at TEXT NOT NULL
DEFAULT CURRENT_TIMESTAMP
);
-- 表session
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY
NOT NULL,
expires_at INTEGER NOT NULL,
token TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
user_id TEXT NOT NULL,
FOREIGN KEY (
user_id
)
REFERENCES user (id) ON UPDATE NO ACTION
ON DELETE CASCADE
);
-- 表user
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY
NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
email_verified INTEGER NOT NULL,
image TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
username TEXT,
display_username TEXT
);
-- 表verification
CREATE TABLE IF NOT EXISTS verification (
id TEXT PRIMARY KEY
NOT NULL,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER,
updated_at INTEGER
);
-- 索引idx_points_accounts_user_id
CREATE INDEX IF NOT EXISTS idx_points_accounts_user_id ON points_accounts (
user_id
);
-- 索引idx_points_transactions_user_id
CREATE INDEX IF NOT EXISTS idx_points_transactions_user_id ON points_transactions (
user_id
);
-- 索引session_token_unique
CREATE UNIQUE INDEX IF NOT EXISTS session_token_unique ON session (
token
);
-- 索引user_email_unique
CREATE UNIQUE INDEX IF NOT EXISTS user_email_unique ON user (
email
);
-- 索引user_username_unique
CREATE UNIQUE INDEX IF NOT EXISTS user_username_unique ON user (
username
);
COMMIT TRANSACTION;
PRAGMA foreign_keys = on;

View File

@@ -8,6 +8,7 @@ const nextConfig = {
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
output: 'standalone',
};
module.exports = nextConfig;

78
web/package-lock.json generated
View File

@@ -24,6 +24,7 @@
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.4",
"framer-motion": "^11.3.8",
"globby": "^14.1.0",
"lucide-react": "^0.424.0",
"next": "^14.2.5",
"postcss": "^8.4.40",
@@ -2520,6 +2521,18 @@
"node": ">=20.0.0"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -6000,6 +6013,35 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.3",
"path-type": "^6.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
@@ -8234,6 +8276,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -9432,6 +9486,18 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@@ -10336,6 +10402,18 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

BIN
web/sqlite.db Normal file

Binary file not shown.

View File

@@ -8,6 +8,9 @@ import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiF
export const metadata: Metadata = {
title: '联系我们 - PodcastHub',
description: '有任何问题或建议?请随时联系 PodcastHub 团队。我们期待您的声音。',
alternates: {
canonical: '/contact',
},
};
/**

View File

@@ -10,8 +10,9 @@ const inter = Inter({
});
export const metadata: Metadata = {
title: 'PodcastHub - 给创意一个真实的声音',
description: '使用AI技术将您的想法和内容转换为高质量播客音频,支持多种语音和风格选择。',
metadataBase: new URL('https://www.podcasthub.com'),
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
description: 'PodcastHub 利用尖端AI技术为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频支持多种个性化语音和风格选择。立即体验高效创作让您的声音在全球范围内传播吸引更多听众并简化您的播客制作流程。',
keywords: ['播客', 'AI', '语音合成', 'TTS', '音频生成'],
authors: [{ name: 'PodcastHub Team' }],
icons: {
@@ -19,11 +20,18 @@ export const metadata: Metadata = {
apple: '/favicon.webp',
},
openGraph: {
title: 'PodcastHub - 给创意一个真实的声音',
description: '使用AI技术将您的想法和内容转换为高质量的播客音频,支持多种语音和风格选择。',
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
description: 'PodcastHub 利用尖端AI技术为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频,支持多种个性化语音和风格选择。立即体验高效创作,让您的声音在全球范围内传播,吸引更多听众,并简化您的播客制作流程。',
type: 'website',
locale: 'zh_CN',
},
twitter: {
card: 'summary_large_image',
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
},
alternates: {
canonical: '/',
},
};
export const viewport = {

View File

@@ -1,5 +1,20 @@
import { Metadata } from 'next';
import PodcastContent from '@/components/PodcastContent';
export async function generateMetadata({ params }: PodcastDetailPageProps): Promise<Metadata> {
const fileName = decodeURIComponent(params.fileName);
const title = `播客详情 - ${fileName}`;
const description = `收听 ${fileName} 的播客。`;
return {
title,
description,
alternates: {
canonical: `/podcast/${fileName}`,
},
};
}
interface PodcastDetailPageProps {
params: {
fileName: string;

View File

@@ -1,12 +1,20 @@
'use client';
import React from 'react';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
const PricingPage: React.FC = () => {
return (
<PricingSection />
);
import { Metadata } from 'next';
import React from 'react';
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
export const metadata: Metadata = {
title: '定价 - PodcastHub',
description: '查看 PodcastHub 的灵活定价方案,找到最适合您的播客创作计划。',
alternates: {
canonical: '/pricing',
},
};
export default PricingPage;
const PricingPage: React.FC = () => {
return (
<PricingSection />
);
};
export default PricingPage;

View File

@@ -7,6 +7,9 @@ import { Metadata } from 'next';
export const metadata: Metadata = {
title: '隐私政策 - PodcastHub',
description: '了解 PodcastHub 如何保护您的隐私。我们致力于透明化地处理您的数据。',
alternates: {
canonical: '/privacy',
},
};
/**

View File

@@ -7,6 +7,9 @@ import { Metadata } from 'next';
export const metadata: Metadata = {
title: '使用条款 - PodcastHub',
description: '欢迎了解 PodcastHub 的使用条款。本条款旨在保护用户与平台的共同利益。',
alternates: {
canonical: '/terms',
},
};
/**

View File

@@ -16,8 +16,10 @@ import {
import { cn } from '@/lib/utils';
import ConfigSelector from './ConfigSelector';
import VoicesModal from './VoicesModal'; // 引入 VoicesModal
import LoginModal from './LoginModal'; // 引入 LoginModal
import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container
import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
import { useSession } from '@/lib/auth-client'; // 引入 useSession
import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types';
import { Satisfy } from 'next/font/google'; // 导入艺术字体 Satisfy
@@ -72,6 +74,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const [language, setLanguage] = useState(languageOptions[0].value);
const [duration, setDuration] = useState(durationOptions[0].value);
const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示
const [voices, setVoices] = useState<Voice[]>([]); // 从 ConfigSelector 获取 voices
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => {
// 从 localStorage 读取缓存的说话人配置
@@ -83,8 +86,13 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const { toasts, error } = useToast(); // 使用 useToast hook, 引入 success
const { data: session } = useSession(); // 获取 session
const handleSubmit = async () => { // 修改为 async 函数
if (!session?.user) { // 判断是否登录
setShowLoginModal(true); // 未登录则显示登录模态框
return;
}
if (!topic.trim()) {
error("主题不能为空", "请输入播客主题。"); // 使用 toast.error
return;
@@ -206,9 +214,9 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
</g>
</svg>
</div>
<h2 className="text-2xl sm:text-3xl text-black mb-6 break-words">
<h1 className="text-2xl sm:text-3xl text-black mb-6 break-words">
</h2>
</h1>
{/* 模式切换按钮 todo */}
{/* <div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
@@ -387,10 +395,10 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
{/* 创作按钮 */}
<button
onClick={handleSubmit}
disabled={!topic.trim() || isGenerating || credits <= 0}
disabled={!topic.trim() || isGenerating}
className={cn(
"btn-primary flex items-center gap-1 text-sm sm:text-base px-3 py-2 sm:px-4 sm:py-2",
(!topic.trim() || isGenerating || credits <= 0) && "opacity-50 cursor-not-allowed"
(!topic.trim() || isGenerating) && "opacity-50 cursor-not-allowed"
)}
>
{isGenerating ? (
@@ -440,6 +448,11 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
}}
/>
)}
{/* Login Modal */}
<LoginModal
isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
<ToastContainer toasts={toasts} onRemove={() => {}} /> {/* 添加 ToastContainer */}
</div>