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
+3 -1
View File
@@ -101,11 +101,13 @@ func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error {
}
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
func (s *Store) ListOrphanImages() ([]Image, error) {
rows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at DESC`)
AND i.id <> ?
ORDER BY i.created_at DESC`, s.logoImageID())
if err != nil {
return nil, err
}
+7 -4
View File
@@ -54,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
}
rows.Close()
// 孤儿图片:0 引用且超过宽限期
// 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护
imageCutoff := now.Add(-OrphanGrace).Unix()
logoID := s.logoImageID()
orphanRows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at`, imageCutoff)
AND i.id <> ?
ORDER BY i.created_at`, imageCutoff, logoID)
if err != nil {
return nil, err
}
@@ -101,9 +103,10 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
}
}
for _, img := range rep.OrphanImages {
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
res, err := s.db.Exec(
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`,
img.ID, img.ID)
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
img.ID, img.ID, logoID)
if err != nil {
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
}
+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)
+34 -2
View File
@@ -14,6 +14,7 @@ const (
KeySiteDesc = "site_desc"
KeyPageSize = "page_size"
KeyBeianNo = "beian_no"
KeySiteLogo = "site_logo"
)
// GetSetting 读取单个设置。
@@ -39,6 +40,7 @@ type SiteSettings struct {
SiteDesc string `json:"site_desc"`
PageSize int `json:"page_size"`
BeianNo string `json:"beian_no"`
SiteLogo string `json:"site_logo"`
}
// Defaults,未初始化时兜底。
@@ -49,6 +51,15 @@ const (
DefaultBeianNo = ""
)
// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。
func ValidPageSize(n int) bool {
switch n {
case 10, 20, 30, 50:
return true
}
return false
}
// GetSiteSettings 读取站点设置(白名单键,带默认值)。
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
@@ -65,8 +76,9 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
return nil, err
} else if ok {
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13)
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 {
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
// 脏值/越界值回退默认,而非落入任意 [1,100]
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(n) {
ss.PageSize = n
}
}
@@ -75,5 +87,25 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
} else if ok {
ss.BeianNo = strings.TrimSpace(v)
}
if v, ok, err := s.GetSetting(KeySiteLogo); err != nil {
return nil, err
} else if ok {
ss.SiteLogo = strings.TrimSpace(v)
}
return ss, nil
}
// logoImageID 从 site_logo 设置解析受 GC 保护的图片 id(无 logo 或非本站图片路径返回 0)。
func (s *Store) logoImageID() int64 {
v, ok, err := s.GetSetting(KeySiteLogo)
if err != nil || !ok || v == "" {
return 0
}
if m := imageRefRe.FindStringSubmatch(v); m != nil {
var id int64
if _, err := fmtSscanInt(m[1], &id); err == nil && id > 0 {
return id
}
}
return 0
}
+5
View File
@@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`,
// v2: 笔记发布日期(可自选);存量笔记回填为 created_at
`
ALTER TABLE notes ADD COLUMN published_at INTEGER;
UPDATE notes SET published_at = created_at WHERE published_at IS NULL;
`,
}
+4 -4
View File
@@ -150,10 +150,10 @@ func TestAdjacentPublicNote(t *testing.T) {
t.Fatal(err)
}
}
mk("a", 100, false, "public") // id=1
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
mk("c", 80, false, "public") // id=3
mk("p", 50, true, "public") // id=4 置顶 → 列表首
mk("a", 100, false, "public") // id=1
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
mk("c", 80, false, "public") // id=3
mk("p", 50, true, "public") // id=4 置顶 → 列表首
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
get := func(slug string) *Note {