├── .env.example ├── .eslintrc.json ├── .gitignore ├── .idea ├── .gitignore ├── blog-nextjs.iml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml └── vcs.xml ├── README.md ├── next.config.mjs ├── package.json ├── postcss.config.js ├── public ├── loading.gif ├── next.svg └── vercel.svg ├── src ├── app │ ├── about │ │ └── page.tsx │ ├── detail │ │ └── [id] │ │ │ ├── Avatar.tsx │ │ │ ├── Comment.tsx │ │ │ ├── CommentForm.tsx │ │ │ ├── Content.tsx │ │ │ ├── Copy.tsx │ │ │ ├── Nav.tsx │ │ │ ├── Recommend.tsx │ │ │ ├── Title.tsx │ │ │ └── page.tsx │ ├── error.tsx │ ├── favicon.ico │ ├── home │ │ ├── Articles.tsx │ │ ├── Aside.tsx │ │ ├── Category.tsx │ │ ├── FullPage.tsx │ │ ├── Notice.tsx │ │ ├── Profile.tsx │ │ ├── RecentUpdate.tsx │ │ └── Tags.tsx │ ├── layout.tsx │ ├── layout │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Nav.tsx │ │ ├── Search.tsx │ │ ├── Statistics.tsx │ │ └── error.tsx │ ├── loading.tsx │ ├── not-found.tsx │ ├── onlineTools │ │ ├── excalidraw │ │ │ ├── Excalidraw.tsx │ │ │ └── page.tsx │ │ └── imageZip │ │ │ ├── Introduction.tsx │ │ │ ├── Zip.tsx │ │ │ ├── page.tsx │ │ │ └── utils.ts │ ├── page.tsx │ ├── robots.ts │ ├── sitemap.ts │ └── timeLine │ │ └── page.tsx ├── assets │ ├── js │ │ └── ribbon.js │ └── style │ │ ├── common.scss │ │ ├── detail.scss │ │ ├── globals.css │ │ └── loading.scss ├── hooks │ └── useFetch.ts ├── types │ ├── article.typings.d.ts │ ├── aside.typings.d.ts │ ├── comment.typings.d.ts │ └── common.typings.d.ts └── utils │ ├── common.ts │ ├── fetch.ts │ └── metadata.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CRYPTO_SECRET_KEY= 2 | NEXT_PUBLIC_BASE_URL= 3 | NEXT_PUBLIC_HOST= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | pnpm-lock.yaml 38 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/blog-nextjs.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: false, 4 | async rewrites() { 5 | return [ 6 | { 7 | source: '/blog/:path*', 8 | destination: 'https://server.jszoo.com/blog/:path*', 9 | }, 10 | { 11 | source: '/image-proxy/:path*', 12 | destination: 'https://jszoo-file.oss-cn-beijing.aliyuncs.com/:path*', 13 | }, 14 | { 15 | source: '/ip/:path*', 16 | destination: 'http://ip-api.com/:path*', 17 | }, 18 | ]; 19 | }, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ant-design/icons": "^5.2.6", 13 | "@excalidraw/excalidraw": "^0.17.2", 14 | "antd": "^5.13.2", 15 | "avataaars": "^2.0.0", 16 | "crypto-js": "^4.2.0", 17 | "file-saver": "^2.0.5", 18 | "jszip": "^3.10.1", 19 | "lodash": "^4.17.21", 20 | "markdown-navbar": "^1.4.3", 21 | "next": "14.1.0", 22 | "platform": "^1.3.6", 23 | "react": "^18", 24 | "react-dom": "^18", 25 | "react-markdown": "^9.0.1", 26 | "react-syntax-highlighter": "^15.5.0", 27 | "rehype-raw": "^7.0.0", 28 | "remark-gfm": "^4.0.0", 29 | "sass": "^1.70.0" 30 | }, 31 | "devDependencies": { 32 | "@types/crypto-js": "^4.2.2", 33 | "@types/file-saver": "^2.0.7", 34 | "@types/lodash": "^4.14.202", 35 | "@types/markdown-navbar": "^1.4.4", 36 | "@types/node": "^20", 37 | "@types/platform": "^1.3.6", 38 | "@types/react": "^18", 39 | "@types/react-dom": "^18", 40 | "@types/react-syntax-highlighter": "^15.5.11", 41 | "autoprefixer": "^10.0.1", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.1.0", 44 | "postcss": "^8", 45 | "tailwindcss": "^3.3.0", 46 | "typescript": "^5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpectreAlan/blog-nextjs/3d3c243a585f09e806fc17c3151c41a9fdbcbcb5/public/loading.gif -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Descriptions, DescriptionsProps, Divider} from 'antd' 3 | 4 | import {GithubOutlined, TwitterOutlined} from '@ant-design/icons' 5 | import {Metadata} from "next"; 6 | 7 | export const metadata: Metadata = { 8 | title: '关于 - SpectreAlan', 9 | description: '近些年一直从事web前端开发工作,熟悉前端主要技术栈,平时喜欢研究web前端、服务器方面的各种技术', 10 | keywords: 'vercel,NestJS,NextJS,Umi Max,MongoDb,阿里云OSS,Antd,Sass' 11 | }; 12 | 13 | const About = () => { 14 | const me: DescriptionsProps['items'] = [ 15 | { 16 | key: 'name', 17 | label: 'Name', 18 | children: 'SpectreAlan' 19 | }, 20 | { 21 | key: 'email', 22 | label: 'Email', 23 | children: 'comjszoo@gmail.com' 24 | }, 25 | { 26 | key: 'hometown', 27 | label: 'Hometown', 28 | children: '四川 - 成都' 29 | }, 30 | { 31 | key: 'live', 32 | label: 'Live', 33 | children: 'Singapore' 34 | }, 35 | { 36 | key: 'contacts', 37 | label: 'Other Contacts', 38 | children: <> 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | }, 47 | { 48 | key: 'summary', 49 | label: 'Summary', 50 | children: metadata.description 51 | }, 52 | { 53 | key: 'web', 54 | label: 'Skills - 前端', 55 | children: <> 56 | 技术栈:Vue、React
57 | UI框架:ElementUI、Vant、Antd-design等
58 | 移动端:Flutter、React Native、UniApp、Taro 59 | 60 | }, 61 | { 62 | key: 'backed', 63 | label: 'Skills - 后端', 64 | children: <> 65 | 主要技术栈:NodeJs、Mysql、MongoDB
66 | 框架:NestJS、Koa2、Egg.js 67 | 68 | } 69 | ] 70 | const blog: DescriptionsProps['items'] = [ 71 | { 72 | key: '1', 73 | label: '博客前台', 74 | children: <> 基于Next.js 14.1开发
75 | UI层使用Antd、Sass、Tailwind
76 | 数据请求使用Fetch
77 | 解析markdown采用 marked
78 | 生成文章目录使用 markdown-navbar ,语法高亮 highlight.js, 79 | }, 80 | { 81 | key: 'blog-nextjs', 82 | label: '前台源码', 83 | children: 84 | blog-nextjs 88 | 89 | }, 90 | { 91 | key: '3', 92 | label: '后台管理系统', 93 | children: <>基于Umijs max开发
94 | UI层使用 @ant-design/pro-components
95 | 图表采用react-echarts
96 | 新建/编辑文章使用 @toast-ui/editor 97 | }, 98 | { 99 | key: 'blog-admin-umijs-max', 100 | label: '后台管理源码', 101 | children: 102 | blog-admin-umijs-max 106 | 107 | }, 108 | { 109 | key: '5', 110 | label: '中台数据接口', 111 | children: <>基于NestJS + MongoDB开发
112 | 所有的图片使用阿里云OSS存储
113 | 定时任务每天获取一言、Bing壁纸
114 | 响应数据通过crypto-js加密
115 | 后台服务通过Vercel免费部署
116 | 数据库使用MongoDB Cloud免费部署 117 | }, 118 | { 119 | key: 'blog-server-nestjs-vercel', 120 | label: '中台源码', 121 | children: 122 | blog-server-nestjs-vercel 126 | 127 | }, 128 | ] 129 | return <> 130 |
134 |
135 |

