文章列表・详情

博客的文章列表和文章页面。可以按分类筛选,点击卡片即可打开文章。

博客

要点

  1. 1

    打开的文章,只记住 id

    放进 state 的不是文章本身,而是打开的文章的 id。内容用 find 从列表中查找。这样即使列表变了,也不会对不上。

  2. 2

    打开文章时,先返回详情

    先写 if (opened) { return 详情 },后面再写列表。这样一个组件就能在两个页面之间切换。

  3. 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 >
  )
}