mirror of
https://github.com/LiuYuYang01/ThriveX-Blog.git
synced 2026-05-06 22:03:08 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Show from '@/components/Show';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useAuthorStore } from '@/stores';
|
||||
|
||||
const Copyright = async () => {
|
||||
const Copyright = () => {
|
||||
const author = useAuthorStore((state) => state.author);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
23
src/app/robots.ts
Normal 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
62
src/app/sitemap.ts
Normal 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];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
65
src/components/Footer/components/ICPBeian/index.tsx
Normal file
65
src/components/Footer/components/ICPBeian/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 博客系统版权
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user