feat: add comprehensive SEO optimization with dynamic metadata

- Implemented dynamic metadata generation for root layout and article pages with Open Graph and Twitter Card support
- Added robots.txt and sitemap.xml generation for improved search engine crawling
- Made HCaptcha verification optional based on configuration across comment, friend application, and wall message forms
This commit is contained in:
sonder
2025-11-19 03:55:54 +08:00
parent d9401466c7
commit 2fe7b65090
12 changed files with 330 additions and 27 deletions

View File

@@ -1,4 +1,7 @@
import { getArticleDataAPI, recordViewAPI } from '@/api/article';
import { getWebConfigDataAPI } from '@/api/config';
import { Web } from '@/types/app/config';
import { Metadata } from 'next';
import Starry from '@/components/Starry';
import Slide from '@/components/Slide';
@@ -26,6 +29,58 @@ interface Props {
searchParams: Promise<{ password: string }>;
}
// 生成文章页面的metadata
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params;
const id = params.id;
const { data: article } = (await getArticleDataAPI(id)) || { data: {} as Article };
const {
data: { value: webConfig },
} = (await getWebConfigDataAPI<{ value: Web }>('web')) || { data: { value: {} as Web } };
const baseUrl = webConfig?.url || 'https://liuyuyang.net';
if (!article || !article.title) {
return {
title: '文章未找到',
};
}
return {
title: article.title,
description: article.description || article.title,
keywords: article.tagList?.map((tag) => tag.name).join(',') || '',
authors: [{ name: webConfig?.title || 'ThriveX' }],
openGraph: {
type: 'article',
locale: 'zh_CN',
url: `${baseUrl}/article/${id}`,
title: article.title,
description: article.description || article.title,
siteName: webConfig?.title || 'ThriveX',
images: [
{
url: article.cover || webConfig?.favicon || '/favicon.ico',
width: 1200,
height: 630,
alt: article.title,
},
],
publishedTime: new Date(+article.createTime).toISOString(),
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.description || article.title,
images: [article.cover || webConfig?.favicon || '/favicon.ico'],
},
alternates: {
canonical: `/article/${id}`,
},
};
}
export default async (props: Props) => {
const searchParams = await props.searchParams;
const params = await props.params;

View File

@@ -1,3 +1,5 @@
'use client';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import Link from 'next/link';
import Show from '@/components/Show';

View File

@@ -9,6 +9,7 @@ import HCaptchaType from '@hcaptcha/react-hcaptcha';
import List from './components/List';
import HCaptcha from '@/components/HCaptcha';
import EmojiBag from '@/components/EmojiBag';
import { useConfigStore } from '@/stores';
import 'react-toastify/dist/ReactToastify.css';
import './index.scss';
@@ -50,6 +51,10 @@ const CommentForm = ({ articleId }: Props) => {
const captchaRef = useRef<HCaptchaType>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string>('');
// 获取HCaptcha配置
const config = useConfigStore();
const hasHCaptcha = !!config?.other?.hcaptcha_key;
const {
register,
@@ -71,7 +76,8 @@ const CommentForm = ({ articleId }: Props) => {
// 清除之前的人机验证错误
setCaptchaError('');
if (!captchaToken) return setCaptchaError('请完成人机验证');
// 只有配置了HCaptcha时才需要验证
if (hasHCaptcha && !captchaToken) return setCaptchaError('请完成人机验证');
setLoading(true);
@@ -210,10 +216,12 @@ const CommentForm = ({ articleId }: Props) => {
<span className="text-red-400 text-sm pl-3 mt-1">{errors.url?.message}</span>
</div>
<div className="flex flex-col">
<HCaptcha ref={captchaRef} setToken={handleCaptchaSuccess} />
{captchaError && <span className="text-red-400 text-sm pl-3 mt-1">{captchaError}</span>}
</div>
{hasHCaptcha && (
<div className="flex flex-col">
<HCaptcha ref={captchaRef} setToken={handleCaptchaSuccess} />
{captchaError && <span className="text-red-400 text-sm pl-3 mt-1">{captchaError}</span>}
</div>
)}
{loading ? (
<div className="w-full h-10 flex justify-center !mt-4">

View File

@@ -2,7 +2,7 @@
import { useAuthorStore } from '@/stores';
const Copyright = async () => {
const Copyright = () => {
const author = useAuthorStore((state) => state.author);
return (

View File

@@ -8,6 +8,7 @@ import { addWebDataAPI, getWebTypeListAPI } from '@/api/web';
import { Bounce, toast, ToastOptions } from 'react-toastify';
import HCaptchaType from '@hcaptcha/react-hcaptcha';
import HCaptcha from '@/components/HCaptcha';
import { useConfigStore } from '@/stores';
import 'react-toastify/dist/ReactToastify.css';
const toastConfig: ToastOptions = {
@@ -30,6 +31,10 @@ export default () => {
const captchaRef = useRef<HCaptchaType>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string>('');
// 获取HCaptcha配置
const config = useConfigStore();
const hasHCaptcha = !!config?.other?.hcaptcha_key;
// 获取网站类型列表
const [typeList, setTypeList] = useState<WebType[]>([]);
@@ -62,7 +67,8 @@ export default () => {
// 清除之前的人机验证错误
setCaptchaError('');
if (!captchaToken) return setCaptchaError('请完成人机验证');
// 只有配置了HCaptcha时才需要验证
if (hasHCaptcha && !captchaToken) return setCaptchaError('请完成人机验证');
setLoading(true);
const { code, message } = (await addWebDataAPI({ ...data, createTime: Date.now().toString(), h_captcha_response: captchaToken })) || { code: 0, message: '' };
@@ -210,10 +216,12 @@ export default () => {
/>
{/* 人机验证 */}
<div className="flex flex-col">
<HCaptcha ref={captchaRef} setToken={handleCaptchaSuccess} />
{captchaError && <span className="text-red-400 text-sm pl-3 mt-1">{captchaError}</span>}
</div>
{hasHCaptcha && (
<div className="flex flex-col">
<HCaptcha ref={captchaRef} setToken={handleCaptchaSuccess} />
{captchaError && <span className="text-red-400 text-sm pl-3 mt-1">{captchaError}</span>}
</div>
)}
</ModalBody>
<ModalFooter>

View File

@@ -1,4 +1,5 @@
import localFont from 'next/font/local';
import { Metadata } from 'next';
import HeroUIProvider from '@/components/HeroUIProvider';
import NProgress from '@/components/NProgress';
@@ -26,6 +27,72 @@ const LXGWWenKai = localFont({
display: 'swap',
});
// 生成动态metadata
export async function generateMetadata(): Promise<Metadata> {
const {
data: { value: data },
} = (await getWebConfigDataAPI<{ value: Web }>('web')) || { data: { value: {} as Web } };
return {
title: {
default: `${data?.title || 'ThriveX'} - ${data?.subhead || '现代化博客管理系统'}`,
template: `%s | ${data?.title || 'ThriveX'}`,
},
description: data?.description || 'ThriveX 现代化博客管理系统',
keywords: data?.keyword || 'ThriveX,博客,Blog',
authors: [{ name: data?.title || 'ThriveX' }],
creator: data?.title || 'ThriveX',
publisher: data?.title || 'ThriveX',
formatDetection: {
email: false,
address: false,
telephone: false,
},
metadataBase: new URL(data?.url || 'https://liuyuyang.net'),
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
locale: 'zh_CN',
url: data?.url || 'https://liuyuyang.net',
title: `${data?.title || 'ThriveX'} - ${data?.subhead || '现代化博客管理系统'}`,
description: data?.description || 'ThriveX 现代化博客管理系统',
siteName: data?.title || 'ThriveX',
images: [
{
url: data?.favicon || '/favicon.ico',
width: 1200,
height: 630,
alt: data?.title || 'ThriveX',
},
],
},
twitter: {
card: 'summary_large_image',
title: `${data?.title || 'ThriveX'} - ${data?.subhead || '现代化博客管理系统'}`,
description: data?.description || 'ThriveX 现代化博客管理系统',
images: [data?.favicon || '/favicon.ico'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: '/favicon.ico',
shortcut: '/favicon.ico',
apple: '/favicon.ico',
},
};
}
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const {
data: { value: data },
@@ -40,12 +107,14 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
return (
<html lang="zh-CN" className={LXGWWenKai.className}>
<head>
<title>{`${data?.title} - ${data?.subhead}`}</title>
<meta name="description" content={data?.description} />
<meta name="keywords" content={data?.keyword} />
<link rel="icon" type="image/x-icon" href={data?.favicon || '/favicon.ico'} />
<link rel="shortcut icon" type="image/x-icon" href={data?.favicon || '/favicon.ico'} />
{/* 动态favicon */}
{data?.favicon && (
<>
<link rel="icon" type="image/x-icon" href={data.favicon} />
<link rel="shortcut icon" type="image/x-icon" href={data.favicon} />
<link rel="apple-touch-icon" href={data.favicon} />
</>
)}
{/* 百度统计 */}
<BaiduStatis />
</head>

23
src/app/robots.ts Normal file
View File

@@ -0,0 +1,23 @@
import { MetadataRoute } from 'next';
import { getWebConfigDataAPI } from '@/api/config';
import { Web } from '@/types/app/config';
export default async function robots(): Promise<MetadataRoute.Robots> {
// 获取网站配置
const {
data: { value: webConfig },
} = (await getWebConfigDataAPI<{ value: Web }>('web')) || { data: { value: {} as Web } };
const baseUrl = webConfig?.url || 'https://liuyuyang.net';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

62
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,62 @@
import { MetadataRoute } from 'next';
import { getArticleListAPI } from '@/api/article';
import { getWebConfigDataAPI } from '@/api/config';
import { Web } from '@/types/app/config';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// 获取网站配置
const {
data: { value: webConfig },
} = (await getWebConfigDataAPI<{ value: Web }>('web')) || { data: { value: {} as Web } };
const baseUrl = webConfig?.url || 'https://liuyuyang.net';
// 获取所有文章
const { data: articles } = (await getArticleListAPI()) || { data: [] };
// 静态页面
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/tags`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${baseUrl}/friend`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/wall`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.6,
},
{
url: `${baseUrl}/record`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.6,
},
];
// 文章页面
const articlePages: MetadataRoute.Sitemap = articles
.filter((article) => article.id && article.createTime)
.map((article) => ({
url: `${baseUrl}/article/${article.id}`,
lastModified: new Date(+article.createTime),
changeFrequency: 'weekly' as const,
priority: 0.9,
}));
return [...staticPages, ...articlePages];
}

View File

@@ -8,6 +8,7 @@ import { addWallDataAPI, getCateListAPI } from '@/api/wall';
import { Bounce, toast, ToastContainer, ToastOptions } from 'react-toastify';
import HCaptchaType from '@hcaptcha/react-hcaptcha';
import HCaptcha from '@/components/HCaptcha';
import { useConfigStore } from '@/stores';
import 'react-toastify/dist/ReactToastify.css';
const toastConfig: ToastOptions = {
@@ -29,6 +30,10 @@ export default () => {
const captchaRef = useRef<HCaptchaType>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string>('');
// 获取HCaptcha配置
const config = useConfigStore();
const hasHCaptcha = !!config?.other?.hcaptcha_key;
// 获取留言分类列表
const [cateList, setCateList] = useState<Cate[]>([]);
@@ -61,7 +66,8 @@ export default () => {
// 清除之前的人机验证错误
setCaptchaError('');
if (!captchaToken) return setCaptchaError('请完成人机验证');
// 只有配置了HCaptcha时才需要验证
if (hasHCaptcha && !captchaToken) return setCaptchaError('请完成人机验证');
const { code, message } = (await addWallDataAPI({ ...data, createTime: Date.now().toString(), h_captcha_response: captchaToken })) || { code: 0, message: '' };
@@ -198,10 +204,12 @@ export default () => {
/>
{/* 人机验证 */}
<div className="flex flex-col">
<HCaptcha ref={captchaRef} setToken={handleCaptchaSuccess} />
{captchaError && <span className="text-red-400 text-sm pl-3 mt-1">{captchaError}</span>}
</div>
{hasHCaptcha && (
<div className="flex flex-col">
<HCaptcha ref={captchaRef} setToken={handleCaptchaSuccess} />
{captchaError && <span className="text-red-400 text-sm pl-3 mt-1">{captchaError}</span>}
</div>
)}
</ModalBody>
<ModalFooter>

View File

@@ -0,0 +1,65 @@
'use client';
import { useEffect, useRef } from 'react';
import Image from 'next/image';
import ICPIcon from '../../images/ICP.png';
interface ICPBeianProps {
icp?: string;
}
export default function ICPBeian({ icp }: ICPBeianProps) {
const icpRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 处理ICP备案HTML+JavaScript代码
if (icp && icpRef.current) {
// 检查是否包含HTML标签或script标签
if (icp.includes('<') || icp.includes('script')) {
icpRef.current.innerHTML = icp;
// 执行内嵌的script标签
const scripts = icpRef.current.getElementsByTagName('script');
Array.from(scripts).forEach((oldScript) => {
const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
newScript.textContent = oldScript.textContent;
oldScript.parentNode?.replaceChild(newScript, oldScript);
});
}
}
}, [icp]);
// 如果没有ICP不渲染
if (!icp) {
return null;
}
// 判断是否为HTML代码
const isHtml = icp.includes('<') || icp.includes('script');
return (
<div className="flex flex-col items-center gap-2 pb-4">
{/* ICP备案 - 纯文本显示图标+链接HTML直接渲染 */}
<div className="group flex justify-center items-center space-x-2 cursor-pointer">
{!isHtml && (
<Image src={ICPIcon} alt="ICP" width={20} height={22} className="w-5 h-[22px]" />
)}
{isHtml ? (
<div ref={icpRef} className="group-hover:text-primary flex items-center" />
) : (
<a
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="group-hover:text-primary"
>
{icp}
</a>
)}
</div>
</div>
);
}

View File

@@ -5,9 +5,9 @@ import { getAuthorDataAPI } from '@/api/user';
import { User } from '@/types/app/user';
import { Web } from '@/types/app/config';
import Tooltip from './components/Tooltip';
import ICPBeian from './components/ICPBeian';
import animals from './images/animals.webp';
import ICP from './images/ICP.png';
export default async () => {
const { data: user } = (await getAuthorDataAPI()) || { data: {} as User };
@@ -29,10 +29,8 @@ export default async () => {
<h2 className="w-[90%] xl:w-3/6 text-sm sm:text-base dark:text-[#8c9ab1] line-clamp-4">{web?.footer}</h2>
</div>
<div className="group flex justify-center space-x-2 pb-4 cursor-pointer">
<Image src={ICP} alt="ICP" width={20} height={22} className="w-5 h-[22px]" />
<span className="group-hover:text-primary">{web?.icp}</span>
</div>
{/* ICP备案支持普通ICP和萌ICP */}
<ICPBeian icp={web?.icp} />
{/*
为了项目的生态越来越强大,作者在这里恳请大家保留 ThriveX 博客系统版权

View File

@@ -6,6 +6,11 @@ export default forwardRef(({ setToken }: { setToken: (token: string) => void },
const config = useConfigStore();
const sitekey = config?.other?.hcaptcha_key;
// 如果没有配置 hcaptcha_key不渲染组件
if (!sitekey) {
return null;
}
return (
<div>
<HCaptcha theme={config.isDark ? 'dark' : 'light'} sitekey={sitekey} onVerify={setToken} ref={ref} />