关于我 136 | - SpectreAlan

137 |
138 |
139 |
140 | 141 | 142 | 143 |
144 |
145 | 146 | } 147 | export default About 148 | -------------------------------------------------------------------------------- /src/app/detail/[id]/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Avatar from 'avataaars'; 3 | 4 | const getRandomValue = (array: any) => array[Math.floor(Math.random() * array.length)]; 5 | const RandomAvatar = () => { 6 | const randomOptions = { 7 | topType: ['NoHair', 'ShortHairShortCurly', 'Eyepatch', 'Hat'], 8 | accessoriesType: ['Blank', 'Round', 'Sunglasses'], 9 | facialHairType: ['BeardMedium', 'MoustacheFancy', 'Blank'], 10 | clotheType: ['Hoodie', 'Overall', 'ShirtCrewNeck'], 11 | eyeType: ['Happy', 'Squint', 'Dizzy'], 12 | eyebrowType: ['Angry', 'FlatNatural', 'Default'], 13 | mouthType: ['Twinkle', 'Disbelief', 'Serious'], 14 | skinColor: ['Light', 'Tanned', 'Yellow'], 15 | }; 16 | 17 | const randomAvatarOptions = Object.fromEntries( 18 | Object.entries(randomOptions).map(([key, value]) => [key, getRandomValue(value)]) 19 | ); 20 | 21 | return ( 22 |
23 | 28 |
29 | ); 30 | }; 31 | export default RandomAvatar; 32 | -------------------------------------------------------------------------------- /src/app/detail/[id]/Comment.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React, {useState, useEffect} from 'react' 3 | import {Divider, Spin, Row, Col, Button, Tag, Avatar} from 'antd' 4 | import httpRequest from "@/utils/fetch"; 5 | import {CommentOutlined, GlobalOutlined, EnvironmentOutlined} from '@ant-design/icons' 6 | import RandomAvatar from '@/app/detail/[id]/Avatar' 7 | import CommentForm from "@/app/detail/[id]/CommentForm"; 8 | 9 | const Comment: React.FC<{ id: string }> = ({id}) => { 10 | const defaultInfo: Comment.Info = { 11 | article: id, 12 | parentId: '-1' 13 | } 14 | const [loading, setLoading] = useState(false) 15 | const [info, setInfo] = useState(defaultInfo) 16 | const [comments, setComments] = useState([]) 17 | const queryComments = async () => { 18 | setLoading(true) 19 | const res: { list: Comment.Item[] } | null = await httpRequest({ 20 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/comment`, 21 | data: {id} 22 | }) 23 | setLoading(false) 24 | const list = res?.list ?? [] 25 | setComments(generateComments(list)) 26 | } 27 | const generateComments = (list: Comment.Item[]): Comment.Item[] => { 28 | const hash: { [key: string]: Comment.Item[] } = {} 29 | let topLevel: Comment.Item[] = [] 30 | const secondLevel: Comment.Item[] = [] 31 | for (let i = 0; i < list.length; i++) { 32 | if (list[i].parentId === '-1') { 33 | topLevel.push(list[i]) 34 | hash[list[i].id] = [] 35 | } else { 36 | secondLevel.push(list[i]) 37 | } 38 | } 39 | for (let i = 0; i < secondLevel.length; i++) { 40 | secondLevel[i].parentName = topLevel.find(item => item.id === secondLevel[i].parentId)!.nickName 41 | hash[secondLevel[i].parentId].push(secondLevel[i]) 42 | } 43 | topLevel.sort((a, b) => (new Date(a.createdAt) as any) - (new Date(b.createdAt) as any)) 44 | 45 | for (let i = 0; i < list.length; i++) { 46 | const temp: Comment.Item[] = hash[topLevel[i].id] 47 | if (temp && temp.length > 0) { 48 | const l = topLevel.slice(0, i + 1) 49 | const r = topLevel.slice(i + 1, topLevel.length) 50 | temp.sort((a, b) => (new Date(a.createdAt) as any) - (new Date(b.createdAt) as any)) 51 | topLevel = l.concat(temp, r) 52 | i += temp.length 53 | } 54 | } 55 | const i = topLevel.findIndex(item => item.pinned) 56 | if (i > 0) { 57 | const o = {...topLevel[i]} 58 | topLevel.splice(i, 1); 59 | topLevel.unshift(o); 60 | } 61 | return topLevel 62 | } 63 | const handelFinished = () => { 64 | setInfo(defaultInfo) 65 | queryComments() 66 | } 67 | 68 | useEffect(() => { 69 | queryComments() 70 | }, []) 71 | 72 | return <> 73 | 74 |
75 |
吐槽一下
76 | { 77 | info.parentId === '-1' && 78 | } 79 | 80 | { 81 | comments.map((comment, i) => ( 82 |
84 | 85 | 86 | { 87 | comment.author ? 88 | : 90 | } 91 | 92 | 93 | {comment.pinned ? 置顶 : ( 94 |
95 | { 96 | comment.nickName ? 97 | {comment.nickName} : 98 | 博主 99 | } 100 | { 101 | comment.parentName && 102 | @ {comment.parentName} 103 | } 104 | { 105 | comment?.platform && {comment.platform} 106 | } 107 | {comment.region} 108 |
109 | )} 110 |
{comment.createdAt}
111 | {comment.content} 112 | 113 | 114 | { 115 | info.parentId === comment.id ? 116 | : 117 | 122 | } 123 | 124 |
125 | { 126 | info.parentId === comment.id && 127 |
128 | } 129 |
)) 130 | } 131 |
132 |
133 | 134 | } 135 | 136 | export default Comment -------------------------------------------------------------------------------- /src/app/detail/[id]/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import {Button, Form, Input} from 'antd'; 2 | import {MailOutlined, MessageOutlined, UserOutlined} from '@ant-design/icons'; 3 | import React, {useState} from 'react'; 4 | import httpRequest from "@/utils/fetch"; 5 | 6 | interface IProps { 7 | info: Comment.Info 8 | handelFinished: () => void 9 | } 10 | 11 | const CommentForm: React.FC = ({info, handelFinished}) => { 12 | const [loading, setLoading] = useState(false) 13 | 14 | const [form] = Form.useForm() 15 | 16 | const placeholder = info?.nickName ? '@' + info?.nickName : 'Talk is cheap,show me the code!' 17 | const onFinish = async (values: { [key: string]: string }) => { 18 | setLoading(true) 19 | const deviceInfo: { [key: string]: string } = {} 20 | const deviceJSON = sessionStorage.getItem('device') 21 | if (deviceJSON) { 22 | const {province, os} = JSON.parse(deviceJSON) 23 | deviceInfo.platform = os 24 | deviceInfo.region = province 25 | } 26 | httpRequest({ 27 | url: '/blog/comment', 28 | method: 'POST', 29 | data: { 30 | ...values, 31 | ...info, 32 | ...deviceInfo 33 | } 34 | }).then(() => { 35 | setLoading(false) 36 | form.resetFields() 37 | handelFinished() 38 | }).catch(() => { 39 | setLoading(false) 40 | }) 41 | }; 42 | return (
43 |  昵称 } name="nickName" 45 | rules={[{required: true, message: '请输入您的昵称!',}]} 46 | > 47 | 48 | 49 |  邮箱 } name="email" 51 | rules={[{type: 'email', message: '邮箱格式有误!',}, { 52 | required: true, 53 | message: '必填!但不会公开,站长回复以后会收到邮件通知', 54 | }]} 55 | > 56 | 57 | 58 |  评论 } name="content" 60 | rules={[{required: true, message: '请输入您的评论!',}]} 61 | > 62 | 63 | 64 | 65 | 66 | 67 |
) 68 | } 69 | export default CommentForm 70 | -------------------------------------------------------------------------------- /src/app/detail/[id]/Content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Markdown from 'react-markdown' 3 | import remarkGfm from 'remark-gfm' 4 | import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter' 5 | import {vscDarkPlus} from 'react-syntax-highlighter/dist/esm/styles/prism' 6 | import rehypeRaw from 'rehype-raw'; 7 | import '@/assets/style/detail.scss' 8 | import Copy from "@/app/detail/[id]/Copy"; 9 | 10 | 11 | const Content: React.FC<{ content: string }> = ({content}) => { 12 | 13 | return
14 | 2 23 | return match ? ( 24 |
25 | 26 | 35 | {String(children).replace(/\n$/, '')} 36 | 37 |
38 | 39 | ) : ( 40 | 41 | {children} 42 | 43 | ) 44 | } 45 | }} 46 | > 47 | {content} 48 |
49 |
50 | } 51 | 52 | export default Content -------------------------------------------------------------------------------- /src/app/detail/[id]/Copy.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React from "react"; 3 | import {CopyOutlined} from "@ant-design/icons"; 4 | import {message} from 'antd' 5 | 6 | const Copy: React.FC<{ code: string }> = ({code}) => { 7 | 8 | const copy = () => { 9 | navigator.clipboard.writeText(code) 10 | message.success('已复制到剪切板') 11 | } 12 | 13 | return copy()} 17 | title='点击复制' 18 | /> 19 | } 20 | 21 | export default Copy -------------------------------------------------------------------------------- /src/app/detail/[id]/Nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import React from 'react' 3 | import MarkdownNavbar from 'markdown-navbar' 4 | import 'markdown-navbar/dist/navbar.css'; 5 | const Nav:React.FC<{content: string}> = ({content})=>{ 6 | return
7 |

文章目录

8 | 13 |
14 | } 15 | 16 | export default Nav -------------------------------------------------------------------------------- /src/app/detail/[id]/Recommend.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Divider} from 'antd' 3 | import httpRequest from "@/utils/fetch"; 4 | import {LikeFilled} from '@ant-design/icons' 5 | import Link from 'next/link' 6 | import Image from 'next/image' 7 | 8 | const Recommend: React.FC<{ tags: string, id: string }> = async ({tags, id}) => { 9 | const res: { list: Article.ArticleItem[] } | null = await httpRequest({ 10 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/related`, 11 | data: {tags} 12 | }) 13 | if (!res) { 14 | return null 15 | } 16 | const recommend: Article.ArticleItem[] = res.list.filter(article => article.id !== id) 17 | return <> 18 | 19 |
20 |
相关推荐
21 |
22 | { 23 | recommend.map((article) => ( 24 | 30 |
31 | {article.title} 39 |
{article.title}
41 | {article.createdAt} 42 |
43 | 44 | )) 45 | } 46 |
47 |
48 | 49 | } 50 | 51 | export default Recommend -------------------------------------------------------------------------------- /src/app/detail/[id]/Title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Space, Tag} from "antd"; 3 | import { 4 | ClockCircleOutlined, 5 | InboxOutlined, 6 | ProfileOutlined, 7 | RiseOutlined, 8 | ScheduleOutlined, 9 | TagOutlined 10 | } from "@ant-design/icons"; 11 | 12 | const colors: string[] = ['green', 'cyan', 'blue', 'geekblue', 'purple', 'lime', 'gold'] 13 | 14 | const Title: React.FC<{ detail: Article.Detail }> = ({detail}) => { 15 | const {content, createdAt, updatedAt, scan, title, category, tags} = detail 16 | return
22 |
23 |
26 | {title} 27 |
28 | 标签: 29 | 30 | {tags.map((tag, i) => {tag.title})} 31 | 32 |
33 |
34 | 类别:{category.title} 35 |
36 |
37 | 创建时间: {createdAt} 38 |
39 |
40 | 41 | 字数总计: {(content.length / 1000).toFixed(2) + ' k'} 42 | 建议阅读时长: {Math.ceil(content.length / 1000) || 1} 分钟 44 | 阅读量: {scan} 45 | 46 |
47 |
48 |
49 | } 50 | export default Title -------------------------------------------------------------------------------- /src/app/detail/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import httpRequest from "@/utils/fetch"; 3 | import Nav from '@/app/detail/[id]/Nav' 4 | import Title from '@/app/detail/[id]/Title' 5 | import Content from '@/app/detail/[id]/Content' 6 | import Recommend from '@/app/detail/[id]/Recommend' 7 | import Comment from '@/app/detail/[id]/Comment' 8 | import Error from '@/app/layout/error' 9 | import {Metadata} from 'next' 10 | import DefaultMetadata from '@/utils/metadata' 11 | 12 | type Props = { 13 | params: { id: string } 14 | } 15 | 16 | export async function generateMetadata( 17 | {params}: Props 18 | ): Promise { 19 | const detail: Article.Detail | null = await httpRequest({ 20 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/detail`, 21 | data: params, 22 | options: { 23 | cache: 'no-store' 24 | } 25 | }) 26 | if (detail) { 27 | const {title, description, category, tags} = detail 28 | return { 29 | title, 30 | description, 31 | category: category.title, 32 | keywords: tags.map(item => item.title).join(',') 33 | } 34 | } 35 | return DefaultMetadata 36 | } 37 | 38 | const DetailPage = async ({params}: { params: { id: string } }) => { 39 | const {id} = params 40 | const detail: Article.Detail | null = await httpRequest({ 41 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/detail`, 42 | data: {id}, 43 | options: { 44 | cache: 'no-store' 45 | } 46 | }) 47 | if (!detail) { 48 | return 49 | } 50 | const {content, tags, catalogue} = detail 51 | const tagsString = tags.map(tag => tag.title).join(',') 52 | return
53 | { 54 | catalogue &&