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 }} + + ◈ + 作品 +
操作
✎ diff --git a/frontend/src/admin/ProjectsView.vue b/frontend/src/admin/ProjectsView.vue new file mode 100644 index 0000000..63ea0d3 --- /dev/null +++ b/frontend/src/admin/ProjectsView.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/frontend/src/api.js b/frontend/src/api.js index 8e6f76f..011dbda 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -54,6 +54,7 @@ export const publicApi = { post: (slug) => request('/api/posts/' + encodeURIComponent(slug)), archive: () => request('/api/archive'), tags: cached(() => request('/api/tags')), + projects: cached(() => request('/api/projects')), latest: cached(() => request('/api/posts?size=5')) } @@ -76,6 +77,10 @@ export const adminApi = { deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }), mergeTag: (id, toId) => request('/api/admin/tags/' + id + '/merge', { method: 'POST', body: { to_id: toId } }), + projects: (params = {}) => request('/api/admin/projects?' + new URLSearchParams(params)), + createProject: (body) => request('/api/admin/projects', { method: 'POST', body }), + updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }), + deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }), settings: () => request('/api/admin/settings'), saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }) } diff --git a/frontend/src/components/LeftNav.vue b/frontend/src/components/LeftNav.vue index bb7a15b..e37d91a 100644 --- a/frontend/src/components/LeftNav.vue +++ b/frontend/src/components/LeftNav.vue @@ -35,6 +35,9 @@ onMounted(async () => { #标签 + + ◈作品 + ◍关于 diff --git a/frontend/src/components/ProjectCard.vue b/frontend/src/components/ProjectCard.vue new file mode 100644 index 0000000..a79110c --- /dev/null +++ b/frontend/src/components/ProjectCard.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue index 04f19c7..108b7e2 100644 --- a/frontend/src/components/TopBar.vue +++ b/frontend/src/components/TopBar.vue @@ -10,6 +10,7 @@ import { site } from '../site' 首页 归档 标签 + 作品 关于 diff --git a/frontend/src/router.js b/frontend/src/router.js index 743e2c9..a3e619f 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -7,6 +7,7 @@ const routes = [ { path: '/tags', name: 'tags', component: () => import('./views/TagsView.vue') }, { path: '/tag/:slug', name: 'tag', component: () => import('./views/TagView.vue') }, { path: '/about', name: 'about', component: () => import('./views/AboutView.vue') }, + { path: '/projects', name: 'projects', component: () => import('./views/ProjectsView.vue') }, { path: '/admin/login', name: 'admin-login', component: () => import('./admin/LoginView.vue') }, { @@ -18,6 +19,7 @@ const routes = [ { path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') }, { path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') }, { path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') }, + { path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') }, { path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') } ] }, diff --git a/frontend/src/views/ProjectsView.vue b/frontend/src/views/ProjectsView.vue new file mode 100644 index 0000000..348a710 --- /dev/null +++ b/frontend/src/views/ProjectsView.vue @@ -0,0 +1,98 @@ + + + + +