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()
|
||||
|
||||
Reference in New Issue
Block a user