From 2fe7b65090cd3195f7554bfd955734ea0c608428 Mon Sep 17 00:00:00 2001 From: sonder Date: Wed, 19 Nov 2025 03:55:54 +0800 Subject: [PATCH] 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 --- src/app/article/[id]/page.tsx | 55 +++++++++++++ .../Comment/components/List/index.tsx | 2 + src/app/article/components/Comment/index.tsx | 18 +++-- .../article/components/Copyright/index.tsx | 2 +- .../friend/components/ApplyForAdd/index.tsx | 18 +++-- src/app/layout.tsx | 81 +++++++++++++++++-- src/app/robots.ts | 23 ++++++ src/app/sitemap.ts | 62 ++++++++++++++ src/app/wall/components/AddWallInfo/index.tsx | 18 +++-- .../Footer/components/ICPBeian/index.tsx | 65 +++++++++++++++ src/components/Footer/index.tsx | 8 +- src/components/HCaptcha/index.tsx | 5 ++ 12 files changed, 330 insertions(+), 27 deletions(-) create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/components/Footer/components/ICPBeian/index.tsx diff --git a/src/app/article/[id]/page.tsx b/src/app/article/[id]/page.tsx index e2f4677..f3195e7 100755 --- a/src/app/article/[id]/page.tsx +++ b/src/app/article/[id]/page.tsx @@ -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 { + 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; diff --git a/src/app/article/components/Comment/components/List/index.tsx b/src/app/article/components/Comment/components/List/index.tsx index 66e6fc8..2e2190e 100755 --- a/src/app/article/components/Comment/components/List/index.tsx +++ b/src/app/article/components/Comment/components/List/index.tsx @@ -1,3 +1,5 @@ +'use client'; + import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import Link from 'next/link'; import Show from '@/components/Show'; diff --git a/src/app/article/components/Comment/index.tsx b/src/app/article/components/Comment/index.tsx index 8452a50..e12cd09 100755 --- a/src/app/article/components/Comment/index.tsx +++ b/src/app/article/components/Comment/index.tsx @@ -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(null); const [captchaToken, setCaptchaToken] = useState(null); const [captchaError, setCaptchaError] = useState(''); + + // 获取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) => { {errors.url?.message} -
- - {captchaError && {captchaError}} -
+ {hasHCaptcha && ( +
+ + {captchaError && {captchaError}} +
+ )} {loading ? (
diff --git a/src/app/article/components/Copyright/index.tsx b/src/app/article/components/Copyright/index.tsx index 0f94ab3..69666c7 100755 --- a/src/app/article/components/Copyright/index.tsx +++ b/src/app/article/components/Copyright/index.tsx @@ -2,7 +2,7 @@ import { useAuthorStore } from '@/stores'; -const Copyright = async () => { +const Copyright = () => { const author = useAuthorStore((state) => state.author); return ( diff --git a/src/app/friend/components/ApplyForAdd/index.tsx b/src/app/friend/components/ApplyForAdd/index.tsx index b817829..9ee501a 100755 --- a/src/app/friend/components/ApplyForAdd/index.tsx +++ b/src/app/friend/components/ApplyForAdd/index.tsx @@ -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(null); const [captchaToken, setCaptchaToken] = useState(null); const [captchaError, setCaptchaError] = useState(''); + + // 获取HCaptcha配置 + const config = useConfigStore(); + const hasHCaptcha = !!config?.other?.hcaptcha_key; // 获取网站类型列表 const [typeList, setTypeList] = useState([]); @@ -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 () => { /> {/* 人机验证 */} -
- - {captchaError && {captchaError}} -
+ {hasHCaptcha && ( +
+ + {captchaError && {captchaError}} +
+ )} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 972ac04..8255f7b 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { + 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 ( - {`${data?.title} - ${data?.subhead}`} - - - - - + {/* 动态favicon */} + {data?.favicon && ( + <> + + + + + )} {/* 百度统计 */} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..2ff2735 --- /dev/null +++ b/src/app/robots.ts @@ -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 { + // 获取网站配置 + 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`, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..8d5f238 --- /dev/null +++ b/src/app/sitemap.ts @@ -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 { + // 获取网站配置 + 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]; +} diff --git a/src/app/wall/components/AddWallInfo/index.tsx b/src/app/wall/components/AddWallInfo/index.tsx index 80ba7eb..9a526ea 100755 --- a/src/app/wall/components/AddWallInfo/index.tsx +++ b/src/app/wall/components/AddWallInfo/index.tsx @@ -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(null); const [captchaToken, setCaptchaToken] = useState(null); const [captchaError, setCaptchaError] = useState(''); + + // 获取HCaptcha配置 + const config = useConfigStore(); + const hasHCaptcha = !!config?.other?.hcaptcha_key; // 获取留言分类列表 const [cateList, setCateList] = useState([]); @@ -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 () => { /> {/* 人机验证 */} -
- - {captchaError && {captchaError}} -
+ {hasHCaptcha && ( +
+ + {captchaError && {captchaError}} +
+ )} diff --git a/src/components/Footer/components/ICPBeian/index.tsx b/src/components/Footer/components/ICPBeian/index.tsx new file mode 100644 index 0000000..4b8db53 --- /dev/null +++ b/src/components/Footer/components/ICPBeian/index.tsx @@ -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(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 ( +
+ {/* ICP备案 - 纯文本显示图标+链接,HTML直接渲染 */} +
+ {!isHtml && ( + ICP + )} + {isHtml ? ( +
+ ) : ( + + {icp} + + )} +
+
+ ); +} diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index 58b1423..d5c4c3c 100755 --- a/src/components/Footer/index.tsx +++ b/src/components/Footer/index.tsx @@ -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 () => {

{web?.footer}

-
- ICP - {web?.icp} -
+ {/* ICP备案(支持普通ICP和萌ICP) */} + {/* 为了项目的生态越来越强大,作者在这里恳请大家保留 ThriveX 博客系统版权 diff --git a/src/components/HCaptcha/index.tsx b/src/components/HCaptcha/index.tsx index f7460b2..dd68c10 100644 --- a/src/components/HCaptcha/index.tsx +++ b/src/components/HCaptcha/index.tsx @@ -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 (