diff --git a/mini/pages/home/home.js b/mini/pages/home/home.js index a9ac040..f6f9727 100644 --- a/mini/pages/home/home.js +++ b/mini/pages/home/home.js @@ -1,14 +1,24 @@ const { getDayInfo, getAlmanacInfo, getMonthCalendar, - getLegalHoliday, isWorkday, getUpcomingFestivals, getNextBuddhistFestival + getLegalHoliday, isWorkday, getUpcomingFestivals, getNextBuddhistFestival, getYearFestivals } = require('../../utils/core/index.js'); const { SOLAR_TERM_TIMES } = require('../../utils/core/data.js'); +const { request } = require('../../utils/request.js'); function pad(n) { return String(n).padStart(2, '0'); } function fmt(y, m, d) { return `${y}-${pad(m)}-${pad(d)}`; } +/** 历史上的今天年份显示:负数为公元前,0 为年份不详 */ +function fmtYear(year) { + if (!year) return ''; + return year < 0 ? `前${-year}年` : `${year}年`; +} + +const OTD_CACHE_KEY = 'otd-cache'; +const OTD_CACHE_TTL = 48 * 3600 * 1000; // 48 小时(数据按月日维度存储,变化频率低) + const MONTH_SPAN = 2; // 当前月前后各2个月,共5个月 -const DAY_SPAN = 2; // 当前日前后各2天,共5天 +const DAY_SPAN = 3; // 当前日前后各3天,共7天(固定窗口,避免动态增删导致动画丢失) Page({ data: { @@ -26,6 +36,12 @@ Page({ weekHeaders: ['日', '一', '二', '三', '四', '五', '六'], // 当前选中日期(用于月历联动) dayInfo: {}, + // 历史上的今天加载中 + otdLoading: false, + // 标记是否正在程序触发 swiper 切换(避免 onDaySwiperChange 重复处理) + swiperAnimating: false, + // 标记是否需要在动画完成后滚动窗口 + pendingShift: 0, // 全屏弹窗 zoomVisible: false, zoomTitle: '', @@ -86,11 +102,13 @@ Page({ key: fmt(y, m, d), dayInfo, almanac, legalHoliday, workday, termTime, isFirstOrFifteen, nextBuddhist, upcoming, - showBuddhist: this.data.showBuddhist + showBuddhist: this.data.showBuddhist, + otd: null, // 历史上的今天数据(异步加载) + otdTab: 'event' // event:大事记 birth:出生 }; }, - /** 构建5天的数据,居中显示 */ + /** 构建7天的数据,居中显示 */ buildDayList(centerDate) { const dayList = []; for (let i = -DAY_SPAN; i <= DAY_SPAN; i++) { @@ -105,6 +123,7 @@ Page({ currentYear: center.dayInfo.solarYear, currentMonth: center.dayInfo.solarMonth }); + this.ensureOtd(DAY_SPAN); }, /** 设置变化后刷新 dayList */ @@ -121,7 +140,7 @@ Page({ /** 日视图 swiper 切换 */ onDaySwiperChange(e) { const index = e.detail.current; - const { dayList } = this.data; + const { dayList, swiperAnimating } = this.data; const cur = dayList[index]; this.setData({ dayIndex: index, @@ -129,44 +148,164 @@ Page({ currentYear: cur.dayInfo.solarYear, currentMonth: cur.dayInfo.solarMonth }); + this.ensureOtd(index); - // 边缘时向对应方向补一天,保持可无限滑动 + // 程序触发切换时不处理边缘滚动(避免打断动画) + if (swiperAnimating) { + this.setData({ swiperAnimating: false }); + return; + } + + // 用户手动滑动到边缘时,标记待滚动方向,等动画完成后再滚动 if (index === 0) { - this.prependDay(); + this.setData({ pendingShift: -1 }); } else if (index === dayList.length - 1) { - this.appendDay(); + this.setData({ pendingShift: 1 }); } }, - prependDay() { - const { dayList } = this.data; - const first = dayList[0]; - const date = new Date(first.dayInfo.solarYear, first.dayInfo.solarMonth - 1, first.dayInfo.solarDay - 1); - dayList.unshift(this.buildDayData(date)); - if (dayList.length > 7) dayList.pop(); - this.setData({ dayList, dayIndex: this.data.dayIndex + 1 }); + /** 日视图 swiper 动画完成 */ + onDaySwiperFinish() { + const { pendingShift } = this.data; + if (pendingShift !== 0) { + this.setData({ pendingShift: 0 }); + this.shiftDayWindow(pendingShift); + } }, - appendDay() { + /** 滚动日数据窗口:direction -1=向前,1=向后 */ + shiftDayWindow(direction) { const { dayList } = this.data; - const last = dayList[dayList.length - 1]; - const date = new Date(last.dayInfo.solarYear, last.dayInfo.solarMonth - 1, last.dayInfo.solarDay + 1); - dayList.push(this.buildDayData(date)); - if (dayList.length > 7) dayList.shift(); - this.setData({ dayList, dayIndex: this.data.dayIndex - 1 }); + const edge = direction < 0 ? dayList[0] : dayList[dayList.length - 1]; + const offset = direction < 0 ? -1 : 1; + const newDate = new Date(edge.dayInfo.solarYear, edge.dayInfo.solarMonth - 1, edge.dayInfo.solarDay + offset); + const newDay = this.buildDayData(newDate); + + let newList, newIndex; + if (direction < 0) { + // 向前滚动:新数据加到前面,当前索引+1(因为前面多了一天) + newList = [newDay, ...dayList.slice(0, -1)]; + newIndex = this.data.dayIndex + 1; + } else { + // 向后滚动:新数据加到后面,当前索引-1(因为前面少了一天) + newList = [...dayList.slice(1), newDay]; + newIndex = this.data.dayIndex - 1; + } + + // 滚动窗口后,current 保持连续,swiper 会平滑滑动到新位置 + this.setData({ + dayList: newList, + dayIndex: newIndex, + dayInfo: newList[newIndex].dayInfo, + currentYear: newList[newIndex].dayInfo.solarYear, + currentMonth: newList[newIndex].dayInfo.solarMonth + }); + this.ensureOtd(newIndex); }, prevDay() { - if (this.data.dayIndex > 0) { - this.setData({ dayIndex: this.data.dayIndex - 1 }); - this.updateCurrentDayInfo(); + const { dayIndex, dayList } = this.data; + if (dayIndex > 0) { + // 不在边缘,正常切换 + const index = dayIndex - 1; + this.setData({ dayIndex: index, swiperAnimating: true }); + this.ensureOtd(index); + } else { + // 在边缘,滚动窗口(窗口滚动后 dayIndex 会调整为 1,swiper 从 0 滑到 1,有动画) + this.shiftDayWindow(-1); } }, nextDay() { - if (this.data.dayIndex < this.data.dayList.length - 1) { - this.setData({ dayIndex: this.data.dayIndex + 1 }); - this.updateCurrentDayInfo(); + const { dayIndex, dayList } = this.data; + if (dayIndex < dayList.length - 1) { + // 不在边缘,正常切换 + const index = dayIndex + 1; + this.setData({ dayIndex: index, swiperAnimating: true }); + this.ensureOtd(index); + } else { + // 在边缘,滚动窗口(窗口滚动后 dayIndex 会调整为 5,swiper 从 6 滑到 5,有动画) + this.shiftDayWindow(1); + } + }, + + /** ===== 历史上的今天(数据来源:中文维基百科) ===== */ + + /** 确保指定 index 的日期已加载 OTD 数据(优先读缓存) */ + ensureOtd(index) { + const item = this.data.dayList[index]; + if (!item || item.otd || item.otdLoading) return; + + const m = item.dayInfo.solarMonth; + const d = item.dayInfo.solarDay; + const cacheKey = `${m}-${d}`; + + // 读本地缓存(按月日维度,48 小时有效) + try { + const cache = wx.getStorageSync(OTD_CACHE_KEY) || {}; + const hit = cache[cacheKey]; + if (hit && hit.data && Date.now() - hit.ts < OTD_CACHE_TTL) { + this.applyOtd(index, item.key, hit.data); + return; + } + } catch (e) { /* 缓存不可用时忽略 */ } + + this.setData({ [`dayList[${index}].otdLoading`]: true }); + request({ url: `/api/wiki/on-this-day?month=${m}&day=${d}` }) + .then(res => { + if (res && res.code === 0 && res.data) { + this.applyOtd(index, item.key, res.data); + this.saveOtdCache(cacheKey, res.data); + } + }) + .catch(() => { /* 网络失败时不显示卡片 */ }) + .then(() => { + // 窗口可能已滚动,先确认 index 处仍是同一天 + const cur = this.data.dayList[index]; + if (cur && cur.key === item.key) { + this.setData({ [`dayList[${index}].otdLoading`]: false }); + } + }); + }, + + /** 将 OTD 数据写入指定日期(异步返回时窗口可能已滚动,需校验 key) */ + applyOtd(index, key, data) { + const cur = this.data.dayList[index]; + if (!cur || cur.key !== key) return; + + const withYear = list => (list || []).map(it => ({ ...it, yearText: fmtYear(it.year) })); + const events = withYear(data.events); + const births = withYear(data.births); + const deaths = withYear(data.deaths); + const festivals = withYear(data.festivals); + if (!events.length && !births.length) return; // 无数据不显示卡片 + + this.setData({ + [`dayList[${index}].otd`]: { + events, births, deaths, festivals, + eventsPreview: events.slice(0, 4), + birthsPreview: births.slice(0, 4) + } + }); + }, + + /** 写入本地缓存(最多保留 80 天,超出淘汰最早的) */ + saveOtdCache(cacheKey, data) { + try { + const cache = wx.getStorageSync(OTD_CACHE_KEY) || {}; + cache[cacheKey] = { ts: Date.now(), data }; + const keys = Object.keys(cache); + if (keys.length > 80) delete cache[keys[0]]; + wx.setStorageSync(OTD_CACHE_KEY, cache); + } catch (e) { /* 存储失败时忽略 */ } + }, + + /** 卡片内切换 大事记/出生 */ + switchOtdTab(e) { + const { date, tab } = e.currentTarget.dataset; + const index = this.data.dayList.findIndex(item => item.key === date); + if (index >= 0) { + this.setData({ [`dayList[${index}].otdTab`]: tab }); } }, @@ -291,6 +430,12 @@ Page({ } else if (type === 'fest') { zoomTitle = '近期节日'; zoomData = { upcoming: dayItem.upcoming }; + } else if (type === 'fest-year') { + zoomTitle = `${dayInfo.solarYear}年节日`; + zoomData = { yearFestivals: getYearFestivals(dayInfo.solarYear) }; + } else if (type === 'otd') { + zoomTitle = `${dayInfo.solarMonth}月${dayInfo.solarDay}日 历史上的今天`; + zoomData = dayItem.otd; } this.setData({ zoomVisible: true, zoomTitle, zoomType: type, zoomData }); }, @@ -303,5 +448,20 @@ Page({ wx.navigateTo({ url: e.currentTarget.dataset.url }); }, - noop() {} + noop() {}, + + // 分享给好友 + onShareAppMessage() { + return { + title: '老黄历 - 传统农历黄历查询', + path: '/pages/home/home' + }; + }, + + // 分享到朋友圈 + onShareTimeline() { + return { + title: '老黄历 - 传统农历黄历查询' + }; + } }); diff --git a/mini/pages/home/home.wxml b/mini/pages/home/home.wxml index b2d9692..574d37a 100644 --- a/mini/pages/home/home.wxml +++ b/mini/pages/home/home.wxml @@ -18,6 +18,7 @@ class="day-swiper" current="{{dayIndex}}" bindchange="onDaySwiperChange" + bindanimationfinish="onDaySwiperFinish" duration="300" circular="{{false}}" > @@ -92,46 +93,75 @@ - + - - + + 值神 {{dayItem.almanac.duty}} - + 黄道 {{dayItem.almanac.twelveStar.name}}({{dayItem.almanac.twelveStar.ecliptic}}) - + 二十八宿 {{dayItem.almanac.twentyEightStar.name}} - + 六曜 {{dayItem.almanac.sixStar}} - + 冲煞 冲{{dayItem.almanac.clash}} 煞{{dayItem.almanac.evilDirection}} - + 彭祖百忌 {{dayItem.almanac.pengZu}} - - - 节日速查 - - + + + + 节日速查 + 更多 >> + + + {{item.name}} {{item.solarMonth}}月{{item.solarDay}}日 {{item.daysLeft === 0 ? '今天' : item.daysLeft + '天后'}} + 暂无近期节日 + + + + + + 历史上的今天 + + 大事记 + 出生 + + + + + {{ev.yearText}} + {{ev.content}} + + + + 来源:中文维基百科 + 查看完整 › + + + + 历史上的今天 + 加载中… @@ -162,26 +192,22 @@ - - - - {{d.solarDay}} - - {{d.lunarFestival || d.solarFestival || d.lunarDayName}} - - - - - + + + {{d.solarDay}} + + {{d.lunarFestival || d.solarFestival || d.lunarDayName}} + + + + @@ -264,5 +290,46 @@ {{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}} + + + + + {{item.name}} + {{item.solarMonth}}月{{item.solarDay}}日 + {{item.type === 'lunar' ? '农历' : '公历'}} + + + + + + + 大事记 + + {{item.yearText}} + {{item.content}} + + + + 出生 + + {{item.yearText}} + {{item.content}} + + + + 逝世 + + {{item.yearText}} + {{item.content}} + + + + 节假日与习俗 + + {{item.content}} + + + 数据来源:中文维基百科(CC BY-SA 4.0) + diff --git a/mini/pages/home/home.wxss b/mini/pages/home/home.wxss index da52c99..5d70718 100644 --- a/mini/pages/home/home.wxss +++ b/mini/pages/home/home.wxss @@ -191,11 +191,12 @@ align-items: center; justify-content: center; gap: 20rpx; - padding: 0 16rpx; + padding: 0 8rpx; /* 减小padding让数字有更多空间 */ + min-width: 0; /* 允许内容收缩 */ } .almanac-day-num { - font-size: 200rpx; + font-size: 240rpx; /* 宜忌固定高度后,日历数字可以放大 */ font-weight: 700; line-height: 1; text-align: center; @@ -299,14 +300,35 @@ flex: 1; display: flex; flex-wrap: wrap; - gap: 8rpx; + gap: 12rpx 8rpx; /* 行间距12rpx,列间距8rpx */ align-content: flex-start; + max-height: 88rpx; /* 固定2行高度:item 32rpx*2 + 行距12rpx + padding 12rpx */ + overflow: hidden; + position: relative; +} + +.frame-yiji-items::after { + content: '...'; + position: absolute; + right: 0; + bottom: 0; + background: #FFFDF8; + padding-left: 8rpx; + font-size: 24rpx; + color: #9E8E7E; + line-height: 32rpx; } .frame-yiji-item { font-size: 24rpx; padding: 4rpx 12rpx; border-radius: 8rpx; + height: 32rpx; + line-height: 24rpx; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; } .frame-yiji-item.yi { background: #FEF2F2; color: #C41E3A; } @@ -330,37 +352,114 @@ color: #C41E3A; } -/* 黄历详情(label 大、value 小、加间隔) */ -.detail-grid { - display: flex; - flex-direction: column; - gap: 20rpx; +/* 黄历详情(紧凑布局,每行2个) */ +.detail-grid-compact { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12rpx; } -.detail-item { +.detail-item-compact { display: flex; align-items: center; - gap: 24rpx; - padding: 16rpx; + gap: 12rpx; + padding: 12rpx; background: #FFFBF5; - border-radius: 12rpx; + border-radius: 8rpx; } -.detail-label { - font-size: 28rpx; +.detail-item-compact .detail-label { + font-size: 24rpx; color: #9E8E7E; - min-width: 140rpx; + min-width: 70rpx; flex-shrink: 0; } -.detail-value { - font-size: 28rpx; +.detail-item-compact .detail-value { + font-size: 24rpx; font-weight: 500; color: #1A1A1A; flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -/* 节日速查 */ +/* 预留空间占位卡片 */ +.placeholder-card { + opacity: 0.6; +} + +.placeholder-content { + text-align: center; + padding: 40rpx; + color: #C9B8A6; + font-size: 26rpx; +} + +/* 节日速查头部 */ +.fest-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.fest-head .section-title { + margin-bottom: 0; +} + +.fest-more { + font-size: 26rpx; + color: #C41E3A; + font-weight: 500; +} + +.fest-empty { + text-align: center; + padding: 40rpx; + color: #C9B8A6; + font-size: 26rpx; +} + +/* 今年节日弹窗(字体稍小) */ +.zoom-fest-year-row { + display: flex; + align-items: center; + padding: 20rpx 0; + border-bottom: 1rpx solid #F0E6DA; + gap: 20rpx; +} + +.zoom-fest-year-name { + font-size: 32rpx; + font-weight: 600; + min-width: 140rpx; +} + +.zoom-fest-year-date { + font-size: 28rpx; + color: #9E8E7E; + flex: 1; +} + +.zoom-fest-year-type { + font-size: 22rpx; + padding: 4rpx 16rpx; + border-radius: 999rpx; +} + +.zoom-fest-year-type.lunar { + background: #FEF2F2; + color: #C41E3A; +} + +.zoom-fest-year-type.solar { + background: #F0F9FF; + color: #0369A1; +} + +/* 节日速查列表 */ .fest-quick { display: flex; flex-direction: column; @@ -468,10 +567,10 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 16rpx 4rpx; + padding: 8rpx 4rpx; background: #FFFFFF; border-radius: 12rpx; - min-height: 110rpx; + min-height: 80rpx; /* 压缩格子高度,避免数字看起来竖向排列 */ } .day-cell-inner.today { @@ -488,14 +587,16 @@ } .day-number { - font-size: 30rpx; - font-weight: 500; + font-size: 36rpx; /* 月历数字放大 */ + font-weight: 600; + line-height: 1.2; } .day-lunar { font-size: 20rpx; color: #9E8E7E; - margin-top: 4rpx; + margin-top: 2rpx; + line-height: 1.2; } .day-lunar.festival { @@ -663,3 +764,130 @@ padding: 4rpx 24rpx; border-radius: 999rpx; } + +/* ===== 历史上的今天 ===== */ +.otd-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12rpx; +} + +.otd-head .section-title { + margin-bottom: 0; +} + +.otd-tabs { + display: flex; + gap: 8rpx; +} + +.otd-tab { + font-size: 24rpx; + padding: 4rpx 20rpx; + border-radius: 999rpx; + color: #9E8E7E; + background: #F5EFE8; +} + +.otd-tab.active { + color: #FFFFFF; + background: #C41E3A; +} + +.otd-list { + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.otd-item { + display: flex; + align-items: flex-start; + gap: 12rpx; +} + +.otd-year { + flex-shrink: 0; + min-width: 104rpx; + font-size: 24rpx; + font-weight: 600; + color: #C41E3A; +} + +.otd-text { + flex: 1; + font-size: 26rpx; + color: #5A4E42; + line-height: 1.5; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.otd-text.no-year { + margin-left: 116rpx; +} + +.otd-foot { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16rpx; + padding-top: 16rpx; + border-top: 1rpx solid #F0E8DE; +} + +.otd-source { + font-size: 22rpx; + color: #C9B8A6; +} + +.otd-more { + font-size: 26rpx; + color: #C41E3A; + font-weight: 500; +} + +/* ===== 历史上的今天放大弹窗 ===== */ +.zoom-otd-title { + font-size: 34rpx; + font-weight: 600; + color: #C41E3A; + margin: 24rpx 0 16rpx; +} + +.zoom-otd-title:first-child { + margin-top: 0; +} + +.zoom-otd-item { + display: flex; + align-items: flex-start; + gap: 16rpx; + padding: 12rpx 0; + border-bottom: 1rpx solid #F0E8DE; +} + +.zoom-otd-item .otd-year { + font-size: 30rpx; + min-width: 120rpx; +} + +.zoom-otd-item .otd-text { + font-size: 32rpx; + -webkit-line-clamp: unset; + overflow: visible; +} + +.zoom-otd-item .otd-text.no-year { + margin-left: 136rpx; +} + +.zoom-otd-source { + margin-top: 24rpx; + text-align: center; + font-size: 24rpx; + color: #C9B8A6; +} diff --git a/mini/pages/wiki/wiki.js b/mini/pages/wiki/wiki.js index 89062db..b3cc910 100644 --- a/mini/pages/wiki/wiki.js +++ b/mini/pages/wiki/wiki.js @@ -1,4 +1,5 @@ const { SOLAR_TERMS, LUNAR_FESTIVALS, SOLAR_FESTIVALS, HEAVEN_STEMS, EARTH_BRANCHES, ZODIACS } = require('../../utils/core/data.js'); +const { request } = require('../../utils/request.js'); const CATEGORIES = [ { key: 'all', name: '全部' }, @@ -6,9 +7,16 @@ const CATEGORIES = [ { key: 'lunarFest', name: '农历节日' }, { key: 'solarFest', name: '公历节日' }, { key: 'ganzhi', name: '干支生肖' }, - { key: 'termExplain', name: '黄历术语' } + { key: 'termExplain', name: '黄历术语' }, + { key: 'other', name: '其他' } ]; +// 服务端分类 key 到展示名的映射 +const CAT_NAMES = { + term: '节气', lunarFest: '农历节日', solarFest: '公历节日', + ganzhi: '干支生肖', termExplain: '术语', other: '其他' +}; + const TERM_BRIEFS = { '立春': '春季开始,万物复苏', '雨水': '降雨增多,气温回升', '惊蛰': '春雷惊醒蛰伏动物', '春分': '昼夜平分,春季过半', '清明': '天气晴朗,草木繁茂', '谷雨': '雨生百谷,播种时节', @@ -112,6 +120,37 @@ Page({ onLoad() { const entries = buildEntries(); this.setData({ entries, filteredEntries: entries }); + this.loadServerEntries(); + }, + + /** 从服务端加载百科条目(管理后台可维护),与本地数据合并:同标题以服务端为准,新增条目追加 */ + loadServerEntries() { + request({ url: '/api/wiki/entries' }) + .then(res => { + if (!res || res.code !== 0 || !res.data || !res.data.list) return; + const merged = [...this.data.entries]; + const titleIndex = {}; + merged.forEach((e, i) => { titleIndex[e.title] = i; }); + + res.data.list.forEach(s => { + const entry = { + cat: s.category, + catName: CAT_NAMES[s.category] || s.category, + title: s.title, + brief: s.brief, + content: s.content, + open: false + }; + if (titleIndex[s.title] !== undefined) { + merged[titleIndex[s.title]] = entry; // 服务端内容覆盖本地 + } else { + merged.push(entry); + } + }); + + this.setData({ entries: merged }, () => this.filter()); + }) + .catch(() => { /* 网络失败时使用本地内置数据 */ }); }, switchCat(e) { @@ -134,5 +173,18 @@ Page({ const index = e.currentTarget.dataset.index; const key = `filteredEntries[${index}].open`; this.setData({ [key]: !this.data.filteredEntries[index].open }); + }, + + onShareAppMessage() { + return { + title: '黄历百科 - 传统文化知识', + path: '/pages/wiki/wiki' + }; + }, + + onShareTimeline() { + return { + title: '黄历百科 - 传统文化知识' + }; } }); diff --git a/mini/utils/core/index.js b/mini/utils/core/index.js index 7db4f30..c0dd164 100644 --- a/mini/utils/core/index.js +++ b/mini/utils/core/index.js @@ -1260,6 +1260,30 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) { return list.slice(0, count); } +/** 获取今年所有节日(农历+公历) */ +function getYearFestivals(year) { + const list = []; + for (const fest of FESTIVAL_QUICK_SEARCH) { + const solar = getFestivalSolarDate(year, fest); + if (solar) { + list.push({ + name: fest.name, + type: fest.type, + solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`, + solarYear: solar.year, + solarMonth: solar.month, + solarDay: solar.day + }); + } + } + // 按日期排序 + list.sort((a, b) => { + if (a.solarMonth !== b.solarMonth) return a.solarMonth - b.solarMonth; + return a.solarDay - b.solarDay; + }); + return list; +} + /** 获取下一个佛教节日及倒计时(基准日可选,默认今天) */ function getNextBuddhistFestival(baseYear, baseMonth, baseDay) { const now = new Date(); @@ -1428,6 +1452,7 @@ module.exports = { getWeekDay, getFestivalSolarDate, getUpcomingFestivals, + getYearFestivals, getNextBuddhistFestival, daysBetween, analyzeShensha, diff --git a/server/README.md b/server/README.md index 6dad13f..83e7e1c 100644 --- a/server/README.md +++ b/server/README.md @@ -7,6 +7,7 @@ - **用户模块** — 微信登录、资料修改、授权管理 - **支付模块** — 微信支付集成、订单管理 - **许愿树** — 许愿发布、付费许愿、机器人自动许愿 +- **百科/历史上的今天** — 百科条目后台维护;从中文维基百科同步 366 天「历史上的今天」数据,定时刷新 - **管理后台** — 基于 Inertia.js 的前后端分离管理界面 ## 技术栈 @@ -87,9 +88,26 @@ server/ - `GET /api/wish/list` — 许愿列表 - `DELETE /api/wish/:id` — 删除许愿 +### 百科接口 + +- `GET /api/wiki/entries` — 百科条目列表(支持 category/keyword 过滤) +- `GET /api/wiki/on-this-day?month=&day=` — 历史上的今天(大事记/出生/逝世/节假日习俗) + +### 管理后台接口(/admin,需管理员认证) + +- `GET/POST /admin/api/wiki/entries` — 百科条目列表/新增 +- `PUT/DELETE /admin/api/wiki/entries/:id` — 更新/删除条目 +- `GET /admin/api/wiki/sync-status` — 维基同步状态 +- `POST /admin/api/wiki/sync` — 手动触发全量同步 + ## 环境变量 -参考 `.env.local` 文件配置。 +参考 `.env.local` 文件配置。维基同步相关: + +- `WIKI_SYNC_INTERVAL_DAYS` — 自动同步间隔(天),默认 7 +- `WIKI_SYNC_HOUR` — 自动同步执行的小时点(0-23),默认 4 + +同步策略:服务启动时若数据缺失自动补齐;之后每天在指定小时点检查,距上次成功超过间隔天数才全量刷新(共 366 页,只抓中文版)。 ## 数据库 diff --git a/server/cmd/main.go b/server/cmd/main.go index 35f2e57..42c000f 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -49,6 +49,10 @@ func main() { wishService := service.NewWishService() wishService.StartRobotWishJob() + // 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取) + handler.WikiSync = service.NewWikiSyncService(config.GetDB()) + handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour) + // 设置运行模式 if cfg.Server.Env == "production" { gin.SetMode(gin.ReleaseMode) @@ -118,6 +122,13 @@ func main() { wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish) wish.GET("/products", handler.GetWishProducts) } + + // 百科/历史上的今天(无需认证) + wiki := api.Group("/wiki") + { + wiki.GET("/entries", handler.GetWikiEntries) + wiki.GET("/on-this-day", handler.GetOnThisDay) + } } // 管理后台路由(Inertia.js) @@ -134,7 +145,16 @@ func main() { authed.GET("/users", handler.AdminUsers) authed.GET("/orders", handler.AdminOrders) authed.GET("/wishes", handler.AdminWishes) + authed.GET("/wiki", handler.AdminWiki) authed.GET("/settings", handler.AdminSettings) + + // 百科管理 API + authed.GET("/api/wiki/entries", handler.AdminWikiEntries) + authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate) + authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate) + authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete) + authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus) + authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger) } } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index f5e03a0..36496c6 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "strconv" ) type Config struct { @@ -11,6 +12,7 @@ type Config struct { JWT JWTConfig Wechat WechatConfig Admin AdminConfig + Wiki WikiConfig } type ServerConfig struct { @@ -52,6 +54,14 @@ type AdminConfig struct { Password string } +// WikiConfig 百科/历史上的今天(维基百科同步)配置 +type WikiConfig struct { + // SyncIntervalDays 同步间隔(天),距上次成功同步超过该间隔才会自动执行 + SyncIntervalDays int + // SyncHour 自动同步执行的小时点(0-23) + SyncHour int +} + func Load() *Config { return &Config{ Server: ServerConfig{ @@ -85,9 +95,22 @@ func Load() *Config { Admin: AdminConfig{ Password: getEnv("ADMIN_PASSWORD", ""), }, + Wiki: WikiConfig{ + SyncIntervalDays: getEnvInt("WIKI_SYNC_INTERVAL_DAYS", 7), + SyncHour: getEnvInt("WIKI_SYNC_HOUR", 4), + }, } } +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if n, err := strconv.Atoi(value); err == nil { + return n + } + } + return defaultValue +} + func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value diff --git a/server/internal/config/database.go b/server/internal/config/database.go index bfc8a04..c0f8050 100644 --- a/server/internal/config/database.go +++ b/server/internal/config/database.go @@ -59,12 +59,20 @@ func autoMigrate() error { &model.WishTree{}, &model.Wish{}, &model.WishProduct{}, + &model.WikiEntry{}, + &model.WikiOnThisDay{}, + &model.WikiSyncLog{}, ); err != nil { return err } // 初始化默认数据 - return seedDefaultData() + if err := seedDefaultData(); err != nil { + return err + } + + // 初始化百科种子数据(详细版,可后台补充/修改) + return seedWikiEntries() } // seedDefaultData 初始化默认数据 diff --git a/server/internal/config/wiki_seed.go b/server/internal/config/wiki_seed.go new file mode 100644 index 0000000..5ba1735 --- /dev/null +++ b/server/internal/config/wiki_seed.go @@ -0,0 +1,155 @@ +package config + +import ( + "log" + + "github.com/gouki/lunar-server/internal/model" +) + +// seedWikiEntries 初始化百科种子数据(仅表为空时写入,后续可在管理后台补充/修改) +func seedWikiEntries() error { + var count int64 + DB.Model(&model.WikiEntry{}).Count(&count) + if count > 0 { + return nil + } + + entries := defaultWikiEntries() + if err := DB.Create(&entries).Error; err != nil { + return err + } + log.Printf("Wiki entries seeded: %d", len(entries)) + return nil +} + +type seedEntry struct { + Category string + Title string + Brief string + Content string +} + +// defaultWikiEntries 详细版百科内容 +func defaultWikiEntries() []model.WikiEntry { + sort := 0 + build := func(list []seedEntry) []model.WikiEntry { + out := make([]model.WikiEntry, 0, len(list)) + for _, e := range list { + sort++ + out = append(out, model.WikiEntry{ + Category: e.Category, + Title: e.Title, + Brief: e.Brief, + Content: e.Content, + Sort: sort, + Status: 1, + }) + } + return out + } + + all := seedSolarTerms() + all = append(all, seedLunarFestivals()...) + all = append(all, seedSolarFestivals()...) + all = append(all, seedGanZhi()...) + all = append(all, seedTermExplains()...) + return build(all) +} + +// ===== 二十四节气 ===== +func seedSolarTerms() []seedEntry { + return []seedEntry{ + {"term", "立春", "春季开始,万物复苏", "立春为二十四节气之首,一般在公历2月3日至5日交节,标志着冬季结束、春季开始。此时东风解冻、蛰虫始振,民间有迎春、打春牛、咬春(吃春饼、萝卜)等习俗,寓意辞旧迎新、祈福纳吉。"}, + {"term", "雨水", "降雨开始增多,气温回升", "雨水节气意味着降水渐增、冰雪消融,春雨滋润大地,是备耕春耕的重要时节。民间有回娘家、拉保保(拜干爹)等习俗。此时乍暖还寒,养生宜防湿健脾、适当春捂。"}, + {"term", "惊蛰", "春雷惊醒蛰伏的动物", "惊蛰时春雷乍动,惊醒蛰伏冬眠的昆虫,气温迅速回升,春耕大忙开始。古有惊蛰日祭白虎、蒙鼓皮的习俗,民间也讲究吃梨以润肺防燥。桃始华、仓庚鸣,春意渐浓。"}, + {"term", "春分", "昼夜平分,春季过半", "春分日太阳直射赤道,昼夜等长,此后阳光直射点北移,北半球昼长夜短。春分有竖蛋、吃春菜、送春牛图等趣味习俗。此时农谚云春分麦起身,肥水要紧跟。"}, + {"term", "清明", "天气清朗明净,草木繁茂", "清明既是节气也是传统节日,有踏青、扫墓祭祖、插柳、放风筝、荡秋千等习俗。此时气温升高、雨量增多,正是春耕春种大好时机,谚语清明前后,种瓜点豆。"}, + {"term", "谷雨", "雨生百谷,播种移苗时节", "谷雨是春季最后一个节气,意为雨生百谷,降雨量渐增有利于谷类作物生长,是播种移苗、埯瓜点豆的最佳时节。民间有赏牡丹、饮谷雨茶、祭仓颉、走谷雨等习俗。"}, + {"term", "立夏", "夏季开始,万物繁茂", "立夏表示告别春天、夏季开始,万物进入旺盛生长期。民间有立夏秤人、吃立夏蛋、尝三新(樱桃、青梅、新麦)等习俗。养生宜养心安神、清淡饮食。"}, + {"term", "小满", "麦类等夏熟作物籽粒开始饱满", "小满意为夏熟作物的籽粒开始灌浆饱满但尚未成熟。此时气温进一步升高、降水增多,民间有祭车神、祭蚕神、食苦菜等习俗。小满也寓意人生小满即安的处世哲学。"}, + {"term", "芒种", "有芒作物成熟收割,忙于夏播", "芒种指有芒的麦子快收、有芒的稻子可种,是农事最繁忙的时节,又称忙种。民间有送花神、安苗、煮青梅等习俗。此时天气炎热,宜防暑补水、晚睡早起。"}, + {"term", "夏至", "白昼最长,盛夏来临", "夏至日太阳直射北回归线,北半球白昼最长。古有夏至祭地、吃面的习俗,取冬至饺子夏至面之意。夏至后进入酷暑,民间开始数伏,养生宜清心寡欲、清淡饮食。"}, + {"term", "小暑", "天气开始炎热,尚未到极点", "小暑意为天气开始炎热但还没到最热,民间有食新、晒伏(晾晒衣物去潮)、吃藕、吃饺子等习俗。小暑前后进入三伏天,要注意防暑降温、饮食卫生。"}, + {"term", "大暑", "一年中最炎热的时期", "大暑是一年中最热的节气,湿热交蒸达到顶点,也是农作物生长最快、雷暴台风最多的时期。民间有饮伏茶、晒伏姜、烧伏香、送大暑船等习俗,养生宜防中暑、忌贪凉。"}, + {"term", "立秋", "秋季开始,暑去凉来", "立秋标志着孟秋开始,暑去凉来。民间有贴秋膘(吃肉进补)、啃秋(吃西瓜)、晒秋等习俗。立秋后早晚渐凉,但常有秋老虎高温,养生宜滋阴润肺、防燥护津。"}, + {"term", "处暑", "暑气至此而止,秋意渐浓", "处暑即出暑,表示炎热暑天结束,秋高气爽、云淡风轻。民间有放河灯、开渔节、吃鸭子的习俗,俗话说处暑送鸭,无病各家。此时秋意渐浓,是出游赏秋的好时节。"}, + {"term", "白露", "天气转凉,清晨露水凝白", "白露是反映气温变化的重要节气,昼夜温差加大,清晨草木上凝结出白色露水。民间有收清露、饮白露茶、酿白露米酒、祭禹王等习俗。养生宜防凉润燥,俗语白露身不露。"}, + {"term", "秋分", "昼夜平分,秋季过半", "秋分日再次昼夜等长,此后夜渐长昼渐短。秋分曾是传统祭月节,中秋节即源于秋分祭月。民间有竖蛋、吃秋菜、粘雀子嘴等习俗。2018年起秋分被设立为中国农民丰收节。"}, + {"term", "寒露", "露水更凉,将要凝结", "寒露时节露水更凉、将欲凝霜,是气候从凉爽到寒冷的过渡节点。民间有登高赏菊、饮菊花酒、吃芝麻的习俗,重阳节常在其前后。养生宜足部保暖、防秋燥。"}, + {"term", "霜降", "天气渐冷,开始降霜", "霜降是秋季最后一个节气,意味着天气渐冷、初霜出现,是秋到冬的过渡。民间有赏菊、吃柿子、进补的习俗,俗语补冬不如补霜降。此时应注意保暖、防秋郁。"}, + {"term", "立冬", "冬季开始,万物收藏", "立冬表示冬季开始,万物进入休养收藏状态。民间有迎冬、补冬(北方吃饺子、南方食羊肉炉)、酿黄酒等习俗。养生宜早卧晚起、避寒就温、滋阴潜阳。"}, + {"term", "小雪", "开始降雪,但雪量尚小", "小雪是反映降雪的节气,意味着降雪开始、雪量不大。民间有腌腊肉、晒鱼干、吃糍粑、酿小雪酒的习俗,冬腊风腌蓄以御冬。此时宜温补益肾、调节情志。"}, + {"term", "大雪", "降雪增多,天寒地冻", "大雪意味着降雪概率增大、天气更冷,瑞雪兆丰年。民间有观赏封河、腌肉、进补的习俗,俗语冬天进补,开春打虎。此时宜保暖防寒、适当食补。"}, + {"term", "冬至", "白昼最短,数九寒天开始", "冬至日北半球白昼最短,此后进入数九寒天。冬至大如年,北方吃饺子、南方吃汤圆,还有祭祖、贺冬、画九九消寒图等习俗。冬至也是阴阳转换的关键节点,养生宜静养藏阳。"}, + {"term", "小寒", "天气寒冷,尚未到极点", "小寒标志着进入一年中最寒冷的日子,常与三九重合,民谚三九四九冰上走。民间有吃腊八粥、探梅、冰嬉的习俗。养生宜重防寒保暖、温补脾肾。"}, + {"term", "大寒", "一年中最寒冷的时期", "大寒是二十四节气中最后一个节气,天气寒冷到极致,也意味着冬去春来、新旧交替。此时正值岁末,民间有尾牙祭、扫尘洁物、准备年货的习俗,迎接新春。"}, + } +} + +// ===== 农历节日 ===== +func seedLunarFestivals() []seedEntry { + return []seedEntry{ + {"lunarFest", "春节", "农历正月初一,百节之首", "春节是中国最隆重热闹的传统节日,俗称过年,源于上古岁首祈岁祭祀。除夕守岁、贴春联、放爆竹、拜年、发压岁钱,正月初一起走亲访友、舞龙舞狮,处处辞旧迎新、祈福纳祥。"}, + {"lunarFest", "元宵节", "农历正月十五,赏灯吃元宵", "元宵节又称上元节、灯节,是春节之后的第一个重要节日。正月十五赏灯会、猜灯谜、吃元宵(汤圆)、舞龙灯,寓意团圆美满。古代元宵节也是青年男女相识的浪漫之夜。"}, + {"lunarFest", "龙抬头", "农历二月初二,剃龙头迎新", "二月初二龙抬头,又称春耕节、农事节,象征春回大地、万物复苏,民间认为龙王开始降雨。习俗有剃龙头(理发)、吃龙食(面条称龙须、饺子称龙耳)、引钱龙,祈愿一年好运。"}, + {"lunarFest", "上巳节", "农历三月初三,临水祓禊", "上巳节俗称三月三,古时人们于水边沐浴祓禊、驱除不祥,后演变为春游踏青、曲水流觞。王羲之《兰亭集序》记载的正是上巳雅集。今壮族、黎族等仍以三月三为重要歌节。"}, + {"lunarFest", "端午节", "农历五月初五,龙舟粽子", "端午节源于自然天象崇拜,后因纪念屈原而广为流传,是中国首个入选世界非遗的节日。习俗有赛龙舟、吃粽子、挂艾草菖蒲、佩香囊、系五彩绳、饮雄黄酒,驱邪避疫、祈福安康。"}, + {"lunarFest", "七夕节", "农历七月初七,中国情人节", "七夕节源于牛郎织女的爱情传说,又称乞巧节、女儿节。旧时女子于此夜穿针乞巧、拜织女,祈求心灵手巧、姻缘美满。2006年列入国家级非物质文化遗产,今被视为中国情人节。"}, + {"lunarFest", "中元节", "农历七月十五,祭祖放河灯", "中元节俗称七月半、鬼节,是道教中元、佛教盂兰盆节与民间祭祖的融合。人们祭祀祖先、焚纸钱、放河灯,缅怀先人、普度亡灵,体现慎终追远的传统文化精神。"}, + {"lunarFest", "中秋节", "农历八月十五,赏月团圆", "中秋节以月圆兆人团圆,是寄托思乡之情、祈盼丰收幸福的传统节日。习俗有赏月、吃月饼、饮桂花酒、玩花灯、观潮,嫦娥奔月、吴刚伐桂的传说为其增添浪漫色彩。"}, + {"lunarFest", "重阳节", "农历九月初九,登高敬老", "重阳节因日月并阳、两九相重而得名,又称登高节、老人节。习俗有登高远眺、赏菊饮酒、插茱萸、吃重阳糕。1989年起被定为敬老节,倡导尊老敬老之风。"}, + {"lunarFest", "寒衣节", "农历十月初一,送寒衣祭祖", "寒衣节又称祭祖节、十月朝,与清明、中元并称三大鬼节。入冬之际,人们祭祀祖先、焚烧纸衣,为先人送去御寒冬衣,寄托对逝者的思念与关怀。"}, + {"lunarFest", "腊八节", "农历腊月初八,喝腊八粥", "腊八节源于佛教纪念释迦牟尼成道,民间喝腊八粥、腌腊八蒜、做腊八豆腐。粥以米、豆、干果等八样食材熬制,寓意吉祥丰收。俗语过了腊八就是年,拉开过年序幕。"}, + {"lunarFest", "小年", "农历腊月廿三/廿四,祭灶扫尘", "小年是春节前的重要民俗日,北方多在腊月廿三、南方多在腊月廿四。主要习俗有祭灶王爷(供糖瓜)、扫尘土、剪窗花、写春联,标志着正式进入忙年阶段。"}, + {"lunarFest", "除夕", "农历腊月最后一天,守岁团圆", "除夕是岁末最后一晚,意为旧岁至此而除、另换新岁。全家团圆吃年夜饭、守岁、贴春联年画、祭祖、发压岁钱,辞旧迎新、爆竹声中一岁除,是中国人情感最浓的时刻。"}, + } +} + +// ===== 公历节日 ===== +func seedSolarFestivals() []seedEntry { + return []seedEntry{ + {"solarFest", "元旦", "公历1月1日,新年伊始", "元旦即公历新年,是世界多数国家通称的新年。1949年中国正式采用公历,将1月1日定为元旦。人们以联欢、倒计时、互赠贺卡等方式迎接新年,万象更新。"}, + {"solarFest", "妇女节", "公历3月8日,国际劳动妇女节", "三八国际劳动妇女节源于20世纪初的妇女运动,纪念妇女争取平等权益的斗争。如今是向母亲、妻子、女友表达敬意与关爱的日子。"}, + {"solarFest", "植树节", "公历3月12日,植树造林", "3月12日为中国植树节,定于1979年,纪念一贯倡导植树造林的孙中山先生(逝世纪念日)。这一天各地组织义务植树活动,绿化祖国、改善生态。"}, + {"solarFest", "愚人节", "公历4月1日,善意的玩笑", "愚人节是西方民间节日,人们在4月1日以各种善意的恶作剧和玩笑互相捉弄,被捉弄者被称为四月傻瓜。玩笑适可而止,重在欢乐氛围。"}, + {"solarFest", "清明节", "公历4月5日前后,扫墓踏青", "清明既是节气也是节日,兼具自然与人文内涵。人们在清明扫墓祭祖、缅怀先人,同时踏青郊游、插柳放风筝,体现天人合一的传统理念。"}, + {"solarFest", "劳动节", "公历5月1日,国际劳动节", "五一国际劳动节源于1886年美国工人争取八小时工作制的运动。中国自1949年将5月1日定为法定劳动节,放假表彰劳动者,举办各类庆祝活动。"}, + {"solarFest", "青年节", "公历5月4日,纪念五四运动", "五四青年节源于1919年5月4日反帝爱国的五四运动。1949年正式定为青年节,激励青年继承爱国、进步、民主、科学的精神。"}, + {"solarFest", "儿童节", "公历6月1日,国际儿童节", "六一国际儿童节设立于1949年,旨在保障世界各国儿童的生存权、保健权和受教育权。这一天学校组织文艺汇演、游园活动,孩子们欢度自己的节日。"}, + {"solarFest", "建党节", "公历7月1日,建党纪念日", "7月1日是中国共产党建党纪念日。1921年7月中共一大召开宣告建党,1941年正式将7月1日确立为建党纪念日,各地举行主题党日与庆祝活动。"}, + {"solarFest", "建军节", "公历8月1日,八一建军节", "八一建军节源于1927年8月1日南昌起义,打响了武装反抗的第一枪。1933年确定8月1日为建军纪念日,这一天致敬中国人民解放军。"}, + {"solarFest", "教师节", "公历9月10日,尊师重教", "9月10日教师节设立于1985年,旨在肯定教师为教育事业所作的贡献。开学伊始感念师恩,学生以贺卡、鲜花表达对老师的敬意。"}, + {"solarFest", "国庆节", "公历10月1日,举国同庆", "10月1日国庆节纪念1949年中华人民共和国成立。黄金周假期里举行升旗仪式、阅兵(逢大庆年份)、灯光秀等活动,举国欢腾、家国同庆。"}, + {"solarFest", "圣诞节", "公历12月25日,西方传统节日", "圣诞节是西方纪念耶稣诞生的传统节日,有圣诞树、圣诞老人、互赠礼物、平安夜弥撒等习俗。如今在中国更多以轻松欢乐的方式呈现,商家氛围浓厚。"}, + } +} + +// ===== 干支生肖 ===== +func seedGanZhi() []seedEntry { + return []seedEntry{ + {"ganzhi", "天干总说", "甲乙丙丁戊己庚辛壬癸", "天干共十个:甲、乙、丙、丁、戊、己、庚、辛、壬、癸。甲丙戊庚壬为阳干,乙丁己辛癸为阴干。天干配五行:甲乙木、丙丁火、戊己土、庚辛金、壬癸水,古人用以纪日纪年。"}, + {"ganzhi", "地支总说", "子丑寅卯辰巳午未申酉戌亥", "地支共十二个:子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥。地支与十二生肖一一对应(子鼠、丑牛、寅虎、卯兔、辰龙、巳蛇、午马、未羊、申猴、酉鸡、戌狗、亥猪),并用以纪月纪时。"}, + {"ganzhi", "六十甲子", "天干地支循环组合,六十年一轮回", "天干地支按顺序两两相配,从甲子到癸亥共六十个组合,称六十甲子,循环往复。用于纪年、纪月、纪日、纪时。人的六十岁生日称花甲之年,也称还历。"}, + {"ganzhi", "生肖由来", "十二种动物纪年,人人有其属相", "十二生肖与十二地支相配,以动物纪年,传说源于黄帝时代或动物渡河比赛的故事。民俗中生肖与婚配、运势紧密相关,本命年有穿红戴红的习俗以趋吉避凶。"}, + {"ganzhi", "五行生克", "金木水火土,相生又相克", "五行指金、木、水、火、土。相生:木生火、火生土、土生金、金生水、水生木;相克:木克土、土克水、水克火、火克金、金克木。五行学说贯穿中医、命理、风水等传统文化。"}, + {"ganzhi", "阴阳学说", "万物皆分阴阳,对立统一", "阴阳是中国古代哲学的核心概念,认为万物皆有阴阳两面:天为阳地为阴、日为阳月为阴、男为阳女为阴。阴阳消长转化、对立统一,是中医与术数文化的理论基石。"}, + } +} + +// ===== 黄历术语 ===== +func seedTermExplains() []seedEntry { + return []seedEntry{ + {"termExplain", "干支纪年", "天干地支组合纪年法", "天干有十:甲乙丙丁戊己庚辛壬癸;地支有十二:子丑寅卯辰巳午未申酉戌亥。两者按顺序两两组合,形成六十甲子循环,用于纪年、纪月、纪日、纪时。如2024年为甲辰年,2025年为乙巳年。"}, + {"termExplain", "建除十二神", "建、除、满、平、定、执、破、危、成、收、开、闭", "建除十二神又称十二值日,是古代择日的重要依据。建日宜出行,除日宜疗病,满日宜嫁娶,平日宜修造,定日宜祈福,执日宜祭祀,破日宜破屋,危日宜安床,成日宜开市,收日宜纳财,开日宜开光,闭日宜安葬。"}, + {"termExplain", "黄道吉日", "青龙、明堂、金匮、天德、玉堂、司命为黄道", "黄道黑道是古代择日术语。黄道日(青龙、明堂、金匮、天德、玉堂、司命值日)诸事皆宜;黑道日(天刑、朱雀、白虎、天牢、玄武、勾陈值日)宜避凶就吉。老黄历中诸事皆宜的日子多为黄道吉日。"}, + {"termExplain", "二十八宿", "古代天文学家将黄道附近的星宿分为二十八组", "二十八宿分东方青龙(角亢氐房心尾箕)、北方玄武(斗牛女虚危室壁)、西方白虎(奎娄胃昴毕觜参)、南方朱雀(井鬼柳星张翼轸)四象,每象七宿。古人用二十八宿观测天象、判断日子吉凶。"}, + {"termExplain", "六曜", "先胜、友引、先负、佛灭、大安、赤口", "六曜起源于中国,后传入日本并广泛流行。大安日诸事皆吉;先胜日上午吉;友引日早晚吉;先负日上午凶;佛灭日大凶;赤口日午凶。以农历月日之和除以六取余数确定。"}, + {"termExplain", "彭祖百忌", "每日天干地支对应的禁忌事项", "彭祖百忌是古代择日学的重要内容,相传彭祖寿八百岁,总结出每日干支的禁忌。如甲不开仓财物耗散,子不问卜自惹祸殃,共六十条对应六十甲子,老黄历每日宜忌之下常附此语。"}, + {"termExplain", "冲煞", "日支与某生肖相冲,该生肖之人需谨慎", "冲煞指日支与某地支相冲(子午相冲、丑未相冲、寅申相冲、卯酉相冲、辰戌相冲、巳亥相冲),对应生肖之人当日宜谨慎行事。煞方指当日凶煞所在方位,不宜在该方位动土或远行。"}, + {"termExplain", "纳音五行", "六十甲子与五行的配合", "纳音是将六十甲子与五行相配,每两组干支合为一种纳音,共三十种。如甲子乙丑海中金、丙寅丁卯炉中火、戊辰己巳大林木等。纳音五行常用于命理分析,俗称什么命(如海中金命)。"}, + {"termExplain", "月相", "朔、上弦、望、下弦的月相变化", "农历初一为朔(新月),初七初八为上弦月,十五为望(满月),廿二廿三为下弦月,月末为晦。月相变化决定农历日期与潮汐,古人也将其用于择吉,如朔日祈福、望日团圆。"}, + {"termExplain", "胎神方位", "孕妇需避开的方位", "胎神是传统民俗中保护胎儿的神灵,每日所在方位不同(如占门碓外东南)。民俗认为孕妇不宜在胎神方位动土、钉钉、搬动重物或刀剪,以免惊动胎神,属传统民俗观念。"}, + {"termExplain", "吉时凶时", "一日十二时辰各有吉凶", "老黄历将一昼夜分为十二时辰(子时至亥时,每时辰两小时),并标注各时段的吉凶宜忌。举办婚礼、开业、动土等重要事宜常择吉时进行,如巳时(9-11点)开业、午时上梁等。"}, + {"termExplain", "值神", "当日当值的十二位神煞", "值神即当日值事之神,与建除十二神对应:青龙、明堂、天刑、朱雀、金匮、天德、白虎、玉堂、天牢、玄武、司命、勾陈依次轮值。青龙明堂金匮天德玉堂司命六神值日为黄道吉日。"}, + } +} diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go index 24b7aee..663389b 100644 --- a/server/internal/handler/admin.go +++ b/server/internal/handler/admin.go @@ -102,6 +102,11 @@ func AdminWishes(c *gin.Context) { adminPage(c, "许愿管理", "wishes") } +// AdminWiki 百科管理(百科条目 + 历史上的今天同步) +func AdminWiki(c *gin.Context) { + adminPage(c, "百科管理", "wiki") +} + // AdminSettings 系统设置 func AdminSettings(c *gin.Context) { adminPage(c, "系统设置", "settings") diff --git a/server/internal/handler/wiki.go b/server/internal/handler/wiki.go new file mode 100644 index 0000000..d089c0a --- /dev/null +++ b/server/internal/handler/wiki.go @@ -0,0 +1,161 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/gouki/lunar-server/internal/model" + "github.com/gouki/lunar-server/internal/service" +) + +// WikiSync 维基百科同步服务实例(main 中注入) +var WikiSync *service.WikiSyncService + +// ===== 公开接口(小程序) ===== + +// GetWikiEntries 百科条目列表(小程序百科页) +func GetWikiEntries(c *gin.Context) { + wikiService := service.NewWikiService() + entries, err := wikiService.ListEntries(c.Query("category"), c.Query("keyword"), 1) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": 500, + "msg": "获取百科条目失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{ + "list": entries, + }, + }) +} + +// GetOnThisDay 历史上的今天(含大事记/出生/逝世/节假日) +func GetOnThisDay(c *gin.Context) { + month, _ := strconv.Atoi(c.Query("month")) + day, _ := strconv.Atoi(c.Query("day")) + + wikiService := service.NewWikiService() + result, err := wikiService.GetOnThisDay(month, day) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "msg": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": result, + }) +} + +// ===== 管理后台接口 ===== + +// AdminWikiEntries 百科条目列表(含隐藏条目) +func AdminWikiEntries(c *gin.Context) { + wikiService := service.NewWikiService() + entries, err := wikiService.ListEntries(c.Query("category"), c.Query("keyword"), -1) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取百科条目失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{"list": entries}, + }) +} + +// AdminWikiEntryCreate 新增百科条目 +func AdminWikiEntryCreate(c *gin.Context) { + var entry model.WikiEntry + if err := c.ShouldBindJSON(&entry); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) + return + } + + wikiService := service.NewWikiService() + if err := wikiService.CreateEntry(&entry); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": entry, + }) +} + +// AdminWikiEntryUpdate 更新百科条目 +func AdminWikiEntryUpdate(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "无效的ID"}) + return + } + + var entry model.WikiEntry + if err := c.ShouldBindJSON(&entry); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) + return + } + + wikiService := service.NewWikiService() + if err := wikiService.UpdateEntry(uint(id), &entry); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "更新失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success"}) +} + +// AdminWikiEntryDelete 删除百科条目 +func AdminWikiEntryDelete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "无效的ID"}) + return + } + + wikiService := service.NewWikiService() + if err := wikiService.DeleteEntry(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success"}) +} + +// AdminWikiSyncStatus 维基百科同步状态 +func AdminWikiSyncStatus(c *gin.Context) { + wikiService := service.NewWikiService() + status, err := wikiService.GetSyncStatus(WikiSync != nil && WikiSync.IsRunning()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取同步状态失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": status, + }) +} + +// AdminWikiSyncTrigger 手动触发全量同步(异步执行) +func AdminWikiSyncTrigger(c *gin.Context) { + if WikiSync == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "同步服务未初始化"}) + return + } + if !WikiSync.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/wiki.go b/server/internal/model/wiki.go new file mode 100644 index 0000000..93642c9 --- /dev/null +++ b/server/internal/model/wiki.go @@ -0,0 +1,42 @@ +package model + +import ( + "time" +) + +// WikiEntry 百科条目(小程序百科页,可在管理后台维护) +type WikiEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + Category string `gorm:"size:30;index" json:"category"` // term:节气 lunarFest:农历节日 solarFest:公历节日 ganzhi:干支生肖 termExplain:黄历术语 other:其他 + Title string `gorm:"size:50" json:"title"` + Brief string `gorm:"size:255" json:"brief"` // 简介(列表折叠时显示) + Content string `gorm:"type:text" json:"content"` // 详细内容(展开后显示) + Sort int `gorm:"default:0" json:"sort"` + Status int `gorm:"default:1" json:"status"` // 1:显示 0:隐藏 + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// WikiOnThisDay 历史上的今天(数据来源:中文维基百科) +type WikiOnThisDay struct { + ID uint `gorm:"primaryKey" json:"id"` + Month int `gorm:"index:idx_otd_day" json:"month"` // 1-12 + Day int `gorm:"index:idx_otd_day" json:"day"` // 1-31 + Kind string `gorm:"size:20;index:idx_otd_day" json:"kind"` // event:大事记 birth:出生 death:逝世 festival:节假日习俗 + Year int `json:"year"` // 0:年份不详,负数:公元前 + Content string `gorm:"type:text" json:"content"` + Status int `gorm:"default:1" json:"status"` +} + +// WikiSyncLog 维基百科同步日志 +type WikiSyncLog struct { + ID uint `gorm:"primaryKey" json:"id"` + Trigger string `gorm:"size:20" json:"trigger"` // startup:schema自动 cron:定时任务 admin:手动 + Status string `gorm:"size:20" json:"status"` // running:进行中 success:成功 partial:部分失败 failed:失败 + Pages int `json:"pages"` // 总页数 + 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/service/wiki.go b/server/internal/service/wiki.go new file mode 100644 index 0000000..18c9657 --- /dev/null +++ b/server/internal/service/wiki.go @@ -0,0 +1,148 @@ +package service + +import ( + "errors" + + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/model" + "gorm.io/gorm" +) + +// WikiService 百科服务 +type WikiService struct { + db *gorm.DB +} + +// NewWikiService 创建百科服务 +func NewWikiService() *WikiService { + return &WikiService{db: config.GetDB()} +} + +// ListEntries 百科条目列表(status<0 表示不过滤状态,供管理后台使用) +func (s *WikiService) ListEntries(category, keyword string, status int) ([]*model.WikiEntry, error) { + query := s.db.Model(&model.WikiEntry{}) + if status >= 0 { + query = query.Where("status = ?", status) + } + if category != "" { + query = query.Where("category = ?", category) + } + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where("title LIKE ? OR brief LIKE ? OR content LIKE ?", like, like, like) + } + + var entries []*model.WikiEntry + if err := query.Order("sort ASC, id ASC").Find(&entries).Error; err != nil { + return nil, err + } + return entries, nil +} + +// CreateEntry 新增百科条目 +func (s *WikiService) CreateEntry(entry *model.WikiEntry) error { + if entry.Category == "" || entry.Title == "" { + return errors.New("分类和标题不能为空") + } + if entry.Status == 0 { + entry.Status = 1 + } + return s.db.Create(entry).Error +} + +// UpdateEntry 更新百科条目 +func (s *WikiService) UpdateEntry(id uint, entry *model.WikiEntry) error { + var existing model.WikiEntry + if err := s.db.First(&existing, id).Error; err != nil { + return err + } + return s.db.Model(&existing).Updates(map[string]interface{}{ + "category": entry.Category, + "title": entry.Title, + "brief": entry.Brief, + "content": entry.Content, + "sort": entry.Sort, + "status": entry.Status, + }).Error +} + +// DeleteEntry 删除百科条目 +func (s *WikiService) DeleteEntry(id uint) error { + return s.db.Delete(&model.WikiEntry{}, id).Error +} + +// OnThisDayResult 某一天的历史数据 +type OnThisDayResult struct { + Events []model.WikiOnThisDay `json:"events"` + Births []model.WikiOnThisDay `json:"births"` + Deaths []model.WikiOnThisDay `json:"deaths"` + Festivals []model.WikiOnThisDay `json:"festivals"` +} + +// GetOnThisDay 查询某一天的历史上的今天数据(各板块限量返回) +func (s *WikiService) GetOnThisDay(month, day int) (*OnThisDayResult, error) { + if month < 1 || month > 12 || day < 1 || day > 31 { + return nil, errors.New("日期参数无效") + } + + var all []model.WikiOnThisDay + if err := s.db.Where("month = ? AND day = ? AND status = 1", month, day). + Order("year ASC, id ASC"). + Find(&all).Error; err != nil { + return nil, err + } + + result := &OnThisDayResult{ + Events: make([]model.WikiOnThisDay, 0), + Births: make([]model.WikiOnThisDay, 0), + Deaths: make([]model.WikiOnThisDay, 0), + Festivals: make([]model.WikiOnThisDay, 0), + } + limits := map[string]int{"event": 20, "birth": 15, "death": 10, "festival": 10} + for _, item := range all { + switch item.Kind { + case "event": + if len(result.Events) < limits["event"] { + result.Events = append(result.Events, item) + } + case "birth": + if len(result.Births) < limits["birth"] { + result.Births = append(result.Births, item) + } + case "death": + if len(result.Deaths) < limits["death"] { + result.Deaths = append(result.Deaths, item) + } + case "festival": + if len(result.Festivals) < limits["festival"] { + result.Festivals = append(result.Festivals, item) + } + } + } + return result, nil +} + +// SyncStatus 同步状态(管理后台展示) +type SyncStatus struct { + Running bool `json:"running"` + TotalDays int64 `json:"totalDays"` // 已覆盖的天数 + TotalItems int64 `json:"totalItems"` // 总条目数 + LastSync *model.WikiSyncLog `json:"lastSync"` +} + +// GetSyncStatus 获取同步状态 +func (s *WikiService) GetSyncStatus(running bool) (*SyncStatus, error) { + status := &SyncStatus{Running: running} + + var totalDays int64 + s.db.Model(&model.WikiOnThisDay{}).Where("status = 1"). + Select("COUNT(DISTINCT month, day) AS c").Scan(&totalDays) + status.TotalDays = totalDays + s.db.Model(&model.WikiOnThisDay{}).Where("status = 1").Count(&status.TotalItems) + + var lastSync model.WikiSyncLog + if err := s.db.Order("id DESC").First(&lastSync).Error; err == nil { + status.LastSync = &lastSync + } + return status, nil +} diff --git a/server/internal/service/wiki_sync.go b/server/internal/service/wiki_sync.go new file mode 100644 index 0000000..a282fb0 --- /dev/null +++ b/server/internal/service/wiki_sync.go @@ -0,0 +1,486 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gouki/lunar-server/internal/model" + "gorm.io/gorm" +) + +// WikiSyncService 维基百科「X月X日」页面同步服务 +// 全量共 366 页(含 2 月 29 日),首次抓取后按配置间隔定期增量刷新 +type WikiSyncService struct { + db *gorm.DB + + running int32 // 原子标记:是否有同步正在执行 + + httpClient *http.Client +} + +// NewWikiSyncService 创建同步服务 +func NewWikiSyncService(db *gorm.DB) *WikiSyncService { + return &WikiSyncService{ + db: db, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +var ( + reComment = regexp.MustCompile(`(?s)`) + reRef = regexp.MustCompile(`(?is)]*/>|]*>.*?`) + reFlag = regexp.MustCompile(`\{\{(?:flag|flagcountry|flagicon)\|([^}|]+)(?:\|[^{}]*)?\}\}`) + reTemplate = regexp.MustCompile(`\{\{[^{}]*\}\}`) + reLink = regexp.MustCompile(`\[\[(?:[^|\[\]]*\|)?([^|\[\]]+)\]\]`) + reHTMLTag = regexp.MustCompile(`]*>`) + reHeading = regexp.MustCompile(`^==+\s*(.*?)\s*==+\s*$`) + // 语言转换标记 -{zh-cn:A;zh-tw:B}- 或 -{A}- + reConv = regexp.MustCompile(`-\{([^{}]*)\}-`) + // 条目开头的年份:前612年: / 1912年: / 1980年代: + reYearPrefix = regexp.MustCompile(`^(前)?(\d{1,4})\s*年代?\s*[::]\s*`) + reNoYear = regexp.MustCompile(`^(年份不详|年份不詳|不详|不詳|生年不详|生年不詳)\s*[::]\s*`) + reLeadPunct = regexp.MustCompile(`^[::,,、;;\.\s]+`) +) + +// daysInMonth 每月天数(含 2 月 29 日,闰日页面维基也有) +var daysInMonth = []int{31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + +type dayKey struct { + Month int + Day int +} + +// allDays 全年 366 天 +func allDays() []dayKey { + days := make([]dayKey, 0, 366) + for m := 1; m <= 12; m++ { + for d := 1; d <= daysInMonth[m-1]; d++ { + days = append(days, dayKey{Month: m, Day: d}) + } + } + return days +} + +// IsRunning 是否有同步任务正在执行 +func (s *WikiSyncService) IsRunning() bool { + return atomic.LoadInt32(&s.running) == 1 +} + +// coveredDaySet 已覆盖的月日集合 +func (s *WikiSyncService) coveredDaySet() map[dayKey]bool { + set := make(map[dayKey]bool, 400) + type row struct { + Month int + Day int + } + var rows []row + s.db.Model(&model.WikiOnThisDay{}).Where("status = 1"). + Select("DISTINCT month, day").Scan(&rows) + for _, r := range rows { + set[dayKey{Month: r.Month, Day: r.Day}] = true + } + return set +} + +// missingDays 尚未覆盖的天 +func (s *WikiSyncService) missingDays() []dayKey { + covered := s.coveredDaySet() + missing := make([]dayKey, 0) + for _, d := range allDays() { + if !covered[d] { + missing = append(missing, d) + } + } + return missing +} + +// TriggerSync 触发一次同步(异步执行);已有任务执行中时返回 false +// full=true 全量刷新 366 页;full=false 仅补齐缺失的天 +func (s *WikiSyncService) TriggerSync(trigger string, full bool) bool { + days := allDays() + if !full { + days = s.missingDays() + if len(days) == 0 { + return false // 无缺失,无需同步 + } + } + if !atomic.CompareAndSwapInt32(&s.running, 0, 1) { + return false + } + go func() { + defer atomic.StoreInt32(&s.running, 0) + s.runSync(trigger, days) + }() + return true +} + +// StartScheduler 启动定时同步任务: +// - 启动时若数据库为空,延迟 10 秒后自动执行首次全量抓取 +// - 之后每天在 syncHour 小时点检查,距上次成功超过 intervalDays 天才执行 +func (s *WikiSyncService) StartScheduler(intervalDays, syncHour int) { + go func() { + if missing := s.missingDays(); len(missing) > 0 { + // 数据缺失(首次部署或上次抓取中断),延迟 10 秒等服务稳定后补齐 + log.Printf("wiki sync: 缺失 %d/366 天,10秒后自动补齐", len(missing)) + time.Sleep(10 * 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("wiki sync: 定时任务触发全量同步") + s.TriggerSync("cron", true) + } + }() +} + +// lastSuccessAt 最近一次成功(含部分成功)的同步完成时间 +func (s *WikiSyncService) lastSuccessAt() (time.Time, bool) { + var syncLog model.WikiSyncLog + 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 同步指定日期集合(全量 366 页或缺失补齐) +func (s *WikiSyncService) runSync(trigger string, days []dayKey) { + syncLog := model.WikiSyncLog{ + Trigger: trigger, + Status: "running", + Pages: len(days), + StartedAt: time.Now(), + } + s.db.Create(&syncLog) + + jobs := make(chan dayKey, len(days)) + for _, d := range days { + jobs <- d + } + close(jobs) + + var success, failed int32 + var wg sync.WaitGroup + workerCount := 3 + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for d := range jobs { + if err := s.syncOneDay(d.Month, d.Day); err != nil { + atomic.AddInt32(&failed, 1) + log.Printf("wiki sync: %d月%d日 失败: %v", d.Month, d.Day, err) + } else { + atomic.AddInt32(&success, 1) + } + time.Sleep(800 * time.Millisecond) // 控制请求频率,避免被限流 + } + }() + } + wg.Wait() + + 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("wiki sync: 同步完成 trigger=%s pages=%d success=%d failed=%d", trigger, len(days), success, failed) +} + +// syncOneDay 抓取并更新某一天的数据(失败不覆盖旧数据;死锁自动重试) +func (s *WikiSyncService) syncOneDay(month, day int) error { + wikitext, err := s.fetchDayPage(month, day) + if err != nil { + return err + } + items := parseDayPage(wikitext) + if len(items) == 0 { + return fmt.Errorf("页面解析结果为空") + } + + for i := range items { + items[i].Month = month + items[i].Day = day + items[i].Status = 1 + } + + // 按天整体替换:先删后插;并发写入同表可能死锁,重试 3 次 + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * time.Second) + } + lastErr = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("month = ? AND day = ?", month, day). + Delete(&model.WikiOnThisDay{}).Error; err != nil { + return err + } + return tx.Create(&items).Error + }) + if lastErr == nil { + return nil + } + if !strings.Contains(lastErr.Error(), "Deadlock") && !strings.Contains(lastErr.Error(), "1213") { + break + } + } + return lastErr +} + +// fetchDayPage 抓取「X月X日」页面的 wikitext(失败重试;429 限流时加大退避间隔) +func (s *WikiSyncService) fetchDayPage(month, day int) (string, error) { + title := fmt.Sprintf("%d月%d日", month, day) + apiURL := "https://zh.wikipedia.org/w/api.php?action=parse&prop=wikitext&format=json&formatversion=2&page=" + + url.QueryEscape(title) + + var lastErr error + for attempt := 0; attempt < 4; attempt++ { + if attempt > 0 { + backoff := time.Duration(attempt*3) * time.Second + if lastErr != nil && strings.Contains(lastErr.Error(), "429") { + backoff = time.Duration(attempt*30) * time.Second // 限流时退避 30s/60s/90s + } + time.Sleep(backoff) + } + text, err := s.doFetch(apiURL) + if err == nil { + return text, nil + } + lastErr = err + } + return "", lastErr +} + +func (s *WikiSyncService) doFetch(apiURL string) (string, error) { + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "LunarServer/1.0 (wiki on-this-day sync; contact: admin@neatcn.com)") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 上限 2MB + if err != nil { + return "", err + } + + var result struct { + Parse struct { + Wikitext string `json:"wikitext"` + } `json:"parse"` + Error struct { + Info string `json:"info"` + } `json:"error"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("JSON 解析失败: %w", err) + } + if result.Error.Info != "" { + return "", fmt.Errorf("API 错误: %s", result.Error.Info) + } + if result.Parse.Wikitext == "" { + return "", fmt.Errorf("页面内容为空") + } + return result.Parse.Wikitext, nil +} + +// ===== wikitext 解析 ===== + +// classifySection 根据二级标题识别板块类型 +func classifySection(title string) string { + switch { + case strings.Contains(title, "大事") || strings.Contains(title, "事件"): + return "event" + case strings.Contains(title, "出生"): + return "birth" + case strings.Contains(title, "逝世") || strings.Contains(title, "去世"): + return "death" + case strings.Contains(title, "节假日") || strings.Contains(title, "节日") || + strings.Contains(title, "節日") || strings.Contains(title, "假日") || + strings.Contains(title, "习俗") || strings.Contains(title, "風俗") || + strings.Contains(title, "风俗"): + return "festival" + } + return "" +} + +// parseDayPage 解析页面 wikitext,提取大事记/出生/逝世/节假日条目 +func parseDayPage(wikitext string) []model.WikiOnThisDay { + items := make([]model.WikiOnThisDay, 0, 128) + seen := make(map[string]bool) // 同板块去重(维基页面偶有重复条目) + + curKind := "" + inTable := false + for _, rawLine := range strings.Split(wikitext, "\n") { + line := strings.TrimSpace(rawLine) + + // 跳过表格区块 + if strings.HasPrefix(line, "{|") { + inTable = true + continue + } + if inTable { + if strings.HasPrefix(line, "|}") { + inTable = false + } + continue + } + + // 二级/三级标题切换板块(三级标题归属当前二级板块) + if m := reHeading.FindStringSubmatch(line); m != nil { + if strings.HasPrefix(line, "=== ") || strings.HasPrefix(line, "===") { + continue // 三级标题:大事记内的世纪分组,不改变板块 + } + curKind = classifySection(m[1]) + continue + } + + if curKind == "" || !strings.HasPrefix(line, "*") || strings.HasPrefix(line, "*>") { + continue + } + + content := strings.TrimSpace(strings.TrimLeft(line, "*")) + if content == "" { + continue + } + year, text := extractYear(cleanWikitext(content)) + if len([]rune(text)) < 4 { // 过短条目无展示价值 + continue + } + if len([]rune(text)) > 500 { + text = string([]rune(text)[:500]) + } + + dedupeKey := curKind + "|" + text + if seen[dedupeKey] { + continue + } + seen[dedupeKey] = true + + items = append(items, model.WikiOnThisDay{ + Kind: curKind, + Year: year, + Content: text, + }) + } + return items +} + +// extractYear 从条目文本提取年份:前612年→-612,1912年→1912,无年份→0 +func extractYear(text string) (int, string) { + text = strings.TrimSpace(text) + if m := reYearPrefix.FindStringSubmatch(text); m != nil { + year, err := strconv.Atoi(m[2]) + if err == nil { + if m[1] == "前" { + year = -year + } + return year, strings.TrimSpace(text[len(m[0]):]) + } + } + if m := reNoYear.FindStringSubmatch(text); m != nil { + return 0, strings.TrimSpace(text[len(m[0]):]) + } + return 0, text +} + +// convertConvMarkup 处理语言转换标记 -{zh-cn:A;zh-tw:B}-,优先取简体变体 +func convertConvMarkup(s string) string { + return reConv.ReplaceAllStringFunc(s, func(m string) string { + inner := m[2 : len(m)-2] // 去掉 -{ 和 }- + parts := strings.Split(inner, ";") + // 无冒号:-{A}- 直接取内容 + if !strings.Contains(inner, ":") { + return strings.TrimSpace(inner) + } + fallback := "" + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + kv := strings.SplitN(p, ":", 2) + if len(kv) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(kv[0])) + val := strings.TrimSpace(kv[1]) + if key == "zh-cn" || key == "zh-hans" { + return val + } + fallback = val + } + return fallback + }) +} + +// cleanWikitext 去除 wiki 语法,输出纯文本 +func cleanWikitext(s string) string { + s = reComment.ReplaceAllString(s, "") + s = reRef.ReplaceAllString(s, "") + s = convertConvMarkup(s) + // 国旗模板保留国家名 + s = reFlag.ReplaceAllString(s, "$1") + // 模板可能嵌套,循环剥离直至稳定 + for i := 0; i < 6; i++ { + next := reTemplate.ReplaceAllString(s, "") + if next == s { + break + } + s = next + } + s = reLink.ReplaceAllString(s, "$1") + s = strings.ReplaceAll(s, "'''", "") + s = strings.ReplaceAll(s, "''", "") + s = reHTMLTag.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, " ", " ") + s = strings.TrimSpace(s) + s = reLeadPunct.ReplaceAllString(s, "") // 模板被剥离后可能残留开头标点 + return strings.TrimSpace(s) +} diff --git a/server/internal/service/wiki_sync_test.go b/server/internal/service/wiki_sync_test.go new file mode 100644 index 0000000..8304839 --- /dev/null +++ b/server/internal/service/wiki_sync_test.go @@ -0,0 +1,113 @@ +package service + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// 取自 zh.wikipedia.org「8月10日」页面的真实片段 +const sampleWikitext = `'''8月10日'''是[[阳历]]年的第222天。 + +== 大事记 == +=== 19世紀以前 === +* [[前612年]]:[[亚述]]最后一位君主[[辛·沙里施昆]]死于[[尼尼微]],王国灭亡。 +* [[955年]]:[[鄂圖一世 (神聖羅馬帝國)|鄂圖一世]]麾下的軍隊在[[第二次萊希菲爾德之戰 (955)|萊希菲爾德之戰]]中取得勝利。注释 +=== 20世紀 === +* [[1912年]]:[[威廉·布利斯]]出版了第一本关于[[童子军]]的书。 +* [[1945年]]:{{le|蒙古}}对日宣战。 +* [[2002年]]:-{zh-hans:凯尔·斯科特;zh-hk:卡爾·史葛;zh-tw:凱爾·斯科特;}-,英國職業足球運動員。 +* 年份不详:某件无法确定年份的事情发生了。 + +== 出生 == +* [[前156年]]:[[汉武帝]]刘彻,[[西汉]]皇帝([[前87年]]逝世) +* [[前156年]]:[[汉武帝]]刘彻,[[西汉]]皇帝([[前87年]]逝世) +* [[1874年]]:[[赫伯特·胡佛]],美国第31任[[总统]]([[1964年]]逝世) + +== 逝世 == +* [[258年]]:[[羅馬的聖老楞佐]],基督教殉道者 + +== 节假日和习俗 == +* {{flag|厄瓜多尔}}:独立日 +* 世界[[狮子]]日 + +==参考资料== +{{Commonscat}} +` + +func TestParseDayPage(t *testing.T) { + items := parseDayPage(sampleWikitext) + + count := func(kind string) int { + n := 0 + for _, it := range items { + if it.Kind == kind { + n++ + } + } + return n + } + + if got := count("event"); got != 6 { + t.Errorf("event 条目数 = %d, 期望 6", got) + } + if got := count("birth"); got != 2 { // 重复条目应去重 + t.Errorf("birth 条目数 = %d, 期望 2", got) + } + if got := count("death"); got != 1 { + t.Errorf("death 条目数 = %d, 期望 1", got) + } + if got := count("festival"); got != 2 { + t.Errorf("festival 条目数 = %d, 期望 2", got) + } + + // 校验具体条目的年份与文本清洗 + assertItem := func(kind string, wantYear int, wantContent string) { + for _, it := range items { + if it.Kind == kind && it.Content == wantContent { + if it.Year != wantYear { + t.Errorf("%q 年份 = %d, 期望 %d", wantContent, it.Year, wantYear) + } + return + } + } + t.Errorf("未找到条目: kind=%s content=%q", kind, wantContent) + } + assertItem("event", -612, "亚述最后一位君主辛·沙里施昆死于尼尼微,王国灭亡。") + assertItem("event", 955, "鄂圖一世麾下的軍隊在萊希菲爾德之戰中取得勝利。") + assertItem("event", 0, "某件无法确定年份的事情发生了。") + assertItem("event", 2002, "凯尔·斯科特,英國職業足球運動員。") + assertItem("festival", 0, "厄瓜多尔:独立日") + assertItem("birth", -156, "汉武帝刘彻,西汉皇帝(前87年逝世)") + assertItem("festival", 0, "世界狮子日") + + // 不应包含 wiki 语法残留 + for _, it := range items { + if strings.Contains(it.Content, "[[") || strings.Contains(it.Content, "{{") || + strings.Contains(it.Content, " import('/static/js/pages/Users.js'), orders: () => import('/static/js/pages/Orders.js'), wishes: () => import('/static/js/pages/Wishes.js'), + wiki: () => import('/static/js/pages/Wiki.js'), settings: () => import('/static/js/pages/Settings.js'), }; return pages[name] ? pages[name]() : pages.dashboard(); diff --git a/server/web/static/js/pages/Dashboard.js b/server/web/static/js/pages/Dashboard.js index 9a22fa5..d4a9668 100644 --- a/server/web/static/js/pages/Dashboard.js +++ b/server/web/static/js/pages/Dashboard.js @@ -22,6 +22,9 @@ export default { 许愿管理 + + 百科管理 + 系统设置 diff --git a/server/web/static/js/pages/Orders.js b/server/web/static/js/pages/Orders.js index 4f682b1..2c0e170 100644 --- a/server/web/static/js/pages/Orders.js +++ b/server/web/static/js/pages/Orders.js @@ -22,6 +22,9 @@ export default { 许愿管理 + + 百科管理 + 系统设置 diff --git a/server/web/static/js/pages/Settings.js b/server/web/static/js/pages/Settings.js index 7ead1ff..4fe1a91 100644 --- a/server/web/static/js/pages/Settings.js +++ b/server/web/static/js/pages/Settings.js @@ -22,6 +22,9 @@ export default { 许愿管理 + + 百科管理 + 系统设置 diff --git a/server/web/static/js/pages/Users.js b/server/web/static/js/pages/Users.js index b9df25d..233f02f 100644 --- a/server/web/static/js/pages/Users.js +++ b/server/web/static/js/pages/Users.js @@ -22,6 +22,9 @@ export default { 许愿管理 + + 百科管理 + 系统设置 diff --git a/server/web/static/js/pages/Wiki.js b/server/web/static/js/pages/Wiki.js new file mode 100644 index 0000000..beded52 --- /dev/null +++ b/server/web/static/js/pages/Wiki.js @@ -0,0 +1,326 @@ +// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步 +export default { + template: ` +
+ + +
+ +
+
+ +
+
+ + +
+
+
+
+

