From fec383f2570692b22ff8f1728af87d4b71b674d5 Mon Sep 17 00:00:00 2001 From: gouki Date: Thu, 13 Aug 2026 00:31:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(wiki):=20=E4=BA=8C=E5=8D=81=E5=9B=9B?= =?UTF-8?q?=E8=8A=82=E6=B0=94=E8=AF=A6=E6=83=85=E6=8A=93=E5=8F=96=E7=99=BE?= =?UTF-8?q?=E5=BA=A6=E7=99=BE=E7=A7=91=E5=B9=B6=E5=9B=BE=E6=96=87=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据源:百度百科开放接口 BaikeLemmaCardApi(无反爬,返回结构化 JSON) 与「历史上的今天」(维基)完全解耦,维基代码原样保留可随时恢复: 服务端: - WikiEntry 增加 image(配图)和 extra(结构化字段 JSON)字段 - 新建 BaikeTermSyncLog 独立日志表(不污染维基 WikiSyncLog) - 新建 BaikeTermSyncService 独立同步服务(24 节气,间隔 1.2s) - 启动时缺详情自动补齐,之后按配置间隔定时增量刷新 - 管理 API:GET/POST /admin/api/wiki/term-sync* 管理后台: - 百科条目 Tab 顶部加节气同步卡片(X/24 + 状态 + 同步按钮) - 条目编辑器加配图 URL(含预览)和节气扩展字段编辑 小程序: - 百科页展开详情图文展示:配图 + 简介 + 物候/习俗/养生等分块 本地验证:24/24 节气抓取成功,image/extra 全部填充,维基数据不受影响 --- mini/pages/wiki/wiki.js | 30 ++ mini/pages/wiki/wiki.wxml | 12 +- mini/pages/wiki/wiki.wxss | 47 +++ server/cmd/main.go | 7 + server/internal/config/database.go | 1 + server/internal/handler/wiki.go | 40 ++ server/internal/model/baike_term.go | 18 + server/internal/model/wiki.go | 2 + server/internal/service/baike_term_sync.go | 367 ++++++++++++++++++ .../internal/service/baike_term_sync_test.go | 130 +++++++ server/internal/service/wiki.go | 2 + server/web/static/js/pages/Wiki.js | 109 +++++- 12 files changed, 759 insertions(+), 6 deletions(-) create mode 100644 server/internal/model/baike_term.go create mode 100644 server/internal/service/baike_term_sync.go create mode 100644 server/internal/service/baike_term_sync_test.go diff --git a/mini/pages/wiki/wiki.js b/mini/pages/wiki/wiki.js index b3cc910..98f2a0c 100644 --- a/mini/pages/wiki/wiki.js +++ b/mini/pages/wiki/wiki.js @@ -41,6 +41,34 @@ const TERM_EXPLAINS = [ { title: '胎神方位', brief: '孕妇需避开的方位', content: '胎神是传统民俗中保护胎儿的神灵,每日所在方位不同。孕妇不宜在胎神方位动土、钉钉或搬动重物,以免惊动胎神。' } ]; +// 节气扩展字段的展示顺序与中文标签 +const EXTRA_LABELS = [ + { key: 'alias', name: '别名' }, + { key: 'english', name: '外文名' }, + { key: 'meaning', name: '涵义' }, + { key: 'solarDate', name: '公历时间' }, + { key: 'ecliptic', name: '黄道位置' }, + { key: 'climate', name: '气候特点' }, + { key: 'phenology', name: '物候现象' }, + { key: 'farming', name: '农事活动' }, + { key: 'customs', name: '传统习俗' }, + { key: 'health', name: '起居养生' }, + { key: 'flower', name: '花信' } +]; + +// buildExtraList 把服务端 extra JSON 字符串解析为 [{name, value}] 展示列表(仅保留非空项,按定义顺序) +function buildExtraList(extraStr) { + if (!extraStr) return []; + let obj = {}; + try { obj = JSON.parse(extraStr); } catch (e) { return []; } + const list = []; + EXTRA_LABELS.forEach(f => { + const v = (obj[f.key] || '').trim(); + if (v) list.push({ name: f.name, value: v }); + }); + return list; +} + function buildEntries() { const entries = []; @@ -139,6 +167,8 @@ Page({ title: s.title, brief: s.brief, content: s.content, + image: s.image || '', + extraList: buildExtraList(s.extra), open: false }; if (titleIndex[s.title] !== undefined) { diff --git a/mini/pages/wiki/wiki.wxml b/mini/pages/wiki/wiki.wxml index 34ede43..11ab28b 100644 --- a/mini/pages/wiki/wiki.wxml +++ b/mini/pages/wiki/wiki.wxml @@ -26,7 +26,17 @@ {{item.brief}} - {{item.content}} + + + + {{item.content}} + + + + {{field.name}} + {{field.value}} + + diff --git a/mini/pages/wiki/wiki.wxss b/mini/pages/wiki/wiki.wxss index feeb8f0..43e2de7 100644 --- a/mini/pages/wiki/wiki.wxss +++ b/mini/pages/wiki/wiki.wxss @@ -51,6 +51,53 @@ line-height: 1.7; } +/* 节气配图 */ +.entry-image { + width: 100%; + border-radius: 12rpx; + margin-bottom: 16rpx; + background: #F5F0EA; +} + +/* 详细简介 */ +.entry-content { + display: block; + color: #4A4036; +} + +/* 结构化扩展字段列表 */ +.extra-list { + margin-top: 16rpx; + background: #FBF7F1; + border-radius: 12rpx; + padding: 8rpx 20rpx; +} + +.extra-item { + display: flex; + padding: 12rpx 0; + border-bottom: 1rpx solid #F0E6DA; +} + +.extra-item:last-child { + border-bottom: none; +} + +.extra-name { + flex-shrink: 0; + width: 140rpx; + font-size: 26rpx; + color: #A08B76; + font-weight: 500; +} + +.extra-value { + flex: 1; + font-size: 26rpx; + color: #4A4036; + line-height: 1.6; +} + .empty-tip { padding: 80rpx 0; } diff --git a/server/cmd/main.go b/server/cmd/main.go index 42c000f..699cb9c 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -53,6 +53,10 @@ func main() { handler.WikiSync = service.NewWikiSyncService(config.GetDB()) handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour) + // 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐) + handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB()) + handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour) + // 设置运行模式 if cfg.Server.Env == "production" { gin.SetMode(gin.ReleaseMode) @@ -155,6 +159,9 @@ func main() { authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete) authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus) authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger) + // 节气详情同步(百度百科,独立于维基) + authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus) + authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger) } } diff --git a/server/internal/config/database.go b/server/internal/config/database.go index c0f8050..bdf0005 100644 --- a/server/internal/config/database.go +++ b/server/internal/config/database.go @@ -62,6 +62,7 @@ func autoMigrate() error { &model.WikiEntry{}, &model.WikiOnThisDay{}, &model.WikiSyncLog{}, + &model.BaikeTermSyncLog{}, ); err != nil { return err } diff --git a/server/internal/handler/wiki.go b/server/internal/handler/wiki.go index d089c0a..56903c0 100644 --- a/server/internal/handler/wiki.go +++ b/server/internal/handler/wiki.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/gouki/lunar-server/internal/config" "github.com/gouki/lunar-server/internal/model" "github.com/gouki/lunar-server/internal/service" ) @@ -12,6 +13,9 @@ import ( // WikiSync 维基百科同步服务实例(main 中注入) var WikiSync *service.WikiSyncService +// BaikeTermSync 节气详情同步服务实例(main 中注入,独立于维基同步) +var BaikeTermSync *service.BaikeTermSyncService + // ===== 公开接口(小程序) ===== // GetWikiEntries 百科条目列表(小程序百科页) @@ -159,3 +163,39 @@ func AdminWikiSyncTrigger(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "同步任务已启动"}) } + +// ===== 节气详情同步(百度百科,独立于维基) ===== + +// AdminTermSyncStatus 节气详情同步状态(X/24 已含详情 + 最近一次同步日志) +func AdminTermSyncStatus(c *gin.Context) { + db := config.GetDB() + var detailed int64 + db.Model(&model.WikiEntry{}).Where("category = ? AND image != ''", "term").Count(&detailed) + + var lastLog model.BaikeTermSyncLog + db.Order("started_at DESC").First(&lastLog) + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{ + "running": BaikeTermSync != nil && BaikeTermSync.IsRunning(), + "detailedTerms": detailed, + "totalTerms": 24, + "lastSync": lastLog, + }, + }) +} + +// AdminTermSyncTrigger 手动触发节气详情同步(异步执行) +func AdminTermSyncTrigger(c *gin.Context) { + if BaikeTermSync == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "节气同步服务未初始化"}) + return + } + if !BaikeTermSync.TriggerSync("admin", true) { + c.JSON(http.StatusConflict, gin.H{"code": 409, "msg": "已有节气同步任务正在执行,请稍后再试"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "节气同步任务已启动"}) +} diff --git a/server/internal/model/baike_term.go b/server/internal/model/baike_term.go new file mode 100644 index 0000000..d37b40d --- /dev/null +++ b/server/internal/model/baike_term.go @@ -0,0 +1,18 @@ +package model + +import ( + "time" +) + +// BaikeTermSyncLog 节气详情同步日志(独立于维基 WikiSyncLog,互不影响) +type BaikeTermSyncLog struct { + ID uint `gorm:"primaryKey" json:"id"` + Trigger string `gorm:"size:20" json:"trigger"` // startup:启动自动 cron:定时任务 admin:手动 + Status string `gorm:"size:20" json:"status"` // running:进行中 success:成功 partial:部分失败 failed:失败 + Pages int `json:"pages"` // 总节气数(24) + Success int `json:"success"` // 成功数 + Failed int `json:"failed"` // 失败数 + Error string `gorm:"size:500" json:"error"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` // 同步完成时间,进行中为 NULL +} diff --git a/server/internal/model/wiki.go b/server/internal/model/wiki.go index 93642c9..949410e 100644 --- a/server/internal/model/wiki.go +++ b/server/internal/model/wiki.go @@ -11,6 +11,8 @@ type WikiEntry struct { Title string `gorm:"size:50" json:"title"` Brief string `gorm:"size:255" json:"brief"` // 简介(列表折叠时显示) Content string `gorm:"type:text" json:"content"` // 详细内容(展开后显示) + Image string `gorm:"size:255" json:"image"` // 配图 URL(节气等条目的配图) + Extra string `gorm:"type:text" json:"extra"` // 扩展字段 JSON(节气:别名/外文名/物候/习俗/养生等) Sort int `gorm:"default:0" json:"sort"` Status int `gorm:"default:1" json:"status"` // 1:显示 0:隐藏 CreatedAt time.Time `json:"createdAt"` diff --git a/server/internal/service/baike_term_sync.go b/server/internal/service/baike_term_sync.go new file mode 100644 index 0000000..ca4070b --- /dev/null +++ b/server/internal/service/baike_term_sync.go @@ -0,0 +1,367 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + "sync/atomic" + "time" + + "github.com/gouki/lunar-server/internal/model" + "gorm.io/gorm" +) + +// BaikeTermSyncService 二十四节气详情同步服务 +// 数据源:百度百科开放接口 BaikeLemmaCardApi(词条卡片,含简介/配图/信息卡) +// 与「历史上的今天」(WikiSyncService)完全独立,互不影响 +type BaikeTermSyncService struct { + db *gorm.DB + + running int32 // 原子标记:是否有同步正在执行 + + httpClient *http.Client +} + +// NewBaikeTermSyncService 创建节气同步服务 +func NewBaikeTermSyncService(db *gorm.DB) *BaikeTermSyncService { + return &BaikeTermSyncService{ + db: db, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// solarTermNames 二十四节气(顺序即 Sort 排序) +var solarTermNames = []string{ + "立春", "雨水", "惊蛰", "春分", "清明", "谷雨", + "立夏", "小满", "芒种", "夏至", "小暑", "大暑", + "立秋", "处暑", "白露", "秋分", "寒露", "霜降", + "立冬", "小雪", "大雪", "冬至", "小寒", "大寒", +} + +var ( + // reTermSup 匹配 ... 引用标记(含内容一并去除,避免残留引用编号) + reTermSup = regexp.MustCompile(`(?s)]*>.*?`) + reTermHTMLTag = regexp.MustCompile(`]*>`) + reTermSpace = regexp.MustCompile(`\s+`) +) + +// IsRunning 是否有同步任务正在执行 +func (s *BaikeTermSyncService) IsRunning() bool { + return atomic.LoadInt32(&s.running) == 1 +} + +// detailedTermCount 已含详情(image 非空)的节气条目数 +func (s *BaikeTermSyncService) detailedTermCount() int64 { + var count int64 + s.db.Model(&model.WikiEntry{}). + Where("category = ? AND image != ''", "term"). + Count(&count) + return count +} + +// TriggerSync 触发一次节气同步(异步执行);已有任务执行中时返回 false +// full=true 全量刷新 24 节气;full=false 仅补齐缺详情的节气 +func (s *BaikeTermSyncService) TriggerSync(trigger string, full bool) bool { + names := solarTermNames + if !full { + names = s.missingTerms() + if len(names) == 0 { + return false // 无缺失,无需同步 + } + } + if !atomic.CompareAndSwapInt32(&s.running, 0, 1) { + return false + } + go func() { + defer atomic.StoreInt32(&s.running, 0) + s.runSync(trigger, names) + }() + return true +} + +// missingTerms 尚未抓取详情的节气(image 为空) +func (s *BaikeTermSyncService) missingTerms() []string { + var existing []string + s.db.Model(&model.WikiEntry{}). + Where("category = ? AND image != ''", "term"). + Pluck("title", &existing) + set := make(map[string]bool, len(existing)) + for _, t := range existing { + set[t] = true + } + missing := make([]string, 0, len(solarTermNames)) + for _, name := range solarTermNames { + if !set[name] { + missing = append(missing, name) + } + } + return missing +} + +// StartScheduler 启动定时同步任务: +// - 启动时若节气详情缺失,延迟 15 秒后自动补齐 +// - 之后每天在 syncHour 小时点检查,距上次成功超过 intervalDays 天才执行 +func (s *BaikeTermSyncService) StartScheduler(intervalDays, syncHour int) { + go func() { + if missing := s.missingTerms(); len(missing) > 0 { + log.Printf("term sync: 缺失 %d/24 节气详情,15秒后自动补齐", len(missing)) + time.Sleep(15 * time.Second) + s.TriggerSync("startup", false) + } + + if intervalDays <= 0 { + intervalDays = 7 + } + if syncHour < 0 || syncHour > 23 { + syncHour = 4 + } + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + if now.Hour() != syncHour { + continue + } + if last, ok := s.lastSuccessAt(); ok && time.Since(last) < time.Duration(intervalDays)*24*time.Hour { + continue // 未到刷新周期 + } + log.Println("term sync: 定时任务触发全量同步") + s.TriggerSync("cron", true) + } + }() +} + +// lastSuccessAt 最近一次成功(含部分成功)的同步完成时间 +func (s *BaikeTermSyncService) lastSuccessAt() (time.Time, bool) { + var syncLog model.BaikeTermSyncLog + err := s.db.Where("status IN ?", []string{"success", "partial"}). + Order("finished_at DESC").First(&syncLog).Error + if err != nil || syncLog.FinishedAt == nil { + return time.Time{}, false + } + return *syncLog.FinishedAt, true +} + +// runSync 同步指定节气集合(全量 24 或缺失补齐) +func (s *BaikeTermSyncService) runSync(trigger string, names []string) { + syncLog := model.BaikeTermSyncLog{ + Trigger: trigger, + Status: "running", + Pages: len(names), + StartedAt: time.Now(), + } + s.db.Create(&syncLog) + + var success, failed int32 + for _, name := range names { + if err := s.syncOneTerm(name); err != nil { + atomic.AddInt32(&failed, 1) + log.Printf("term sync: %s 失败: %v", name, err) + } else { + atomic.AddInt32(&success, 1) + } + time.Sleep(1200 * time.Millisecond) // 控制请求频率,避免触发限流 + } + + status := "success" + if failed > 0 { + status = "partial" + } + if success == 0 { + status = "failed" + } + now := time.Now() + s.db.Model(&syncLog).Updates(map[string]interface{}{ + "status": status, + "success": int(success), + "failed": int(failed), + "finished_at": &now, + }) + log.Printf("term sync: 同步完成 trigger=%s terms=%d success=%d failed=%d", trigger, len(names), success, failed) +} + +// ===== 百度百科节气抓取与解析 ===== + +// baikeCardItem 信息卡单项 +type baikeCardItem struct { + Key string `json:"key"` + Name string `json:"name"` + Value []string `json:"value"` +} + +// baikeLemmaCard 百度百科词条卡片返回结构(只取需要的字段) +type baikeLemmaCard struct { + Key string `json:"key"` // 词条名 + Desc string `json:"desc"` // 一句话描述 + Title string `json:"title"` // 标题 + Abstract string `json:"abstract"`// 简介(含 HTML) + Image string `json:"image"` // 配图 URL + Card []baikeCardItem `json:"card"` // 信息卡 +} + +// syncOneTerm 抓取单个节气详情并写入/更新 WikiEntry +func (s *BaikeTermSyncService) syncOneTerm(name string) error { + lemma, err := s.fetchLemma(name) + if err != nil { + return err + } + + // 解析信息卡为扩展字段 + extra := map[string]string{} + for _, c := range lemma.Card { + text := cleanTermHTML(firstValue(c.Value)) + if text == "" { + continue + } + switch c.Name { + case "别名": + extra["alias"] = text + case "外文名": + extra["english"] = text + case "涵义": + extra["meaning"] = text + case "公历时间": + extra["solarDate"] = text + case "黄道位置": + extra["ecliptic"] = text + case "气候特点": + extra["climate"] = text + case "物候现象": + extra["phenology"] = text + case "农事活动": + extra["farming"] = text + case "传统习俗": + extra["customs"] = text + case "起居养生": + extra["health"] = text + case "花信": + extra["flower"] = text + } + } + extraJSON, _ := json.Marshal(extra) + + content := cleanTermHTML(lemma.Abstract) + brief := extra["meaning"] + if brief == "" { + brief = firstSentence(content) + } + if len([]rune(brief)) > 120 { + brief = string([]rune(brief)[:120]) + } + + // 按 category=term AND title=name 查找,存在则更新,不存在则新建 + var entry model.WikiEntry + err = s.db.Where("category = ? AND title = ?", "term", name).First(&entry).Error + if err == gorm.ErrRecordNotFound { + entry = model.WikiEntry{ + Category: "term", + Title: name, + Brief: brief, + Content: content, + Image: lemma.Image, + Extra: string(extraJSON), + Sort: termSort(name), + Status: 1, + } + return s.db.Create(&entry).Error + } else if err != nil { + return err + } + return s.db.Model(&entry).Updates(map[string]interface{}{ + "brief": brief, + "content": content, + "image": lemma.Image, + "extra": string(extraJSON), + }).Error +} + +// fetchLemma 调用百度百科 BaikeLemmaCardApi 抓取词条卡片(失败重试) +func (s *BaikeTermSyncService) fetchLemma(name string) (*baikeLemmaCard, error) { + apiURL := "https://baike.baidu.com/api/openapi/BaikeLemmaCardApi?scope=103&format=json&appid=379020&bk_length=600&bk_key=" + url.QueryEscape(name) + + var lastErr error + for attempt := 0; attempt < 4; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt*3) * time.Second) + } + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36") + req.Header.Set("Referer", "https://baike.baidu.com/") + req.Header.Set("Accept", "application/json, text/plain, */*") + + resp, err := s.httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 上限 2MB + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + continue + } + if err != nil { + lastErr = err + continue + } + + var lemma baikeLemmaCard + if err := json.Unmarshal(body, &lemma); err != nil { + lastErr = fmt.Errorf("JSON 解析失败: %w", err) + continue + } + if lemma.Abstract == "" && len(lemma.Card) == 0 { + lastErr = fmt.Errorf("%s 返回内容为空", name) + continue + } + return &lemma, nil + } + return nil, lastErr +} + +// termSort 节气在列表中的排序(按节气顺序) +func termSort(name string) int { + for i, n := range solarTermNames { + if n == name { + return i + 1 + } + } + return 0 +} + +// firstValue 取信息卡 value 的第一个值 +func firstValue(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + +// firstSentence 取文本首句(按句号/感叹号/问号切分) +func firstSentence(s string) string { + for _, sep := range []string{"。", "!", "?"} { + if idx := strings.Index(s, sep); idx > 0 { + return s[:idx+len(sep)] + } + } + return s +} + +// cleanTermHTML 去除 HTML 标签(含 sup 引用标记整体)与多余空白,输出纯文本(本地实现,与维基同步解耦) +func cleanTermHTML(s string) string { + s = reTermSup.ReplaceAllString(s, "") // 先去 引用编号(含内容) + s = reTermHTMLTag.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, " ", " ") + s = reTermSpace.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} diff --git a/server/internal/service/baike_term_sync_test.go b/server/internal/service/baike_term_sync_test.go new file mode 100644 index 0000000..4115e9e --- /dev/null +++ b/server/internal/service/baike_term_sync_test.go @@ -0,0 +1,130 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +// 取自百度百科 BaikeLemmaCardApi 「立春」的真实返回片段 +const sampleLemmaJSON = `{ + "id":25702,"key":"立春","desc":"二十四节气之一","title":"立春", + "image":"https://bkimg.cdn.bcebos.com/pic/d62a6059252dd42a2834edd463674cb5c9ea15ce1205", + "abstract":"立春(Beginning of Spring),又名正月节、岁节、改岁、岁旦、打春等,为二十四节气之首。当太阳到达黄经315°时为立春,于每年公历2月3-5日交节。", + "card":[ + {"key":"m25_nameC","name":"中文名","value":["立春"]}, + {"key":"m25_nameE","name":"外文名","value":["Beginning of Spring3","Lichun"]}, + {"key":"m25_alternateName","name":"别名","value":["岁首、岁节、改岁、立春节、正月节"]}, + {"key":"m25_meaning","name":"涵义","value":["阳气上升,万物更生,新岁开启"]}, + {"key":"m25_solarDate","name":"公历时间","value":["公历2月3日-2月5日交节"]}, + {"key":"m25_ecliptic","name":"黄道位置","value":["太阳到达黄经315度"]}, + {"key":"m25_phenology","name":"物候现象","value":["东风解冻、蛰虫始振、鱼陟负冰"]}, + {"key":"m25_farming","name":"农事活动","value":["备耕春播"]}, + {"key":"m25_customs","name":"传统习俗","value":["演春、迎春、打春"]}, + {"key":"m25_health","name":"起居养生","value":["防躁动,避免过度劳累,保证睡眠情绪豁达。"]}, + {"key":"m25_flower","name":"花信","value":["迎春、樱桃、望春35"]} + ] +}` + +func TestParseLemmaCard(t *testing.T) { + var lemma baikeLemmaCard + if err := json.Unmarshal([]byte(sampleLemmaJSON), &lemma); err != nil { + t.Fatalf("JSON 解析失败: %v", err) + } + if lemma.Key != "立春" { + t.Errorf("key = %q, want 立春", lemma.Key) + } + if lemma.Image == "" { + t.Error("image 不应为空") + } + if !strings.Contains(lemma.Abstract, "二十四节气之首") { + t.Errorf("abstract 应含「二十四节气之首」, got %q", lemma.Abstract) + } + if len(lemma.Card) == 0 { + t.Fatal("card 不应为空") + } +} + +func TestLemmaCardMapping(t *testing.T) { + var lemma baikeLemmaCard + json.Unmarshal([]byte(sampleLemmaJSON), &lemma) + + extra := map[string]string{} + for _, c := range lemma.Card { + text := cleanTermHTML(firstValue(c.Value)) + if text == "" { + continue + } + switch c.Name { + case "别名": + extra["alias"] = text + case "外文名": + extra["english"] = text + case "涵义": + extra["meaning"] = text + case "物候现象": + extra["phenology"] = text + case "传统习俗": + extra["customs"] = text + case "起居养生": + extra["health"] = text + } + } + + if extra["alias"] != "岁首、岁节、改岁、立春节、正月节" { + t.Errorf("alias = %q", extra["alias"]) + } + if extra["meaning"] != "阳气上升,万物更生,新岁开启" { + t.Errorf("meaning = %q", extra["meaning"]) + } + if extra["phenology"] != "东风解冻、蛰虫始振、鱼陟负冰" { + t.Errorf("phenology = %q", extra["phenology"]) + } + // 传统习俗含 HTML 链接,应被清洗 + if strings.Contains(extra["customs"], " 应清洗 + if strings.Contains(extra["english"], "") { + t.Errorf("english 应清洗 sup 标签, got %q", extra["english"]) + } +} + +func TestCleanTermHTML(t *testing.T) { + cases := map[string]string{ + `演春、迎春`: "演春、迎春", + `Beginning of Spring3`: "Beginning of Spring", + ` 多余  空白 `: "多余 空白", + } + for in, want := range cases { + if got := cleanTermHTML(in); got != want { + t.Errorf("cleanTermHTML(%q) = %q, want %q", in, got, want) + } + } +} + +func TestFirstSentence(t *testing.T) { + s := "立春为二十四节气之首。当太阳到达黄经315°时为立春。" + if got := firstSentence(s); got != "立春为二十四节气之首。" { + t.Errorf("firstSentence = %q", got) + } + // 无句号时返回原文 + if got := firstSentence("无句号文本"); got != "无句号文本" { + t.Errorf("firstSentence 无句号 = %q", got) + } +} + +func TestTermSort(t *testing.T) { + if termSort("立春") != 1 { + t.Errorf("termSort(立春) = %d, want 1", termSort("立春")) + } + if termSort("大寒") != 24 { + t.Errorf("termSort(大寒) = %d, want 24", termSort("大寒")) + } + if termSort("不存在") != 0 { + t.Errorf("termSort(不存在) = %d, want 0", termSort("不存在")) + } +} diff --git a/server/internal/service/wiki.go b/server/internal/service/wiki.go index 18c9657..6691657 100644 --- a/server/internal/service/wiki.go +++ b/server/internal/service/wiki.go @@ -61,6 +61,8 @@ func (s *WikiService) UpdateEntry(id uint, entry *model.WikiEntry) error { "title": entry.Title, "brief": entry.Brief, "content": entry.Content, + "image": entry.Image, + "extra": entry.Extra, "sort": entry.Sort, "status": entry.Status, }).Error diff --git a/server/web/static/js/pages/Wiki.js b/server/web/static/js/pages/Wiki.js index beded52..4226a9d 100644 --- a/server/web/static/js/pages/Wiki.js +++ b/server/web/static/js/pages/Wiki.js @@ -47,6 +47,27 @@ export default {
+ +
+
+
+
+
节气详情(百度百科)
+
+ 已含详情 {{ termSync.detailedTerms || 0 }} / {{ termSync.totalTerms || 24 }} + | 最近同步:{{ formatTime(termSync.lastSync.startedAt) }} + | {{ termSync.lastSync.status === 'success' ? '成功' : termSync.lastSync.status === 'partial' ? '部分失败' : '失败' }} +
+
+
+
+ + +
+
+
@@ -170,6 +191,21 @@ export default {
+
+ + +
配图预览
+
+ +
+ +
+
+ + +
+
+
@@ -210,20 +246,39 @@ export default { editor: { visible: false, id: null, - form: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 }, + form: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 }, + extra: {}, }, + termExtraFields: [ + { key: 'alias', name: '别名' }, + { key: 'english', name: '外文名' }, + { key: 'meaning', name: '涵义' }, + { key: 'solarDate', name: '公历时间' }, + { key: 'ecliptic', name: '黄道位置' }, + { key: 'climate', name: '气候特点' }, + { key: 'phenology', name: '物候现象' }, + { key: 'farming', name: '农事活动' }, + { key: 'customs', name: '传统习俗' }, + { key: 'health', name: '起居养生' }, + { key: 'flower', name: '花信' }, + ], saving: false, syncing: false, syncStatus: {}, syncTimer: null, + termSync: {}, + termSyncing: false, + termSyncTimer: null, }; }, mounted() { this.loadEntries(); this.loadSyncStatus(); + this.loadTermSyncStatus(); }, beforeUnmount() { if (this.syncTimer) clearInterval(this.syncTimer); + if (this.termSyncTimer) clearInterval(this.termSyncTimer); }, methods: { catName(key) { @@ -265,22 +320,40 @@ export default { openEditor(entry) { this.editor.id = entry ? entry.id : null; this.editor.form = entry - ? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, sort: entry.sort, status: entry.status } - : { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 }; + ? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, image: entry.image || '', sort: entry.sort, status: entry.status } + : { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 }; + let extra = {}; + if (entry && entry.extra) { + try { extra = JSON.parse(entry.extra); } catch (e) { extra = {}; } + } + this.editor.extra = extra; this.editor.visible = true; }, async saveEntry() { const f = this.editor.form; if (!f.title.trim()) { alert('标题不能为空'); return; } + // 节气分类:把 extra 对象序列化为 JSON 字符串 + const payload = { ...f }; + if (f.category === 'term') { + const extra = {}; + this.termExtraFields.forEach(fd => { + const v = (this.editor.extra[fd.key] || '').trim(); + if (v) extra[fd.key] = v; + }); + payload.extra = JSON.stringify(extra); + } else { + payload.extra = ''; + } this.saving = true; try { if (this.editor.id) { - await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(f) }); + await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { - await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(f) }); + await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(payload) }); } this.editor.visible = false; this.loadEntries(); + this.loadTermSyncStatus(); } catch (e) { alert(e.message); } finally { @@ -322,5 +395,31 @@ export default { this.syncing = false; } }, + async loadTermSyncStatus() { + try { + this.termSync = await this.api('/admin/api/wiki/term-sync-status'); + if (this.termSync.running && !this.termSyncTimer) { + this.termSyncTimer = setInterval(() => this.loadTermSyncStatus(), 5000); + } else if (!this.termSync.running && this.termSyncTimer) { + clearInterval(this.termSyncTimer); + this.termSyncTimer = null; + } + } catch (e) { + console.error(e); + } + }, + async triggerTermSync() { + if (!confirm('将从百度百科抓取 24 节气的详细图文(约 30 秒),确认执行?')) return; + this.termSyncing = true; + try { + await this.api('/admin/api/wiki/term-sync', { method: 'POST' }); + this.loadTermSyncStatus(); + setTimeout(() => this.loadEntries(), 3000); + } catch (e) { + alert(e.message); + } finally { + this.termSyncing = false; + } + }, }, }