From f7669badf2c34f29c768ad742bc1f2914c294105 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:05:46 +0800 Subject: [PATCH] =?UTF-8?q?=E9=95=BF=E6=96=87=E8=87=AA=E5=8A=A8=E6=91=98?= =?UTF-8?q?=E8=A6=81=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=B0=81=E9=9D=A2=EF=BC=9A?= =?UTF-8?q?=E6=9C=AA=E5=A1=AB=E6=97=B6=E4=BB=8E=E6=AD=A3=E6=96=87=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=E2=80=94=E2=80=94=E6=91=98=E8=A6=81=E5=89=A5=E8=AF=AD?= =?UTF-8?q?=E6=B3=95=E5=8F=96=E5=BC=80=E5=A4=B4=20110=20=E5=AD=97=EF=BC=88?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=A1=8C/=E4=BB=A3=E7=A0=81=E5=9D=97/?= =?UTF-8?q?=E5=9B=BE=E9=93=BE=E6=8E=A5=E5=89=94=E9=99=A4=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=8F=96=E6=AD=A3=E6=96=87=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E5=9B=BE=EF=BC=9B=E6=9B=B4=E6=96=B0=E5=8F=AA=E5=9C=A8?= =?UTF-8?q?=E3=80=8C=E6=96=B0=E6=97=A7=E9=83=BD=E7=A9=BA=E3=80=8D=E6=97=B6?= =?UTF-8?q?=E8=A1=A5=EF=BC=8C=E7=AB=99=E4=B8=BB=E4=B8=BB=E5=8A=A8=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=E7=9A=84=E4=BF=9D=E5=BE=97=E4=BD=8F=EF=BC=9B=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=93=8D=E5=BA=94=E5=9B=9E=E5=86=99=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/admin/api.go | 7 ++- backend/internal/admin/meta.go | 68 +++++++++++++++++++++++++++++ backend/internal/admin/meta_test.go | 62 ++++++++++++++++++++++++++ frontend/src/admin/EditorView.vue | 3 ++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 backend/internal/admin/meta.go create mode 100644 backend/internal/admin/meta_test.go 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) }