作品展示页:前端展示 + 后台增删改查

- 新增 projects 表与迁移(唯一 slug 索引)
- 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects
- 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页
- 左栏/手机顶栏/后台侧栏均加「作品」入口
- 补充项目 CRUD 单元测试与后台鉴权拦截测试
This commit is contained in:
Sakurasan
2026-09-22 01:47:59 +08:00
parent 2b53eb976b
commit 3238175ef0
14 changed files with 1029 additions and 1 deletions
+88
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+13
View File
@@ -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 {
+30
View File
@@ -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"`
+177
View File
@@ -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) {
+84
View File
@@ -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)
}
}
+4
View File
@@ -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>
+325
View File
@@ -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>
+5
View File
@@ -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 })
}
+3
View File
@@ -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>
+198
View File
@@ -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>
+1
View File
@@ -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>
+2
View File
@@ -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') }
]
},
+98
View File
@@ -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>