feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进

- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
This commit is contained in:
2026-09-09 11:10:30 +08:00
parent 87d9bab5dc
commit 5457aeaa33
25 changed files with 600 additions and 257 deletions
+33 -21
View File
@@ -9,31 +9,34 @@ import (
)
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
type Note struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
}
// ErrNotFound 统一的「不存在」错误。
var ErrNotFound = errors.New("not found")
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at"
// published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at"
func scanNote(scan func(dest ...any) error) (*Note, error) {
var n Note
var tagsJSON string
var pinned int
var content string
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil {
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt, &n.PublishedAt); err != nil {
return nil, err
}
n.Content = content
@@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) {
return 0, err
}
res, err := s.db.Exec(
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt)
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt)
if err != nil {
return 0, err
}
@@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error {
return err
}
res, err := s.db.Exec(
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID)
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.UpdatedAt, n.ID)
if err != nil {
return err
}
@@ -164,7 +167,7 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at
FROM notes n WHERE ` + where + `
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
@@ -234,7 +237,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
func (s *Store) ListAdminNotes() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC, id DESC`
return s.queryNotes(q)
@@ -242,12 +245,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) {
// ListTrash 回收站列表(软删除中),不含全文。
func (s *Store) ListTrash() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC`
return s.queryNotes(q)
}
// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。
func (s *Store) EmptyTrash() (int64, error) {
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)