作品
+做过的一些东西。
+From 3238175ef0267d6ad3cd596fabc8e8cc555c5153 Mon Sep 17 00:00:00 2001
From: Sakurasan <26715255+Sakurasan@users.noreply.github.com>
Date: Tue, 22 Sep 2026 01:47:59 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BD=9C=E5=93=81=E5=B1=95=E7=A4=BA=E9=A1=B5?=
=?UTF-8?q?=EF=BC=9A=E5=89=8D=E7=AB=AF=E5=B1=95=E7=A4=BA=20+=20=E5=90=8E?=
=?UTF-8?q?=E5=8F=B0=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 projects 表与迁移(唯一 slug 索引)
- 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects
- 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页
- 左栏/手机顶栏/后台侧栏均加「作品」入口
- 补充项目 CRUD 单元测试与后台鉴权拦截测试
---
backend/internal/admin/api.go | 88 +++++++
backend/internal/admin/api_test.go | 2 +-
backend/internal/api/api.go | 13 +
backend/internal/model/model.go | 30 +++
backend/internal/store/store.go | 177 +++++++++++++
backend/internal/store/store_test.go | 84 ++++++
frontend/src/admin/AdminLayout.vue | 4 +
frontend/src/admin/ProjectsView.vue | 325 ++++++++++++++++++++++++
frontend/src/api.js | 5 +
frontend/src/components/LeftNav.vue | 3 +
frontend/src/components/ProjectCard.vue | 198 +++++++++++++++
frontend/src/components/TopBar.vue | 1 +
frontend/src/router.js | 2 +
frontend/src/views/ProjectsView.vue | 98 +++++++
14 files changed, 1029 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/admin/ProjectsView.vue
create mode 100644 frontend/src/components/ProjectCard.vue
create mode 100644 frontend/src/views/ProjectsView.vue
diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go
index 1df9b51..2aeba82 100644
--- a/backend/internal/admin/api.go
+++ b/backend/internal/admin/api.go
@@ -54,6 +54,8 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/posts/", a.guard(a.postByID))
mux.HandleFunc("/api/admin/tags", a.guard(a.listTags))
mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID))
+ mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects))
+ mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
return mux
}
@@ -355,6 +357,92 @@ func writeTag(w http.ResponseWriter, t model.Tag, err error) {
httpx.OK(w, t)
}
+// ---------- projects ----------
+
+func (a *API) listProjects(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ status := httpx.QueryString(r, "status")
+ projects, err := a.Store.ListProjects(status)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ if projects == nil {
+ projects = []model.Project{}
+ }
+ httpx.OK(w, map[string]any{"projects": projects})
+ case http.MethodPost:
+ var in model.ProjectInput
+ if err := httpx.Decode(r, &in); err != nil {
+ httpx.BadRequest(w, "invalid body")
+ return
+ }
+ if strings.TrimSpace(in.Title) == "" {
+ httpx.BadRequest(w, "title required")
+ return
+ }
+ p, err := a.Store.CreateProject(in)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.Created(w, p)
+ default:
+ httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
+ }
+}
+
+func (a *API) projectByID(w http.ResponseWriter, r *http.Request) {
+ rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/projects/"), "/")
+ if rest == "" {
+ a.listProjects(w, r)
+ return
+ }
+ id, err := parseInt(rest)
+ if err != nil {
+ httpx.BadRequest(w, "bad project id")
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ p, err := a.Store.GetProject(id)
+ writeProject(w, p, err)
+ case http.MethodPut, http.MethodPatch:
+ var in model.ProjectInput
+ if err := httpx.Decode(r, &in); err != nil {
+ httpx.BadRequest(w, "invalid body")
+ return
+ }
+ p, err := a.Store.UpdateProject(id, in)
+ writeProject(w, p, err)
+ case http.MethodDelete:
+ if err := a.Store.DeleteProject(id); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ httpx.NotFound(w)
+ return
+ }
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"ok": true})
+ default:
+ httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT/DELETE required")
+ }
+}
+
+func writeProject(w http.ResponseWriter, p model.Project, err error) {
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ httpx.NotFound(w)
+ return
+ }
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, p)
+}
+
// ---------- settings ----------
func (a *API) settings(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/admin/api_test.go b/backend/internal/admin/api_test.go
index 9a98d0a..6455b5d 100644
--- a/backend/internal/admin/api_test.go
+++ b/backend/internal/admin/api_test.go
@@ -41,7 +41,7 @@ func login(t *testing.T, h http.Handler, user, pass string) *httptest.ResponseRe
func TestGuardRejectsAnonymous(t *testing.T) {
_, h := newTestAPI(t)
- for _, path := range []string{"/api/admin/posts", "/api/admin/settings", "/api/admin/dashboard"} {
+ for _, path := range []string{"/api/admin/posts", "/api/admin/projects", "/api/admin/settings", "/api/admin/dashboard"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnauthorized {
diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go
index 4fb5cbf..4cf2a50 100644
--- a/backend/internal/api/api.go
+++ b/backend/internal/api/api.go
@@ -35,6 +35,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/posts/", a.getPost)
mux.HandleFunc("/api/archive", a.archive)
mux.HandleFunc("/api/tags", a.tags)
+ mux.HandleFunc("/api/projects", a.projects)
return mux
}
@@ -120,6 +121,18 @@ func (a *API) tags(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, map[string]any{"tags": tags})
}
+func (a *API) projects(w http.ResponseWriter, r *http.Request) {
+ projects, err := a.Store.ListProjects(model.StatusPublished)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ if projects == nil {
+ projects = []model.Project{}
+ }
+ httpx.OK(w, map[string]any{"projects": projects})
+}
+
// ---------- RSS ----------
type rssItem struct {
diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go
index ce9f139..a5d5cbf 100644
--- a/backend/internal/model/model.go
+++ b/backend/internal/model/model.go
@@ -52,6 +52,36 @@ type Tag struct {
Count int `json:"count"`
}
+// Project is a showcase entry rendered on the public /projects page. It links
+// out to an external homepage and (optionally) a source repository.
+type Project struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Slug string `json:"slug"`
+ Summary string `json:"summary"`
+ CoverURL string `json:"cover_url"`
+ URL string `json:"url"`
+ RepoURL string `json:"repo_url"`
+ Status string `json:"status"`
+ Position int `json:"position"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// ProjectInput carries the editable fields for a project. A blank Slug or
+// Status is filled in by the store (slug from Title, status defaults to
+// published) so the admin UI can omit them.
+type ProjectInput struct {
+ Title string `json:"title"`
+ Slug string `json:"slug"`
+ Summary string `json:"summary"`
+ CoverURL string `json:"cover_url"`
+ URL string `json:"url"`
+ RepoURL string `json:"repo_url"`
+ Status string `json:"status"`
+ Position int `json:"position"`
+}
+
type ArchiveMonth struct {
Month string `json:"month"`
Posts []Post `json:"posts"`
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 8687d93..685e0a8 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -72,6 +72,19 @@ func (s *Store) migrate() error {
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
)`,
+ fmt.Sprintf(`CREATE TABLE IF NOT EXISTS projects (
+ id %s,
+ title TEXT NOT NULL DEFAULT '',
+ slug TEXT NOT NULL,
+ summary TEXT NOT NULL DEFAULT '',
+ cover_url TEXT NOT NULL DEFAULT '',
+ url TEXT NOT NULL DEFAULT '',
+ repo_url TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'published',
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )`, ai),
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -99,6 +112,7 @@ func (s *Store) migrate() error {
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
+ {"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -453,6 +467,169 @@ func (s *Store) uniqueSlug(base string, excludeID int64) string {
}
}
+func (s *Store) uniqueProjectSlug(base string, excludeID int64) string {
+ base = Slugify(base)
+ if base == "" {
+ base = "project"
+ }
+ candidate := base
+ for i := 2; ; i++ {
+ var id int64
+ err := s.db.QueryRow(s.db.Q(`SELECT id FROM projects WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
+ if err == sql.ErrNoRows {
+ return candidate
+ }
+ if err != nil {
+ return fmt.Sprintf("%s-%d", base, time.Now().Unix())
+ }
+ candidate = fmt.Sprintf("%s-%d", base, i)
+ }
+}
+
+// ---------- projects ----------
+
+func scanProject(row interface{ Scan(...any) error }) (model.Project, error) {
+ var p model.Project
+ err := row.Scan(&p.ID, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.URL,
+ &p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt)
+ return p, err
+}
+
+// ListProjects returns projects with an optional status filter. Pass "" to
+// get every project (admin), or model.StatusPublished / model.StatusDraft to
+// narrow. Results are ordered by explicit Position then recency.
+func (s *Store) ListProjects(status string) ([]model.Project, error) {
+ where := ""
+ args := []any{}
+ if status == model.StatusPublished || status == model.StatusDraft {
+ where = " WHERE status = ?"
+ args = append(args, status)
+ }
+ q := s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at
+ FROM projects` + where + ` ORDER BY position ASC, created_at DESC`)
+ rows, err := s.db.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []model.Project{}
+ for rows.Next() {
+ p, err := scanProject(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) GetProject(id int64) (model.Project, error) {
+ var p model.Project
+ err := s.db.QueryRow(s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at
+ FROM projects WHERE id = ?`), id).Scan(&p.ID, &p.Title, &p.Slug, &p.Summary,
+ &p.CoverURL, &p.URL, &p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt)
+ if err == sql.ErrNoRows {
+ return p, ErrNotFound
+ }
+ return p, err
+}
+
+func (s *Store) CreateProject(in model.ProjectInput) (model.Project, error) {
+ p := model.Project{
+ Title: strings.TrimSpace(in.Title),
+ Slug: strings.TrimSpace(in.Slug),
+ Summary: strings.TrimSpace(in.Summary),
+ CoverURL: strings.TrimSpace(in.CoverURL),
+ URL: strings.TrimSpace(in.URL),
+ RepoURL: strings.TrimSpace(in.RepoURL),
+ Status: NormalizeStatus(in.Status),
+ Position: in.Position,
+ }
+ if p.Status == "" {
+ p.Status = model.StatusPublished
+ }
+ if p.Slug == "" {
+ p.Slug = Slugify(p.Title)
+ }
+ if p.Slug == "" {
+ p.Slug = "project-" + time.Now().UTC().Format("20060102-150405")
+ }
+ p.Slug = s.uniqueProjectSlug(p.Slug, 0)
+ p.CreatedAt = now()
+ p.UpdatedAt = p.CreatedAt
+
+ q := s.db.Q(`INSERT INTO projects (title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)`)
+ var id int64
+ if s.db.Dialect == db.Postgres {
+ err := s.db.QueryRow(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL,
+ p.Status, p.Position, p.CreatedAt, p.UpdatedAt).Scan(&id)
+ if err != nil {
+ return p, err
+ }
+ } else {
+ res, err := s.db.Exec(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL,
+ p.Status, p.Position, p.CreatedAt, p.UpdatedAt)
+ if err != nil {
+ return p, err
+ }
+ id, err = res.LastInsertId()
+ if err != nil {
+ return p, err
+ }
+ }
+ return s.GetProject(id)
+}
+
+func (s *Store) UpdateProject(id int64, in model.ProjectInput) (model.Project, error) {
+ cur, err := s.GetProject(id)
+ if err != nil {
+ return cur, err
+ }
+ p := cur
+ if in.Title != "" {
+ p.Title = strings.TrimSpace(in.Title)
+ }
+ if in.Summary != "" {
+ p.Summary = strings.TrimSpace(in.Summary)
+ }
+ if in.CoverURL != "" {
+ p.CoverURL = strings.TrimSpace(in.CoverURL)
+ }
+ if in.URL != "" {
+ p.URL = strings.TrimSpace(in.URL)
+ }
+ if in.RepoURL != "" {
+ p.RepoURL = strings.TrimSpace(in.RepoURL)
+ }
+ if in.Status != "" {
+ p.Status = NormalizeStatus(in.Status)
+ }
+ if in.Slug != "" && in.Slug != cur.Slug {
+ p.Slug = s.uniqueProjectSlug(in.Slug, id)
+ }
+ p.Position = in.Position
+ p.UpdatedAt = now()
+
+ if _, err := s.db.Exec(s.db.Q(`UPDATE projects SET title=?,slug=?,summary=?,cover_url=?,url=?,repo_url=?,
+ status=?,position=?,updated_at=? WHERE id=?`),
+ p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL, p.Status, p.Position, p.UpdatedAt, id); err != nil {
+ return p, err
+ }
+ return s.GetProject(id)
+}
+
+func (s *Store) DeleteProject(id int64) error {
+ res, err := s.db.Exec(s.db.Q(`DELETE FROM projects WHERE id = ?`), id)
+ if err != nil {
+ return err
+ }
+ if n, err := res.RowsAffected(); err == nil && n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
// ---------- write ----------
func (s *Store) Create(in model.PostInput) (model.Post, error) {
diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go
index 7bd9a13..475220f 100644
--- a/backend/internal/store/store_test.go
+++ b/backend/internal/store/store_test.go
@@ -1,6 +1,7 @@
package store
import (
+ "errors"
"strings"
"testing"
@@ -291,3 +292,86 @@ func TestListWithMaliciousOrderByFailsSafe(t *testing.T) {
t.Errorf("unexpected total %d", page.Total)
}
}
+
+func TestProjectCRUD(t *testing.T) {
+ s := openTestStore(t)
+
+ // empty list is a non-nil slice
+ list, err := s.ListProjects("")
+ if err != nil {
+ t.Fatalf("ListProjects: %v", err)
+ }
+ if list == nil {
+ t.Fatal("ListProjects returned nil slice")
+ }
+
+ created, err := s.CreateProject(model.ProjectInput{
+ Title: "Folo",
+ Summary: "This AI RSS reader reads the internet for you",
+ CoverURL: "https://folo.is/cover.webp",
+ URL: "https://folo.is",
+ RepoURL: "https://github.com/DIYgod/RSSHub-Radar",
+ Status: model.StatusPublished,
+ Position: 2,
+ })
+ if err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+ if created.ID == 0 {
+ t.Fatal("created project has no ID")
+ }
+ if created.Slug != "folo" {
+ t.Errorf("slug = %q, want folo", created.Slug)
+ }
+
+ // duplicate slug gets disambiguated
+ dup, err := s.CreateProject(model.ProjectInput{Title: "Folo", Status: model.StatusPublished})
+ if err != nil {
+ t.Fatalf("CreateProject dup: %v", err)
+ }
+ if dup.Slug == created.Slug {
+ t.Errorf("duplicate slug not disambiguated: %q", dup.Slug)
+ }
+
+ // public list excludes drafts; admin ("") includes them
+ draft, err := s.CreateProject(model.ProjectInput{Title: "Secret", Status: model.StatusDraft})
+ if err != nil {
+ t.Fatalf("CreateProject draft: %v", err)
+ }
+ pub, _ := s.ListProjects(model.StatusPublished)
+ if len(pub) != 2 {
+ t.Errorf("published list len = %d, want 2", len(pub))
+ }
+ all, _ := s.ListProjects("")
+ if len(all) != 3 {
+ t.Errorf("all list len = %d, want 3", len(all))
+ }
+
+ // update
+ upd, err := s.UpdateProject(created.ID, model.ProjectInput{Summary: "Updated summary", Position: 1})
+ if err != nil {
+ t.Fatalf("UpdateProject: %v", err)
+ }
+ if upd.Summary != "Updated summary" || upd.Position != 1 {
+ t.Errorf("update did not apply: %+v", upd)
+ }
+
+ // get
+ got, err := s.GetProject(draft.ID)
+ if err != nil {
+ t.Fatalf("GetProject: %v", err)
+ }
+ if got.Status != model.StatusDraft {
+ t.Errorf("status = %q, want draft", got.Status)
+ }
+
+ // delete
+ if err := s.DeleteProject(created.ID); err != nil {
+ t.Fatalf("DeleteProject: %v", err)
+ }
+ _, err = s.GetProject(created.ID)
+ if !errors.Is(err, ErrNotFound) {
+ t.Errorf("after delete: got %v, want ErrNotFound", err)
+ }
+}
+
diff --git a/frontend/src/admin/AdminLayout.vue b/frontend/src/admin/AdminLayout.vue
index 87ee3e0..1f5daa0 100644
--- a/frontend/src/admin/AdminLayout.vue
+++ b/frontend/src/admin/AdminLayout.vue
@@ -115,6 +115,10 @@ watch(() => route.path, () => loadCounts())
标签
{{ counts.tags }}
+
做过的一些东西。
+