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:
+56
-22
@@ -27,16 +27,17 @@ import (
|
||||
|
||||
// adminNoteItem 管理列表项(不含全文)。
|
||||
type adminNoteItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func toAdminItem(n *store.Note) adminNoteItem {
|
||||
@@ -44,6 +45,7 @@ func toAdminItem(n *store.Note) adminNoteItem {
|
||||
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
|
||||
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||
PublishedAt: n.PublishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,15 +63,16 @@ func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
// notePayload 笔记写请求体。
|
||||
// notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。
|
||||
type notePayload struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PublishedAt *int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
|
||||
@@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = markdown.Summary(p.Content, 200)
|
||||
}
|
||||
publishedAt := now
|
||||
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||
publishedAt = *p.PublishedAt
|
||||
}
|
||||
n := &store.Note{
|
||||
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
|
||||
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
|
||||
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now,
|
||||
Pinned: p.Pinned, PublishedAt: publishedAt, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
id, err := s.st.CreateNote(n)
|
||||
if err != nil {
|
||||
@@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
n.Tags = store.NormalizeTags(p.Tags)
|
||||
n.Pinned = p.Pinned
|
||||
n.Content = p.Content
|
||||
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||
n.PublishedAt = *p.PublishedAt
|
||||
}
|
||||
if strings.TrimSpace(p.Summary) == "" {
|
||||
n.Summary = markdown.Summary(p.Content, 200)
|
||||
} else {
|
||||
@@ -253,6 +263,17 @@ func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
// handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。
|
||||
func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.st.EmptyTrash()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
s.log.Info("admin_action", "op", "trash.empty", "count", n)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
|
||||
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
@@ -397,6 +418,7 @@ type settingsDTO struct {
|
||||
SiteDesc *string `json:"site_desc"`
|
||||
PageSize *int `json:"page_size"`
|
||||
BeianNo *string `json:"beian_no"`
|
||||
SiteLogo *string `json:"site_logo"`
|
||||
}
|
||||
|
||||
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
||||
@@ -415,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
dec.DisallowUnknownFields()
|
||||
var dto settingsDTO
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no)")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no/site_logo)")
|
||||
return
|
||||
}
|
||||
if dto.SiteTitle != nil {
|
||||
@@ -440,8 +462,8 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
if dto.PageSize != nil {
|
||||
if *dto.PageSize < 1 || *dto.PageSize > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
|
||||
if !store.ValidPageSize(*dto.PageSize) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50")
|
||||
return
|
||||
}
|
||||
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
|
||||
@@ -460,6 +482,18 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
if dto.SiteLogo != nil {
|
||||
// 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示
|
||||
v := strings.TrimSpace(*dto.SiteLogo)
|
||||
if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空")
|
||||
return
|
||||
}
|
||||
if err := s.st.SetSetting(store.KeySiteLogo, v); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
|
||||
s.log.Info("admin_action", "op", "settings.update")
|
||||
ss, err := s.st.GetSiteSettings()
|
||||
|
||||
@@ -284,7 +284,7 @@ func TestSettingsWhitelist(t *testing.T) {
|
||||
}
|
||||
// PUT 合法键
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders())
|
||||
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":20,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders())
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
|
||||
}
|
||||
@@ -311,14 +311,16 @@ func TestSettingsWhitelist(t *testing.T) {
|
||||
}
|
||||
// page_size 生效为公开列表默认
|
||||
_, body = e.get(e.client(), "/api/notes")
|
||||
if !strings.Contains(string(body), `"page_size":5`) {
|
||||
if !strings.Contains(string(body), `"page_size":20`) {
|
||||
t.Errorf("page_size 设置应生效: %s", body)
|
||||
}
|
||||
// page_size 越界
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"page_size":0}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
|
||||
// page_size 越界/非候选项
|
||||
for _, bad := range []string{"0", "5", "101"} {
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"page_size":`+bad+`}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("page_size=%s 应 400: %d", bad, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
|
||||
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
|
||||
Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义)
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
||||
// ---- sitemap(仅公开笔记)----
|
||||
|
||||
type urlSet struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
URLs []siteURL `xml:"url"`
|
||||
}
|
||||
|
||||
|
||||
+12
-10
@@ -42,13 +42,14 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// publicNoteItem 公开列表项(元信息,不含全文)。
|
||||
type publicNoteItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
||||
@@ -70,7 +71,7 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, publicNoteItem{
|
||||
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||
Tags: n.Tags, Pinned: n.Pinned,
|
||||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -119,8 +120,9 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
||||
"summary": note.Summary, "content": note.Content,
|
||||
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
||||
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
||||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||
"published_at": note.PublishedAt,
|
||||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+13
-12
@@ -29,12 +29,12 @@ const (
|
||||
|
||||
// Server HTTP 服务。
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
global *middleware.Limiter // 全局宽松限流(per-IP)
|
||||
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
||||
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
||||
st *store.Store
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
global *middleware.Limiter // 全局宽松限流(per-IP)
|
||||
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
||||
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
||||
}
|
||||
|
||||
// New 构造 Server(生产限流参数,§7.2)。
|
||||
@@ -93,6 +93,7 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
|
||||
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
|
||||
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
|
||||
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
|
||||
adminMux.HandleFunc("DELETE /api/admin/trash", s.handleAdminTrashEmpty)
|
||||
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
|
||||
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
|
||||
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
|
||||
@@ -291,13 +292,13 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
||||
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
|
||||
}
|
||||
m := webui.Meta{
|
||||
Title: ss.SiteTitle,
|
||||
Description: ss.SiteDesc,
|
||||
OGTitle: ss.SiteTitle,
|
||||
Title: ss.SiteTitle,
|
||||
Description: ss.SiteDesc,
|
||||
OGTitle: ss.SiteTitle,
|
||||
OGDescription: ss.SiteDesc,
|
||||
OGType: "website",
|
||||
SiteName: ss.SiteTitle,
|
||||
OGURL: baseURL(r) + "/",
|
||||
OGType: "website",
|
||||
SiteName: ss.SiteTitle,
|
||||
OGURL: baseURL(r) + "/",
|
||||
}
|
||||
slug := metaSlug(r.URL.Path)
|
||||
if slug == "" {
|
||||
|
||||
@@ -154,12 +154,12 @@ func TestVisibilityMatrix(t *testing.T) {
|
||||
|
||||
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
|
||||
imageCases := []struct {
|
||||
name string
|
||||
id int64
|
||||
anonCode int
|
||||
anonCache string
|
||||
adminCode int
|
||||
adminCache string
|
||||
name string
|
||||
id int64
|
||||
anonCode int
|
||||
anonCache string
|
||||
adminCode int
|
||||
adminCache string
|
||||
}{
|
||||
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
|
||||
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
|
||||
|
||||
Reference in New Issue
Block a user