作品展示页:前端展示 + 后台增删改查
- 新增 projects 表与迁移(唯一 slug 索引) - 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects - 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页 - 左栏/手机顶栏/后台侧栏均加「作品」入口 - 补充项目 CRUD 单元测试与后台鉴权拦截测试
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,10 @@ watch(() => route.path, () => loadCounts())
|
||||
<span>标签</span>
|
||||
<span class="badge">{{ counts.tags }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/projects" class="item">
|
||||
<span class="ic">◈</span>
|
||||
<span>作品</span>
|
||||
</RouterLink>
|
||||
<div class="group">操作</div>
|
||||
<RouterLink to="/admin/new" class="item">
|
||||
<span class="ic">✎</span>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '../api'
|
||||
|
||||
const projects = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
function blank() {
|
||||
return {
|
||||
id: null,
|
||||
title: '',
|
||||
summary: '',
|
||||
cover_url: '',
|
||||
url: '',
|
||||
repo_url: '',
|
||||
status: 'published',
|
||||
position: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部新建表单
|
||||
const form = ref(blank())
|
||||
// 当前展开编辑的那一行 id(null 表示没有)
|
||||
const editing = ref(null)
|
||||
const draft = ref(blank())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminApi.projects()
|
||||
projects.value = data.projects || data || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
async function create() {
|
||||
if (!form.value.title.trim()) {
|
||||
alert('请填写作品名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminApi.createProject({
|
||||
title: form.value.title,
|
||||
summary: form.value.summary,
|
||||
cover_url: form.value.cover_url,
|
||||
url: form.value.url,
|
||||
repo_url: form.value.repo_url,
|
||||
status: form.value.status,
|
||||
position: Number(form.value.position) || 0
|
||||
})
|
||||
form.value = blank()
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(p) {
|
||||
editing.value = p.id
|
||||
draft.value = {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
summary: p.summary,
|
||||
cover_url: p.cover_url,
|
||||
url: p.url,
|
||||
repo_url: p.repo_url,
|
||||
status: p.status,
|
||||
position: p.position
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = null
|
||||
draft.value = blank()
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!draft.value.title.trim()) {
|
||||
alert('请填写作品名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminApi.updateProject(editing.value, {
|
||||
title: draft.value.title,
|
||||
summary: draft.value.summary,
|
||||
cover_url: draft.value.cover_url,
|
||||
url: draft.value.url,
|
||||
repo_url: draft.value.repo_url,
|
||||
status: draft.value.status,
|
||||
position: Number(draft.value.position) || 0
|
||||
})
|
||||
editing.value = null
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(p) {
|
||||
const next = p.status === 'published' ? 'draft' : 'published'
|
||||
try {
|
||||
await adminApi.updateProject(p.id, { status: next })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!window.confirm(`删除作品「${p.title}」?此操作不可撤销。`)) return
|
||||
try {
|
||||
await adminApi.deleteProject(p.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
|
||||
<h1 style="font-family: var(--serif); font-size: 22px;">
|
||||
作品 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ projects.length }}</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="panel" style="margin-bottom: 16px;">
|
||||
<div class="panel-title"><h2>新建作品</h2></div>
|
||||
<div class="form">
|
||||
<label class="field">
|
||||
<span class="label">名称</span>
|
||||
<input v-model="form.title" class="input" placeholder="Folo" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">一句话描述</span>
|
||||
<input v-model="form.summary" class="input" placeholder="This AI RSS reader reads the internet for you" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">封面图 URL(16:9)</span>
|
||||
<input v-model="form.cover_url" class="input" placeholder="https://…/cover.webp" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">链接</span>
|
||||
<input v-model="form.url" class="input" placeholder="https://folo.is" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">仓库链接</span>
|
||||
<input v-model="form.repo_url" class="input" placeholder="https://github.com/…" spellcheck="false" />
|
||||
</label>
|
||||
<div class="row2">
|
||||
<label class="field">
|
||||
<span class="label">排序权重</span>
|
||||
<input v-model.number="form.position" type="number" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">状态</span>
|
||||
<select v-model="form.status" class="select">
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="create">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!projects.length" class="empty">还没有作品。</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-for="p in projects" :key="p.id" class="proj-row">
|
||||
<div class="line">
|
||||
<span class="name">{{ p.title }}</span>
|
||||
<span class="state" :class="p.status">{{ p.status === 'published' ? '已发布' : '草稿' }}</span>
|
||||
<span class="acts">
|
||||
<button @click="toggleStatus(p)">{{ p.status === 'published' ? '转草稿' : '发布' }}</button>
|
||||
<button @click="startEdit(p)">编辑</button>
|
||||
<button class="danger" @click="remove(p)">删除</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editing === p.id" class="form edit">
|
||||
<label class="field">
|
||||
<span class="label">名称</span>
|
||||
<input v-model="draft.title" class="input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">一句话描述</span>
|
||||
<input v-model="draft.summary" class="input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">封面图 URL(16:9)</span>
|
||||
<input v-model="draft.cover_url" class="input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">链接</span>
|
||||
<input v-model="draft.url" class="input" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">仓库链接</span>
|
||||
<input v-model="draft.repo_url" class="input" spellcheck="false" />
|
||||
</label>
|
||||
<div class="row2">
|
||||
<label class="field">
|
||||
<span class="label">排序权重</span>
|
||||
<input v-model.number="draft.position" type="number" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">状态</span>
|
||||
<select v-model="draft.status" class="select">
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="edit-acts">
|
||||
<button class="btn btn-primary" @click="saveEdit">保存</button>
|
||||
<button class="btn" @click="cancelEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row2 .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--admin-line);
|
||||
}
|
||||
|
||||
.edit-acts {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.proj-row {
|
||||
border-top: 1px solid var(--admin-line);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
color: var(--admin-ink);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--admin-line);
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.state.published {
|
||||
color: var(--admin-accent);
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--admin-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.acts button:hover {
|
||||
color: var(--admin-ink);
|
||||
}
|
||||
|
||||
.acts button.danger:hover {
|
||||
color: #c0392b;
|
||||
}
|
||||
</style>
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ onMounted(async () => {
|
||||
<RouterLink to="/tags" class="item">
|
||||
<span class="ico">#</span><span>标签</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/projects" class="item">
|
||||
<span class="ico">◈</span><span>作品</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/about" class="item">
|
||||
<span class="ico">◍</span><span>关于</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
project: { type: Object, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="project.url ? 'a' : 'div'"
|
||||
:href="project.url ? project.url : null"
|
||||
:target="project.url ? '_blank' : null"
|
||||
:rel="project.url ? 'noopener noreferrer' : null"
|
||||
class="card"
|
||||
>
|
||||
<span class="layer layer-back" aria-hidden="true"></span>
|
||||
<span class="layer layer-front" aria-hidden="true"></span>
|
||||
|
||||
<span class="content">
|
||||
<span class="cover">
|
||||
<img
|
||||
v-if="project.cover_url"
|
||||
:src="project.cover_url"
|
||||
:alt="project.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else class="cover-fallback">{{ (project.title || '?').slice(0, 1) }}</span>
|
||||
</span>
|
||||
|
||||
<span class="meta">
|
||||
<span class="title">
|
||||
{{ project.title }}
|
||||
<svg
|
||||
v-if="project.url"
|
||||
class="arrow"
|
||||
viewBox="0 0 13 15"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(0.666667, 2.333333)" stroke="currentColor" stroke-width="2.4">
|
||||
<polyline class="arrow-line" points="5.33333333 0 10.8333333 5.5 5.33333333 11" />
|
||||
<line class="arrow-stem" x1="10.8333333" y1="5.5" x2="0.833333333" y2="5.16666667" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="summary">{{ project.summary }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
--radius: 10px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius);
|
||||
border: 1px dashed var(--line);
|
||||
transition: transform 0.28s ease, background-color 0.28s ease, border-color 0.28s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-back {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-front {
|
||||
z-index: 2;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cover-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: var(--serif);
|
||||
font-size: 42px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
width: 11px;
|
||||
height: 13px;
|
||||
color: var(--accent);
|
||||
transform: translate(-2px, 2px) rotate(-45deg);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.arrow-stem {
|
||||
transform: translateX(-4px);
|
||||
opacity: 0;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- hover: split the double dashed border ---- */
|
||||
.card:hover .layer-back,
|
||||
.card:hover .layer-front {
|
||||
transform: translate(4px, 4px);
|
||||
}
|
||||
|
||||
.card:hover .layer-front {
|
||||
transform: translate(-4px, -4px);
|
||||
background: var(--card);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.card:hover .content {
|
||||
transform: translate(-4px, -4px);
|
||||
}
|
||||
|
||||
.card:hover .arrow {
|
||||
transform: translate(0, 0) rotate(-45deg);
|
||||
}
|
||||
|
||||
.card:hover .arrow-stem {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.layer,
|
||||
.content,
|
||||
.arrow,
|
||||
.arrow-stem {
|
||||
transition: none;
|
||||
}
|
||||
.card:hover .layer-back {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import { site } from '../site'
|
||||
<RouterLink to="/">首页</RouterLink>
|
||||
<RouterLink to="/archive">归档</RouterLink>
|
||||
<RouterLink to="/tags">标签</RouterLink>
|
||||
<RouterLink to="/projects">作品</RouterLink>
|
||||
<RouterLink to="/about">关于</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -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') }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import ProjectCard from '../components/ProjectCard.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
|
||||
const projects = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await publicApi.projects()
|
||||
projects.value = data.projects || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
applyDocTitle('作品')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">作品</h1>
|
||||
<p class="sub">做过的一些东西。</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!projects.length" class="empty">还没有作品。</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<ProjectCard v-for="p in projects" :key="p.id" :project="p" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--paper);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user