百科条目(共 {{ entries.length }} 条)

+ +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID分类标题简介排序状态操作
{{ e.id }}{{ catName(e.category) }}{{ e.title }}{{ e.brief }}{{ e.sort }} + {{ e.status === 1 ? '显示' : '隐藏' }} + + + +
暂无条目
+
+
+
+
+ + +
+
+
+

维基百科数据同步

+

数据来源:中文维基百科「X月X日」页面(共 366 页,含大事记/出生/逝世/节假日习俗)。服务启动时若数据缺失会自动抓取;之后按配置间隔(默认 7 天)定时增量刷新。

+ +
+
+
日期覆盖
+
{{ syncStatus.totalDays || 0 }} / 366 天
+
+
+
条目总数
+
{{ syncStatus.totalItems || 0 }}
+
+
+
同步状态
+
+ 进行中 + 已完成 + 部分失败 + 失败 + 未同步 +
+
+
+ +
+
最近同步:{{ formatTime(syncStatus.lastSync.startedAt) }} ~ {{ syncStatus.lastSync.finishedAt ? formatTime(syncStatus.lastSync.finishedAt) : '进行中' }}
+
触发方式:{{ triggerName(syncStatus.lastSync.trigger) }} | 成功 {{ syncStatus.lastSync.success }} 页 / 失败 {{ syncStatus.lastSync.failed }} 页
+
+ + + +
+
+
+
+ + +
+
+
+
+

