文章列表・详情
博客的文章列表和文章页面。可以按分类筛选,点击卡片即可打开文章。
博客
要点
- 1
打开的文章,只记住 id
放进 state 的不是文章本身,而是打开的文章的 id。内容用 find 从列表中查找。这样即使列表变了,也不会对不上。
- 2
打开文章时,先返回详情
先写 if (opened) { return 详情 },后面再写列表。这样一个组件就能在两个页面之间切换。
- 3
给读完的人推荐下一篇
在文章下方显示同一分类的其他文章。用 filter 选出“同一分类,并且不是当前这篇”的文章。
使用的工具
点击查看第 2 部分的比较
修改代码
修改代码,右边的预览会跟着变化。
import { useState } from "react" import { ArrowLeftIcon, ClockIcon } from "lucide-react" import { Badge } from "./components/ui/badge" import { Button } from "./components/ui/button" // 文章列表(真实项目中从服务器或 CMS 获取) const articles = [ { id: 1, title: "用 3 个问题写出 useState", category: "React", date: "2026-10-02", minutes: 5, color: "from-sky-400 to-blue-600", excerpt: "回答“要改什么?”“用哪个 state?”“新值是什么?”这 3 个问题,就能写出修改 state 的那一行。", body: [ "不知道怎么写修改 state 的那一行时,就按顺序回答 3 个问题。", "第一,想改变页面上的什么?第二,记住它的是哪个 state?第三,新值是什么?", "答案都有了,剩下只要写 setXxx(新值) 就行了。", ], }, { id: 2, title: "间距统一用 4 的倍数", category: "设计", date: "2026-09-28", minutes: 3, color: "from-violet-400 to-fuchsia-500", excerpt: "只使用 4、8、12、16… 这样固定的间距,整个页面会显得更统一。", body: [ "间距如果凭感觉随手定,就会一点点错开,页面显得杂乱。", "在 Tailwind 中,p-1 是 4px,p-2 是 8px。只用这些档位,自然就是 4 的倍数。", ], }, { id: 3, title: "map 和 filter 的区别", category: "React", date: "2026-09-20", minutes: 4, color: "from-cyan-400 to-sky-600", excerpt: "map 逐个改写,filter 决定留不留。看数量会不会变,就能区分它们。", body: [ "map 会把列表中的每一项改写成别的东西,生成一个新列表。数量不变。", "filter 只保留符合条件的项,生成一个新列表。数量可能会减少。", ], }, { id: 4, title: "零经验的第一个作品集", category: "职业", date: "2026-09-12", minutes: 6, color: "from-amber-400 to-orange-500", excerpt: "比起作品数量,更重要的是“你是怎么想的”。哪怕只有一个,能讲清理由的作品就很有说服力。", body: [ "与其做很多,不如能讲清楚一个作品“为什么这样做”。", "在截图旁简单写上你花心思的地方,以及当时纠结的问题。", ], }, { id: 5, title: "颜色最多用 3 种", category: "设计", date: "2026-09-05", minutes: 3, color: "from-rose-400 to-pink-600", excerpt: "主色、强调色、文字颜色。有这 3 种,大多数页面都够用了。", body: [ "颜色越多,越让人不知道该看哪里。", "先定一种主色,只在需要强调的地方使用强调色。", ], }, { id: 6, title: "坚持每天学习 30 分钟的诀窍", category: "职业", date: "2026-08-30", minutes: 4, color: "from-emerald-400 to-teal-600", excerpt: "先决定“停在哪里”,而不是“什么时候学”。停在清楚下一步的地方,第二天就能马上开始。", body: [ "学不下去,多半是因为每次开始时都得先想“要做什么”。", "每天结束前,用一行字记下接下来要做的事,第二天直接从那里开始就行。", ], }, ] const categories = ["全部", "React", "设计", "职业"] // 文章的彩色横幅(代替图片) function Cover({ article, className }) { return <div className={"bg-linear-to-br " + article.color + " " + className} /> } export default function App() { // ① 当前选中的分类(一开始是 "全部"),放在 state 里 //const category = "全部" const [category, setCategory] = useState("全部") // ① 当前打开的文章 id(浏览列表时为 null),放在 state 里 //const openId = null const [openId, setOpenId] = useState(null) // ⑤ 当前打开的文章:在 articles 中查找 id 等于 openId 的文章(find) //const opened = undefined const opened = articles.find((article) => article.id === openId) //const opened = undefined //const shown = articles.filter((article) => category === "全部" || article.category === category) // ⑥ 打开了文章时,先返回详情页 // 条件:是否有打开的文章 if (opened) { // 同一分类下的其他文章 const related = articles.filter((article) => article.category === opened.category && article.id !== opened.id) return ( <div className="mx-auto my-6 w-full max-w-2xl px-4 text-foreground"> {/* ⑦ 点击后返回列表(把打开的文章设为“无”) */} <Button variant="ghost" size="sm" className="-ml-2" onClick={() => setOpenId(null)} > <ArrowLeftIcon /> 返回文章列表 </Button> <Cover article={opened} className="mt-4 h-48 rounded-xl" /> <div className="mt-6 flex items-center gap-3 text-xs text-muted-foreground"> <Badge variant="secondary">{opened.category}</Badge> <span>{opened.date}</span> <span className="flex items-center gap-1"> <ClockIcon className="size-3" /> 阅读约需 {opened.minutes} 分钟 </span> </div> <h1 className="mt-3 text-2xl font-bold tracking-tight">{opened.title}</h1> <div className="mt-5 flex flex-col gap-4 leading-relaxed text-foreground/80"> {opened.body.map((paragraph) => ( <p key={paragraph}>{paragraph}</p> ))} </div> {related.length > 0 && ( <div className="mt-10 border-t pt-6"> <p className="text-sm font-semibold">同分类的文章</p> <div className="mt-3 flex flex-col gap-2"> {related.map((article) => ( <button key={article.id} type="button" onClick={() => setOpenId(article.id)} className="flex items-center gap-3 rounded-lg p-2 text-left text-sm hover:bg-muted" > <Cover article={article} className="size-10 shrink-0 rounded-md" /> {article.title} </button> ))} </div> </div> )} </div> ) } // ③ 要显示的文章:只显示选中分类的文章(选“全部”时显示所有) const shown = articles.filter((article) => category === "全部" || article.category === category) //const filtered = products.filter((product) => product.name.includes(query) && (category === "全部" || product.category === category)) return ( <div className="mx-auto my-6 w-full max-w-4xl px-4 text-foreground"> <h1 className="text-xl font-semibold">博客</h1> <div className="mt-4 flex flex-wrap gap-2"> {/* // ② 点击后选中该分类。选中的按钮是实心(default),其他是描边(outline) */} { categories.map((name) => ( <Button key={name} size="sm" variant={category === name ? "default" : "outline"} onClick={() => setCategory(name)}> {name} </Button> )) } </div> <div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> {/* ④ 用 map 排列 shown,点击后打开那篇文章 */} {shown.map((article) => ( <button key={article.id} type="button" className="group flex flex-col overflow-hidden rounded-xl border bg-card text-left transition hover:-translate-y-0.5 hover:shadow-md" onClick={() => setOpenId(article.id)} > <Cover article={article} className="h-32" /> <div className="flex flex-1 flex-col p-4"> <div className="flex items-center gap-2 text-xs text-muted-foreground"> <Badge variant="secondary">{article.category}</Badge> <span>{article.date}</span> </div> <p className="mt-2 font-semibold leading-snug group-hover:text-primary">{article.title}</p> <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{article.excerpt}</p> </div> </button> ))} </div> </div > ) }