以 Go + Echo 打造部落格|第 5 篇(完整 Markdown)
今天讓首頁真的「像部落格」!我們把資料庫裡 已發佈(published) 的文章抓出來,做成列表,並加上 分頁(上一頁/下一頁)。完成後,使用者打開
/
就能看到最新文章;草稿(draft)只在後台看得到,不會外流 👍
小辭典:
分頁(pagination):把很多資料切成一頁一頁,避免一次塞爆畫面與網路。
published
的文章,組好分頁資料。/api/posts
公開 API(只回已發佈)。?page=1..N
切換。下方是「新增或更新」的檔案。放好後
make run
就能測。
internal/http/handlers/front_posts.go
(新增)
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"example.com/go-echo-blog/internal/storage/postgres"
)
type FrontPostsHandler struct {
Repo *postgres.PostRepo
SiteName string
PerPage int
}
func NewFrontPostsHandler(r *postgres.PostRepo, site string, perPage int) *FrontPostsHandler {
if perPage <= 0 { perPage = 5 }
return &FrontPostsHandler{Repo: r, SiteName: site, PerPage: perPage}
}
type postCard struct {
ID int64
Title string
Slug string
Summary string
Date string // yyyy-mm-dd
URL string // 先預留 /posts/:slug,下一篇會實作內頁
Status string
}
func (h *FrontPostsHandler) Home(c echo.Context) error {
page := 1
if v := c.QueryParam("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n }
}
items, total, err := h.Repo.List(c.Request().Context(), postgres.ListPostsOpts{
Page: page, PerPage: h.PerPage, Status: "published",
})
if err != nil { return c.String(http.StatusInternalServerError, err.Error()) }
// 轉為模板可用的卡片資料
cards := make([]postCard, 0, len(items))
for _, p := range items {
d := p.PublishedAt
if d == nil {
// 理論上 published 一定有時間,但保險起見 fallback
t := p.UpdatedAt
d = &t
}
tt := d.In(time.FixedZone("Asia/Taipei", 8*60*60))
sum := ""
if p.Summary != nil { sum = *p.Summary }
cards = append(cards, postCard{
ID: p.ID, Title: p.Title, Slug: p.Slug, Summary: sum,
Date: tt.Format("2006-01-02"),
URL: "/posts/" + p.Slug, // 下一篇會實作 /posts/:slug
Status: p.Status,
})
}
// 分頁資訊
totalPages := (total + h.PerPage - 1) / h.PerPage
if totalPages == 0 { totalPages = 1 }
hasPrev := page > 1
hasNext := page < totalPages
prevPage := page - 1
nextPage := page + 1
now := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60))
data := map[string]any{
"Title": "首頁",
"SiteName": h.SiteName,
"Year": now.Year(),
"Now": now.Format("2006-01-02 15:04:05"),
"Posts": cards,
"Page": page,
"Total": total,
"PerPage": h.PerPage,
"TotalPages": totalPages,
"HasPrev": hasPrev,
"HasNext": hasNext,
"PrevPage": prevPage,
"NextPage": nextPage,
}
return c.Render(http.StatusOK, "pages/index.html", data)
}
internal/http/handlers/api_public.go
(新增)
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"example.com/go-echo-blog/internal/storage/postgres"
)
type APIPublicHandler struct {
Repo *postgres.PostRepo
PerPage int
}
func NewAPIPublicHandler(r *postgres.PostRepo, perPage int) *APIPublicHandler {
if perPage <= 0 { perPage = 5 }
return &APIPublicHandler{Repo: r, PerPage: perPage}
}
func (h *APIPublicHandler) List(c echo.Context) error {
page := 1
if v := c.QueryParam("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n }
}
items, total, err := h.Repo.List(c.Request().Context(), postgres.ListPostsOpts{
Page: page, PerPage: h.PerPage, Status: "published",
})
if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()}) }
return c.JSON(http.StatusOK, echo.Map{
"items": items, "total": total, "page": page, "per_page": h.PerPage,
})
}
把第 3 篇做的 pages/index.html
改成顯示「文章卡片」與「分頁按鈕」。
web/templates/pages/index.html
(更新)
{{ define "pages/index" -}}
{{ template "layouts/base" . }}
{{ define "title" -}}首頁{{ end }}
{{ define "content" -}}
<section class="grid gap-6">
<h1 class="text-3xl font-bold">📰 最新文章</h1>
<p class="text-slate-600">只會顯示「已發佈」文章;草稿只在後台看得到。</p>
<div class="grid gap-4">
{{ range .Posts }}
<article class="rounded-lg border bg-white p-4 hover:shadow">
<div class="text-sm text-slate-500">{{ .Date }}</div>
<h2 class="text-xl font-semibold">
<a class="hover:underline" href="{{ .URL }}">{{ .Title }}</a>
</h2>
{{ if .Summary }}
<p class="text-slate-600 mt-1">{{ .Summary }}</p>
{{ else }}
<p class="text-slate-400 mt-1">(這篇沒有摘要)</p>
{{ end }}
</article>
{{ else }}
<div class="rounded-lg border bg-white p-4 text-slate-500">
還沒有發佈的文章,先去後台新增一篇吧!(第 4 篇教過囉)
</div>
{{ end }}
</div>
<nav class="flex items-center justify-between pt-2">
<div class="text-sm text-slate-500">
第 {{ .Page }} / {{ .TotalPages }} 頁(共 {{ .Total }} 篇)
</div>
<div class="flex gap-2">
{{ if .HasPrev }}
<a class="px-3 py-1 rounded border bg-white hover:bg-slate-50" href="/?page={{ .PrevPage }}">上一頁</a>
{{ else }}
<span class="px-3 py-1 rounded border bg-slate-100 text-slate-400">上一頁</span>
{{ end }}
{{ if .HasNext }}
<a class="px-3 py-1 rounded border bg-white hover:bg-slate-50" href="/?page={{ .NextPage }}">下一頁</a>
{{ else }}
<span class="px-3 py-1 rounded border bg-slate-100 text-slate-400">下一頁</span>
{{ end }}
</div>
</nav>
<div class="rounded-lg border bg-white p-4">
<div class="text-sm text-slate-500">台北時間</div>
<div class="text-xl font-mono">{{ .Now }}</div>
</div>
</section>
{{- end }}
{{- end }}
備註:我們把標題連結指到
/posts/:slug
,下一篇會實作這條路由與內頁;現在點下去會 404,屬於預留洞口 😎(工作中的假說:先把資料流與 UI 固定,細節路由下一篇補齊)。
cmd/server/main.go
(只示範變動部分)
// ... 省略前段相同程式
// DB
ctx := context.Background()
db, err := pgstore.Connect(ctx)
if err != nil { log.Fatalf("connect db: %v", err) }
defer db.Close()
// Repos + Handlers
postRepo := pgstore.NewPostRepo(db.Pool)
// 前台首頁(每頁 5 筆)
front := handlers.NewFrontPostsHandler(postRepo, site, 5)
// 原本有的 admin、api(第 4 篇)依然保留
admin := handlers.NewAdminPostsHandler(postRepo, site)
apiAdmin := handlers.NewAPIPostsHandler(postRepo)
apiPublic := handlers.NewAPIPublicHandler(postRepo, 5)
// 基本路由
e.GET("/", front.Home) // ← 取代原本 HomeHandlerWithMeta
e.GET("/health", handlers.HealthHandler)
e.GET("/_ping", func(c echo.Context) error { return c.String(http.StatusOK, "pong") })
// 公開 API
e.GET("/api/posts", apiPublic.List)
// ... 其餘(adminGroup / apiAdmin 等)維持第 4 篇設定
如果你還想保留第 3 篇那個「Hello Blog」版本,只要換成別一路徑(例如
/welcome
)即可。
go-echo-blog/
├─ cmd/
│ └─ server/
│ └─ main.go ★ 更新(首頁路由 → FrontPostsHandler、加 /api/posts)
├─ internal/
│ ├─ http/
│ │ └─ handlers/
│ │ ├─ front_posts.go ★ 新增(前台列表 + 分頁)
│ │ ├─ api_public.go ★ 新增(公開 API:/api/posts)
│ │ ├─ admin_posts.go
│ │ ├─ api_posts.go
│ │ └─ home.go (可留著或移除不再使用的 HomeHandlerWithMeta)
│ └─ storage/
│ └─ postgres/
│ └─ posts.go
├─ web/
│ └─ templates/
│ └─ pages/
│ └─ index.html ★ 更新(列表 + 分頁)
└─ 其他檔案(migrations、Makefile、.env...)
本篇 不用加新的表。
政策:首頁只秀「已發佈」;草稿只在後台管理。排序採用
published_at DESC
(我們在 Repo 的 SQL 已優先COALESCE(published_at, created_at)
了)。
# 第 1 頁
curl -s http://localhost:1323/ | head -n 40
# 第 2 頁
curl -s "http://localhost:1323/?page=2" | head -n 40
# 列表(第 1 頁、每頁 5 筆)
curl -s "http://localhost:1323/api/posts?page=1" | jq .
# 切頁
curl -s "http://localhost:1323/api/posts?page=2" | jq .
for i in 1 2 3 4 5 6 7; do
curl -sX POST http://localhost:1323/api/admin/posts \
-H "Content-Type: application/json" \
-d '{
"author_id": 1,
"title": "Demo 文 '"$i"'",
"slug": "demo-'"$i"'",
"summary": "這是第 '"$i"' 篇示範文章",
"content_md": "內容內容內容 '"$i"'",
"status": "published"
}' > /dev/null
done
接著重新整理首頁 /
,應該就會看到分頁囉 😄
published_at
:理論上發佈文章都會寫入;若遇到 NULL,本篇用 updated_at
當備援顯示。/posts/:slug
會在下一篇實作;目前是預留。OFFSET/LIMIT
的索引或 cursor-based 分頁。恭喜!你的首頁現在能列出已發佈文章並且支援分頁 🎯
下一篇(第 6 篇):文章內頁與 slug 路由 /posts/:slug
,草稿保護與 404 頁面也會一起上。
加分挑戰:
/api/posts
加上 q=關鍵字
查詢(title/summary LIKE)。