{{ editor.id ? '编辑条目' : '新增条目' }}

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ `, + data() { + return { + tab: 'entries', + categories: [ + { key: 'term', name: '二十四节气' }, + { key: 'lunarFest', name: '农历节日' }, + { key: 'solarFest', name: '公历节日' }, + { key: 'ganzhi', name: '干支生肖' }, + { key: 'termExplain', name: '黄历术语' }, + { key: 'other', name: '其他' }, + ], + entries: [], + filterCat: '', + filterKeyword: '', + editor: { + visible: false, + id: null, + form: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 }, + }, + saving: false, + syncing: false, + syncStatus: {}, + syncTimer: null, + }; + }, + mounted() { + this.loadEntries(); + this.loadSyncStatus(); + }, + beforeUnmount() { + if (this.syncTimer) clearInterval(this.syncTimer); + }, + methods: { + catName(key) { + const c = this.categories.find(x => x.key === key); + return c ? c.name : key; + }, + formatTime(t) { + if (!t) return ''; + return new Date(t).toLocaleString('zh-CN', { hour12: false }); + }, + triggerName(t) { + return { startup: '启动自动', cron: '定时任务', admin: '手动触发' }[t] || t; + }, + async api(url, options = {}) { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (res.status === 401) { + alert('登录已过期,请重新登录'); + window.location.href = '/admin'; + throw new Error('未授权'); + } + const data = await res.json(); + if (data.code !== 0) throw new Error(data.msg || '请求失败'); + return data.data; + }, + async loadEntries() { + try { + const params = new URLSearchParams(); + if (this.filterCat) params.set('category', this.filterCat); + if (this.filterKeyword) params.set('keyword', this.filterKeyword); + const data = await this.api('/admin/api/wiki/entries?' + params.toString()); + this.entries = data.list || []; + } catch (e) { + console.error(e); + } + }, + 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 }; + this.editor.visible = true; + }, + async saveEntry() { + const f = this.editor.form; + if (!f.title.trim()) { alert('标题不能为空'); return; } + this.saving = true; + try { + if (this.editor.id) { + await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(f) }); + } else { + await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(f) }); + } + this.editor.visible = false; + this.loadEntries(); + } catch (e) { + alert(e.message); + } finally { + this.saving = false; + } + }, + async removeEntry(entry) { + if (!confirm(`确认删除条目「${entry.title}」?`)) return; + try { + await this.api(`/admin/api/wiki/entries/${entry.id}`, { method: 'DELETE' }); + this.loadEntries(); + } catch (e) { + alert(e.message); + } + }, + async loadSyncStatus() { + try { + this.syncStatus = await this.api('/admin/api/wiki/sync-status'); + // 同步进行中时每 10 秒自动刷新 + if (this.syncStatus.running && !this.syncTimer) { + this.syncTimer = setInterval(() => this.loadSyncStatus(), 10000); + } else if (!this.syncStatus.running && this.syncTimer) { + clearInterval(this.syncTimer); + this.syncTimer = null; + } + } catch (e) { + console.error(e); + } + }, + async triggerSync() { + if (!confirm('全量同步将抓取 366 个维基百科页面(约 3-5 分钟),确认执行?')) return; + this.syncing = true; + try { + await this.api('/admin/api/wiki/sync', { method: 'POST' }); + this.loadSyncStatus(); + } catch (e) { + alert(e.message); + } finally { + this.syncing = false; + } + }, + }, +} diff --git a/server/web/static/js/pages/Wishes.js b/server/web/static/js/pages/Wishes.js index b09d57b..e7b782b 100644 --- a/server/web/static/js/pages/Wishes.js +++ b/server/web/static/js/pages/Wishes.js @@ -22,6 +22,9 @@ export default { 许愿管理 + + 百科管理 + 系统设置