diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index f2c34ba..ad9037d 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -231,6 +231,7 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) { } in.Status = store.NormalizeStatus(in.Status) attachLinkCard(r.Context(), &in, nil) + autoMeta(&in, nil) p, err := a.Store.Create(in) if err != nil { httpx.ServerError(w, err) @@ -270,12 +271,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) { if in.Status != "" { in.Status = store.NormalizeStatus(in.Status) } - // 多读一次当前行只为拿到已有卡片:链接没变就不必再打远端 + // 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动 + // 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住 var curCard *model.LinkCard + var curPost *model.Post if cur, err := a.Store.Get(id); err == nil { curCard = cur.LinkCard + curPost = &cur } attachLinkCard(r.Context(), &in, curCard) + autoMeta(&in, curPost) p, err := a.Store.Update(id, in) writeOne(w, p, err) case http.MethodDelete: diff --git a/backend/internal/admin/meta.go b/backend/internal/admin/meta.go new file mode 100644 index 0000000..f5c56f0 --- /dev/null +++ b/backend/internal/admin/meta.go @@ -0,0 +1,68 @@ +// 长文的自动摘要与自动封面:站主没填这两项时从正文派生—— +// 摘要取正文开头的一段纯文本,封面取正文第一张图。 +// 更新路径只在「新值与旧值都为空」时才生成,站主主动清空的要保得住。 +package admin + +import ( + "regexp" + "strings" + + "oneblog/internal/model" +) + +const autoSummaryLen = 110 + +var ( + mdImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)[^)]*\)`) + mdCodeFence = regexp.MustCompile("(?s)```.*?```") + mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) + mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+.*$`) // 标题行整行不进摘要(与文章标题重复) + mdNoiseRe = regexp.MustCompile(`(?m)^([>\s*-]+|\d+\.\s)+`) + mdEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "*", "", "_", "") + wsRe = regexp.MustCompile(`\s+`) +) + +// autoMeta 按需填充 in.Summary / in.CoverURL。 +// cur 为更新前的旧文章(新建时传 nil)。 +func autoMeta(in *model.PostInput, cur *model.Post) { + if in.Kind != model.KindLong || strings.TrimSpace(in.ContentMd) == "" { + return + } + if strings.TrimSpace(in.Summary) == "" && (cur == nil || strings.TrimSpace(cur.Summary) == "") { + in.Summary = summarizeMarkdown(in.ContentMd, autoSummaryLen) + } + if strings.TrimSpace(in.CoverURL) == "" && (cur == nil || strings.TrimSpace(cur.CoverURL) == "") { + in.CoverURL = firstImage(in.ContentMd) + } +} + +// summarizeMarkdown 剥掉 markdown 语法后取正文开头一段纯文本 +func summarizeMarkdown(md string, maxRunes int) string { + s := mdCodeFence.ReplaceAllString(md, " ") // 代码块整段不进摘要 + s = mdHeadingRe.ReplaceAllString(s, " ") + s = mdImageRe.ReplaceAllString(s, " ") + s = mdLinkRe.ReplaceAllString(s, "$1") + s = mdNoiseRe.ReplaceAllString(s, " ") + s = mdEmphasis.Replace(s) + s = strings.ReplaceAll(s, "`", "") + s = wsRe.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + runes := []rune(s) + if len(runes) > maxRunes { + return string(runes[:maxRunes]) + "…" + } + return s +} + +// firstImage 取正文第一张图片的地址(markdown 图;http/站内路径均可) +func firstImage(md string) string { + m := mdImageRe.FindStringSubmatch(md) + if m == nil { + return "" + } + u := m[1] + if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/uploads/") { + return u + } + return "" +} diff --git a/backend/internal/admin/meta_test.go b/backend/internal/admin/meta_test.go new file mode 100644 index 0000000..b28bbb6 --- /dev/null +++ b/backend/internal/admin/meta_test.go @@ -0,0 +1,62 @@ +package admin + +import ( + "strings" + "testing" + + "oneblog/internal/model" +) + +func TestSummarizeMarkdown(t *testing.T) { + md := "## 标题\n\n这是**正文**的第一段,含[链接](https://x.com)与图片:\n\n![封面](https://a.com/1.png)\n\n```go\ncode ignored\n```\n\n后续内容" + got := summarizeMarkdown(md, 110) + for _, bad := range []string{"#", "**", "![]", "```", "code ignored"} { + if strings.Contains(got, bad) { + t.Errorf("summary 含语法残留 %q: %q", bad, got) + } + } + if !strings.Contains(got, "链接") || !strings.HasPrefix(got, "这是") { + t.Errorf("summary 应以正文开头并保留链接文字: %q", got) + } + long := strings.Repeat("长", 200) + if got := summarizeMarkdown(long, 110); len([]rune(got)) != 111 || !strings.HasSuffix(got, "…") { + t.Errorf("超长应截到 110+省略号, got %d runes", len([]rune(got))) + } +} + +func TestFirstImage(t *testing.T) { + md := "前言\n\n![封面](https://a.com/1.png)\n\n![第二张](https://a.com/2.png)" + if got := firstImage(md); got != "https://a.com/1.png" { + t.Errorf("firstImage = %q", got) + } + if got := firstImage("没有图片"); got != "" { + t.Errorf("无图应返回空, got %q", got) + } +} + +func TestAutoMeta(t *testing.T) { + in := model.PostInput{Kind: model.KindLong, ContentMd: "# T\n\n正文内容 ![图](https://a.com/x.png)"} + autoMeta(&in, nil) + if in.Summary == "" || in.CoverURL != "https://a.com/x.png" { + t.Errorf("空字段应自动填充: summary=%q cover=%q", in.Summary, in.CoverURL) + } + // 站主已填的不能覆盖 + in2 := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "手写的", CoverURL: ""} + autoMeta(&in2, nil) + if in2.Summary != "手写的" { + t.Errorf("手写摘要不应被覆盖: %q", in2.Summary) + } + // 更新路径:新值与旧值都空才补——主动清空的保得住 + cleared := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "", CoverURL: ""} + cur := model.Post{Summary: "旧摘要", CoverURL: "旧封面"} + autoMeta(&cleared, &cur) + if cleared.Summary != "" || cleared.CoverURL != "" { + t.Errorf("主动清空不应复活: summary=%q cover=%q", cleared.Summary, cleared.CoverURL) + } + // 新旧都空 → 生成 + never := model.PostInput{Kind: model.KindLong, ContentMd: "正文 ![图](/uploads/a.png)"} + autoMeta(&never, &model.Post{}) + if never.CoverURL != "/uploads/a.png" { + t.Errorf("新旧都空应取首图: %q", never.CoverURL) + } +} diff --git a/frontend/src/admin/EditorView.vue b/frontend/src/admin/EditorView.vue index 56de4cf..956e83e 100644 --- a/frontend/src/admin/EditorView.vue +++ b/frontend/src/admin/EditorView.vue @@ -281,6 +281,9 @@ async function autosave() { function applySaved(saved) { form.slug = saved.slug form.status = saved.status + // 自动摘要/自动封面在服务端补齐的,回写表单让站主看见(清空仍会保留为空) + if (typeof saved.summary === 'string') form.summary = saved.summary + if (typeof saved.cover_url === 'string') form.cover_url = saved.cover_url publishedLocal.value = isoToLocal(saved.published_at) }