Compare commits
10
Commits
server-v1.1.69
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
241a04496b | ||
|
|
bff792a974 | ||
|
|
5a5388234a | ||
|
|
fec383f257 | ||
|
|
0ce1a1ef97 | ||
|
|
cc7f30191e | ||
|
|
e0b447ddb1 | ||
|
|
d175777d75 | ||
|
|
0256f4e7f3 | ||
|
|
9ccab93c5b |
@@ -47,6 +47,7 @@ Page({
|
||||
zoomTitle: '',
|
||||
zoomType: '',
|
||||
zoomData: null,
|
||||
zoomScrollTop: 0,
|
||||
// 设置
|
||||
showBuddhist: false,
|
||||
highlightLunar: true
|
||||
@@ -97,7 +98,8 @@ Page({
|
||||
const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : '';
|
||||
const isFirstOrFifteen = this.data.highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15);
|
||||
const nextBuddhist = this.data.showBuddhist ? getNextBuddhistFestival(y, m, d) : null;
|
||||
const upcoming = getUpcomingFestivals(5, y, m, d);
|
||||
// 外页只显示最近3条(佛历开启时并入排序);内页弹窗见 openZoom 取10条
|
||||
const upcoming = getUpcomingFestivals(3, y, m, d, this.data.showBuddhist);
|
||||
return {
|
||||
key: fmt(y, m, d),
|
||||
dayInfo, almanac, legalHoliday, workday, termTime,
|
||||
@@ -429,7 +431,8 @@ Page({
|
||||
};
|
||||
} else if (type === 'fest') {
|
||||
zoomTitle = '近期节日';
|
||||
zoomData = { upcoming: dayItem.upcoming };
|
||||
// 内页显示最近10条(佛历开启时并入排序)
|
||||
zoomData = { upcoming: getUpcomingFestivals(10, dayInfo.solarYear, dayInfo.solarMonth, dayInfo.solarDay, this.data.showBuddhist) };
|
||||
} else if (type === 'fest-year') {
|
||||
zoomTitle = `${dayInfo.solarYear}年节日`;
|
||||
zoomData = { yearFestivals: getYearFestivals(dayInfo.solarYear) };
|
||||
|
||||
@@ -123,14 +123,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 节日速查(默认显示3条,点击查看更多) -->
|
||||
<!-- 节日速查(显示最近10条,点击查看更多) -->
|
||||
<view class="card">
|
||||
<view class="fest-head">
|
||||
<text class="section-title">节日速查</text>
|
||||
<text class="fest-more" bindtap="openZoom" data-type="fest-year" data-date="{{dayItem.key}}">更多 >></text>
|
||||
</view>
|
||||
<view class="fest-quick" wx:if="{{dayItem.upcoming.length > 0}}">
|
||||
<view class="fest-quick-item" wx:for="{{dayItem.upcoming.slice(0, 3)}}" wx:key="name" catchtap="goFestival" data-date="{{item.solarDate}}">
|
||||
<view class="fest-quick" wx:if="{{dayItem.upcoming && dayItem.upcoming.length > 0}}">
|
||||
<view class="fest-quick-item" wx:for="{{dayItem.upcoming}}" wx:key="name" catchtap="goFestival" data-date="{{item.solarDate}}">
|
||||
<text class="fest-name">{{item.name}}</text>
|
||||
<text class="fest-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
|
||||
<text class="fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : item.daysLeft + '天后'}}</text>
|
||||
@@ -156,7 +156,7 @@
|
||||
</view>
|
||||
<view class="otd-foot">
|
||||
<text class="otd-source">来源:中文维基百科</text>
|
||||
<text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 ›</text>
|
||||
<text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 >></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card placeholder-card" wx:elif="{{dayItem.otdLoading}}">
|
||||
@@ -236,6 +236,7 @@
|
||||
<text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="zoom-end-pad" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 详情放大 -->
|
||||
@@ -280,6 +281,7 @@
|
||||
<text class="zoom-detail-label">彭祖百忌</text>
|
||||
<text class="zoom-detail-value">{{zoomData.pengZu}}</text>
|
||||
</view>
|
||||
<view class="zoom-end-pad" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 节日放大 -->
|
||||
@@ -289,15 +291,17 @@
|
||||
<text class="zoom-fest-date">{{item.solarDate}}</text>
|
||||
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
|
||||
</view>
|
||||
<view class="zoom-end-pad" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 今年节日(字体稍小,避免溢出) -->
|
||||
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'fest-year' && zoomData}}">
|
||||
<scroll-view scroll-y class="zoom-body" scroll-top="{{zoomScrollTop}}" wx:if="{{zoomType === 'fest-year' && zoomData}}">
|
||||
<view class="zoom-fest-year-row" wx:for="{{zoomData.yearFestivals}}" wx:key="name" bindtap="goFestival" data-date="{{item.solarDate}}">
|
||||
<text class="zoom-fest-year-name">{{item.name}}</text>
|
||||
<text class="zoom-fest-year-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
|
||||
<text class="zoom-fest-year-type {{item.type === 'lunar' ? 'lunar' : 'solar'}}">{{item.type === 'lunar' ? '农历' : '公历'}}</text>
|
||||
</view>
|
||||
<view class="zoom-end-pad" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 历史上的今天放大 -->
|
||||
@@ -330,6 +334,7 @@
|
||||
</view>
|
||||
</block>
|
||||
<view class="zoom-otd-source">数据来源:中文维基百科(CC BY-SA 4.0)</view>
|
||||
<view class="zoom-end-pad" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+30
-11
@@ -434,19 +434,23 @@
|
||||
.zoom-fest-year-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
min-width: 140rpx;
|
||||
width: 160rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoom-fest-year-date {
|
||||
font-size: 28rpx;
|
||||
color: #9E8E7E;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.zoom-fest-year-type {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zoom-fest-year-type.lunar {
|
||||
@@ -491,6 +495,9 @@
|
||||
font-size: 24rpx;
|
||||
color: #C41E3A;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
min-width: 100rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fest-left.is-today {
|
||||
@@ -665,10 +672,18 @@
|
||||
}
|
||||
|
||||
.zoom-body {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 32rpx 28rpx;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
/* scroll-view 底部占位:padding-bottom 不计入滚动高度,用真实元素保证最后一项完整显示 */
|
||||
.zoom-end-pad {
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
|
||||
/* 宜忌放大 */
|
||||
.zoom-yiji-block {
|
||||
margin-bottom: 40rpx;
|
||||
@@ -692,13 +707,13 @@
|
||||
.zoom-yiji-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.zoom-yiji-item {
|
||||
font-size: 44rpx;
|
||||
padding: 16rpx 32rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 32rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 12rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -741,21 +756,24 @@
|
||||
}
|
||||
|
||||
.zoom-fest-name {
|
||||
font-size: 44rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
min-width: 200rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoom-fest-date {
|
||||
font-size: 36rpx;
|
||||
font-size: 32rpx;
|
||||
color: #9E8E7E;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.zoom-fest-left {
|
||||
font-size: 36rpx;
|
||||
font-size: 32rpx;
|
||||
color: #C41E3A;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zoom-fest-left.is-today {
|
||||
@@ -871,12 +889,13 @@
|
||||
}
|
||||
|
||||
.zoom-otd-item .otd-year {
|
||||
font-size: 30rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.zoom-otd-item .otd-text {
|
||||
font-size: 32rpx;
|
||||
font-size: 28rpx;
|
||||
-webkit-line-clamp: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ Page({
|
||||
// 按日期排序
|
||||
terms.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
this.setData({ terms });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '二十四节气 - 节气查询',
|
||||
|
||||
@@ -41,6 +41,44 @@ 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}] 展示列表(仅保留非空项,按定义顺序;meaning 单独展示故排除)
|
||||
function buildExtraList(extraStr) {
|
||||
if (!extraStr) return [];
|
||||
let obj = {};
|
||||
try { obj = JSON.parse(extraStr); } catch (e) { return []; }
|
||||
const list = [];
|
||||
EXTRA_LABELS.forEach(f => {
|
||||
if (f.key === 'meaning') return; // 涵义在引言区单独突出显示
|
||||
const v = (obj[f.key] || '').trim();
|
||||
if (v) list.push({ name: f.name, value: v });
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
// extractMeaning 从 extra JSON 中提取涵义(用于引言区突出显示)
|
||||
function extractMeaning(extraStr) {
|
||||
if (!extraStr) return '';
|
||||
try {
|
||||
const obj = JSON.parse(extraStr);
|
||||
return (obj.meaning || '').trim();
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function buildEntries() {
|
||||
const entries = [];
|
||||
|
||||
@@ -139,6 +177,9 @@ Page({
|
||||
title: s.title,
|
||||
brief: s.brief,
|
||||
content: s.content,
|
||||
image: s.image || '',
|
||||
meaning: extractMeaning(s.extra),
|
||||
extraList: buildExtraList(s.extra),
|
||||
open: false
|
||||
};
|
||||
if (titleIndex[s.title] !== undefined) {
|
||||
|
||||
@@ -25,8 +25,28 @@
|
||||
<text class="badge badge-outline">{{item.catName}}</text>
|
||||
</view>
|
||||
<view class="text-sm text-muted mt-1" wx:if="{{!item.open}}">{{item.brief}}</view>
|
||||
<view class="entry-detail mt-2" wx:if="{{item.open}}">
|
||||
<text class="text-base">{{item.content}}</text>
|
||||
<view class="entry-detail" wx:if="{{item.open}}">
|
||||
<!-- 配图(仅服务端条目有图时显示) -->
|
||||
<image wx:if="{{item.image}}" class="entry-image" src="{{item.image}}" mode="widthFix" lazy-load/>
|
||||
|
||||
<!-- 涵义引言(节气有 meaning 时突出显示) -->
|
||||
<view class="entry-meaning" wx:if="{{item.meaning}}">
|
||||
<text class="meaning-text">{{item.meaning}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 详细简介 -->
|
||||
<text class="text-base entry-content">{{item.content}}</text>
|
||||
|
||||
<!-- 结构化扩展字段(节气:物候/习俗/养生等) -->
|
||||
<view class="extra-list" wx:if="{{item.extraList && item.extraList.length > 0}}">
|
||||
<view class="extra-item" wx:for="{{item.extraList}}" wx:key="name" wx:for-item="field">
|
||||
<view class="extra-name">
|
||||
<text class="extra-dot"></text>
|
||||
<text>{{field.name}}</text>
|
||||
</view>
|
||||
<text class="extra-value">{{field.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -47,7 +47,92 @@
|
||||
|
||||
.entry-detail {
|
||||
border-top: 1rpx solid #F0E6DA;
|
||||
padding-top: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
padding-top: 20rpx;
|
||||
}
|
||||
|
||||
/* 节气配图 */
|
||||
.entry-image {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
background: #F5F0EA;
|
||||
box-shadow: 0 4rpx 16rpx rgba(74, 64, 54, 0.08);
|
||||
}
|
||||
|
||||
/* 涵义引言(突出显示) */
|
||||
.entry-meaning {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #FBF3EC 0%, #F7ECE2 100%);
|
||||
border-left: 6rpx solid #C41E3A;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.meaning-text {
|
||||
font-size: 28rpx;
|
||||
color: #8C6D4F;
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* 详细简介 */
|
||||
.entry-content {
|
||||
display: block;
|
||||
color: #4A4036;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.9;
|
||||
text-align: justify;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
/* 结构化扩展字段列表 */
|
||||
.extra-list {
|
||||
margin-top: 24rpx;
|
||||
background: #FBF7F1;
|
||||
border-radius: 16rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
}
|
||||
|
||||
.extra-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #F0E6DA;
|
||||
}
|
||||
|
||||
.extra-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.extra-name {
|
||||
flex-shrink: 0;
|
||||
width: 150rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #A08B76;
|
||||
font-weight: 500;
|
||||
padding-top: 2rpx;
|
||||
}
|
||||
|
||||
/* 标签前的小圆点装饰 */
|
||||
.extra-dot {
|
||||
display: inline-block;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #C41E3A;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extra-value {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: #4A4036;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"setting": {
|
||||
"bundle": false,
|
||||
"userConfirmedBundleSwitch": false,
|
||||
"urlCheck": true,
|
||||
"urlCheck": false,
|
||||
"scopeDataCheck": false,
|
||||
"coverView": true,
|
||||
"es6": true,
|
||||
|
||||
@@ -1228,8 +1228,9 @@ function daysBetween(fromY, fromM, fromD, toY, toM, toD) {
|
||||
|
||||
/** 获取即将到来的 N 个节日(含基准日之后的)
|
||||
* baseYear/baseMonth/baseDay 可选,默认今天
|
||||
* includeBuddhist 为 true 时,把佛教节日也并入一起按倒计时排序
|
||||
*/
|
||||
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
|
||||
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay, includeBuddhist = false) {
|
||||
const now = new Date();
|
||||
const y = baseYear || now.getFullYear();
|
||||
const m = baseMonth || (now.getMonth() + 1);
|
||||
@@ -1245,6 +1246,7 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
|
||||
if (diff >= 0) {
|
||||
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,
|
||||
@@ -1256,6 +1258,30 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
|
||||
}
|
||||
}
|
||||
|
||||
// 佛历节日(开启后并入,统一按倒计时排序)
|
||||
if (includeBuddhist) {
|
||||
for (const [key, name] of Object.entries(BUDDHIST_FESTIVALS)) {
|
||||
const [lm, ld] = key.split('-').map(Number);
|
||||
for (const tryYear of [y, y + 1]) {
|
||||
const solar = lunarFestivalToSolar(tryYear, lm, ld);
|
||||
if (!solar) continue;
|
||||
const diff = daysBetween(y, m, d, solar.year, solar.month, solar.day);
|
||||
if (diff >= 0) {
|
||||
list.push({
|
||||
name,
|
||||
type: 'buddhist',
|
||||
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
|
||||
solarYear: solar.year,
|
||||
solarMonth: solar.month,
|
||||
solarDay: solar.day,
|
||||
daysLeft: diff
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
return list.slice(0, count);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 网络请求封装
|
||||
// 按小程序运行环境自动切换后端地址:
|
||||
// - develop(开发者工具):本地开发服务
|
||||
// - develop(开发者工具):连线上数据(本地 8080 被其他服务占用,且 localhost 受域名校验限制)
|
||||
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
|
||||
const API_HOSTS = {
|
||||
develop: 'http://localhost:8080',
|
||||
develop: 'https://lunar.neatcn.com',
|
||||
trial: 'https://lunar.neatcn.com',
|
||||
release: 'https://lunar.neatcn.com',
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ func autoMigrate() error {
|
||||
&model.WikiEntry{},
|
||||
&model.WikiOnThisDay{},
|
||||
&model.WikiSyncLog{},
|
||||
&model.BaikeTermSyncLog{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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": "节气同步任务已启动"})
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func Auth() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWT(Authorization 头或 HttpOnly Cookie)
|
||||
// 浏览器导航(GET 页面请求)未登录时渲染登录页;API 请求未登录时返回 401 JSON
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString := ""
|
||||
@@ -115,18 +116,24 @@ func AdminAuth() gin.HandlerFunc {
|
||||
tokenString, _ = c.Cookie(service.AdminTokenCookie)
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "需要管理员权限",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
authed := false
|
||||
if tokenString != "" {
|
||||
cfg := config.Load()
|
||||
userService := service.NewUserService()
|
||||
authed = userService.IsAdminToken(tokenString, cfg.JWT.Secret)
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
userService := service.NewUserService()
|
||||
if !userService.IsAdminToken(tokenString, cfg.JWT.Secret) {
|
||||
if !authed {
|
||||
// 页面导航(GET 且非 /admin/api/):返回登录页
|
||||
if c.Request.Method == http.MethodGet && !strings.HasPrefix(c.Request.URL.Path, "/admin/api/") {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "管理员登录",
|
||||
"page": "login",
|
||||
"url": c.Request.URL.Path,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "需要管理员权限",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
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 匹配 <sup>...</sup> 引用标记(含内容一并去除,避免残留引用编号)
|
||||
reTermSup = regexp.MustCompile(`(?s)<sup[^>]*>.*?</sup>`)
|
||||
reTermHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||
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 := trimTruncatedTail(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, "") // 先去 <sup>引用编号</sup>(含内容)
|
||||
s = reTermHTMLTag.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = reTermSpace.ReplaceAllString(s, " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// trimTruncatedTail 清理百度百科摘要的截断结尾:
|
||||
// 摘要以「...」「…」等结尾说明被接口截断,先去掉省略号;
|
||||
// 若去掉后末句不是完整句(不以 。!? 结尾),回退到最后一个完整句标点,避免半截话
|
||||
func trimTruncatedTail(s string) string {
|
||||
// 去掉结尾的省略号(英文 ... / 中文 … / 多个点)
|
||||
s = strings.TrimRight(s, ".… ")
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// 末字符已是完整句标点则无需回退
|
||||
last := []rune(s)
|
||||
if len(last) == 0 {
|
||||
return s
|
||||
}
|
||||
if strings.ContainsRune("。!?", last[len(last)-1]) {
|
||||
return s
|
||||
}
|
||||
// 回退到最后一个完整句标点(LastIndex 未找到返回 -1,需先判断 j >= 0)
|
||||
idx := -1
|
||||
for _, sep := range []string{"。", "!", "?"} {
|
||||
if j := strings.LastIndex(s, sep); j >= 0 && j+len(sep) > idx {
|
||||
idx = j + len(sep)
|
||||
}
|
||||
}
|
||||
if idx > 0 {
|
||||
return s[:idx]
|
||||
}
|
||||
// 没有完整句标点则保留原文(避免清空)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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 Spring<sup>3</sup><a name=\"ref_3\"></a>","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":["<a target=_blank href=\"/item/x\">演春</a>、迎春、打春"]},
|
||||
{"key":"m25_health","name":"起居养生","value":["防躁动,避免过度劳累,保证睡眠情绪豁达。"]},
|
||||
{"key":"m25_flower","name":"花信","value":["迎春、樱桃、望春<sup>35</sup><a name=\"ref_35\"></a>"]}
|
||||
]
|
||||
}`
|
||||
|
||||
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"], "<a") || strings.Contains(extra["customs"], "href") {
|
||||
t.Errorf("customs 应清洗 HTML, got %q", extra["customs"])
|
||||
}
|
||||
if !strings.Contains(extra["customs"], "演春") {
|
||||
t.Errorf("customs 应保留文字「演春」, got %q", extra["customs"])
|
||||
}
|
||||
// 外文名含 <sup> 应清洗
|
||||
if strings.Contains(extra["english"], "<sup>") {
|
||||
t.Errorf("english 应清洗 sup 标签, got %q", extra["english"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTermHTML(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`<a target=_blank href="/item/x">演春</a>、迎春`: "演春、迎春",
|
||||
`Beginning of Spring<sup>3</sup><a name="ref_3"></a>`: "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("不存在"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimTruncatedTail(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"完整句号结尾不动", "夏至后人们普遍食用清补凉汤来避暑。", "夏至后人们普遍食用清补凉汤来避暑。"},
|
||||
{"英文省略号回退", "表达感谢的心情。唐宋时期,冬至...", "表达感谢的心情。"},
|
||||
{"中文省略号回退", "表达感谢的心情。唐宋时期,冬至…", "表达感谢的心情。"},
|
||||
{"无句号半截话保留", "可明显感觉到早春的气...", "可明显感觉到早春的气"},
|
||||
{"多点省略号", "农事谚语....", "农事谚语"},
|
||||
{"结尾空白加省略号", "冬至大如年。 ...", "冬至大如年。"},
|
||||
{"空字符串", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := trimTruncatedTail(c.in); got != c.want {
|
||||
t.Errorf("%s: trimTruncatedTail(%q) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,8 +17,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WikiSyncService 维基百科「X月X日」页面同步服务
|
||||
// 全量共 366 页(含 2 月 29 日),首次抓取后按配置间隔定期增量刷新
|
||||
// WikiSyncService 「历史上的今天」同步服务
|
||||
// 数据源:百度百科「历史上的今天」开放接口(按月返回结构化 JSON,共 12 次请求)
|
||||
// 首次抓取后按配置间隔定期增量刷新
|
||||
type WikiSyncService struct {
|
||||
db *gorm.DB
|
||||
|
||||
@@ -39,32 +39,21 @@ func NewWikiSyncService(db *gorm.DB) *WikiSyncService {
|
||||
}
|
||||
|
||||
var (
|
||||
reComment = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
reRef = regexp.MustCompile(`(?is)<ref[^>]*/>|<ref[^>]*>.*?</ref>`)
|
||||
reFlag = regexp.MustCompile(`\{\{(?:flag|flagcountry|flagicon)\|([^}|]+)(?:\|[^{}]*)?\}\}`)
|
||||
reTemplate = regexp.MustCompile(`\{\{[^{}]*\}\}`)
|
||||
reLink = regexp.MustCompile(`\[\[(?:[^|\[\]]*\|)?([^|\[\]]+)\]\]`)
|
||||
reHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||
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]+`)
|
||||
reHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||
reSpace = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// daysInMonth 每月天数(含 2 月 29 日,闰日页面维基也有)
|
||||
var daysInMonth = []int{31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
||||
// daysInMonth 每月天数(百度百科数据源不含 2 月 29 日,共 365 天)
|
||||
var daysInMonth = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
||||
|
||||
type dayKey struct {
|
||||
Month int
|
||||
Day int
|
||||
}
|
||||
|
||||
// allDays 全年 366 天
|
||||
// allDays 全年 365 天
|
||||
func allDays() []dayKey {
|
||||
days := make([]dayKey, 0, 366)
|
||||
days := make([]dayKey, 0, 365)
|
||||
for m := 1; m <= 12; m++ {
|
||||
for d := 1; d <= daysInMonth[m-1]; d++ {
|
||||
days = append(days, dayKey{Month: m, Day: d})
|
||||
@@ -107,7 +96,7 @@ func (s *WikiSyncService) missingDays() []dayKey {
|
||||
}
|
||||
|
||||
// TriggerSync 触发一次同步(异步执行);已有任务执行中时返回 false
|
||||
// full=true 全量刷新 366 页;full=false 仅补齐缺失的天
|
||||
// full=true 全量刷新 365 天;full=false 仅补齐缺失的天
|
||||
func (s *WikiSyncService) TriggerSync(trigger string, full bool) bool {
|
||||
days := allDays()
|
||||
if !full {
|
||||
@@ -133,7 +122,7 @@ 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))
|
||||
log.Printf("wiki sync: 缺失 %d/365 天,10秒后自动补齐", len(missing))
|
||||
time.Sleep(10 * time.Second)
|
||||
s.TriggerSync("startup", false)
|
||||
}
|
||||
@@ -171,7 +160,8 @@ func (s *WikiSyncService) lastSuccessAt() (time.Time, bool) {
|
||||
return *syncLog.FinishedAt, true
|
||||
}
|
||||
|
||||
// runSync 同步指定日期集合(全量 366 页或缺失补齐)
|
||||
// runSync 同步指定日期集合(全量 365 天或缺失补齐)
|
||||
// 百度百科按月返回数据,故先归并出涉及的月份,每月一次请求,再按天写入
|
||||
func (s *WikiSyncService) runSync(trigger string, days []dayKey) {
|
||||
syncLog := model.WikiSyncLog{
|
||||
Trigger: trigger,
|
||||
@@ -181,27 +171,37 @@ func (s *WikiSyncService) runSync(trigger string, days []dayKey) {
|
||||
}
|
||||
s.db.Create(&syncLog)
|
||||
|
||||
jobs := make(chan dayKey, len(days))
|
||||
// 归并涉及的月份
|
||||
monthSet := make(map[int]bool)
|
||||
for _, d := range days {
|
||||
jobs <- d
|
||||
monthSet[d.Month] = true
|
||||
}
|
||||
months := make([]int, 0, len(monthSet))
|
||||
for m := range monthSet {
|
||||
months = append(months, m)
|
||||
}
|
||||
|
||||
jobs := make(chan int, len(months))
|
||||
for _, m := range months {
|
||||
jobs <- m
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
var success, failed int32
|
||||
var wg sync.WaitGroup
|
||||
workerCount := 3
|
||||
workerCount := 2 // 百度百科反爬较敏感,低并发 + 间隔
|
||||
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 {
|
||||
for m := range jobs {
|
||||
if err := s.syncOneMonth(m); err != nil {
|
||||
atomic.AddInt32(&failed, 1)
|
||||
log.Printf("wiki sync: %d月%d日 失败: %v", d.Month, d.Day, err)
|
||||
log.Printf("wiki sync: %d月 失败: %v", m, err)
|
||||
} else {
|
||||
atomic.AddInt32(&success, 1)
|
||||
}
|
||||
time.Sleep(800 * time.Millisecond) // 控制请求频率,避免被限流
|
||||
time.Sleep(1200 * time.Millisecond) // 控制请求频率,避免触发安全验证
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -221,266 +221,201 @@ func (s *WikiSyncService) runSync(trigger string, days []dayKey) {
|
||||
"failed": int(failed),
|
||||
"finished_at": &now,
|
||||
})
|
||||
log.Printf("wiki sync: 同步完成 trigger=%s pages=%d success=%d failed=%d", trigger, len(days), success, failed)
|
||||
log.Printf("wiki sync: 同步完成 trigger=%s days=%d months=%d success=%d failed=%d", trigger, len(days), len(months), success, failed)
|
||||
}
|
||||
|
||||
// syncOneDay 抓取并更新某一天的数据(失败不覆盖旧数据;死锁自动重试)
|
||||
func (s *WikiSyncService) syncOneDay(month, day int) error {
|
||||
wikitext, err := s.fetchDayPage(month, day)
|
||||
// syncOneMonth 抓取某月数据并按天整体替换(死锁自动重试)
|
||||
func (s *WikiSyncService) syncOneMonth(month int) error {
|
||||
items, err := s.fetchMonth(month)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := parseDayPage(wikitext)
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("页面解析结果为空")
|
||||
return fmt.Errorf("%d月解析结果为空", month)
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
items[i].Month = month
|
||||
items[i].Day = day
|
||||
items[i].Status = 1
|
||||
// 按天分组
|
||||
byDay := make(map[int][]model.WikiOnThisDay)
|
||||
for _, it := range items {
|
||||
byDay[it.Day] = append(byDay[it.Day], it)
|
||||
}
|
||||
|
||||
// 按天整体替换:先删后插;并发写入同表可能死锁,重试 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
|
||||
// 逐天事务替换
|
||||
for day, dayItems := range byDay {
|
||||
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(&dayItems).Error
|
||||
})
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
if !strings.Contains(lastErr.Error(), "Deadlock") && !strings.Contains(lastErr.Error(), "1213") {
|
||||
return lastErr
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
})
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(lastErr.Error(), "Deadlock") && !strings.Contains(lastErr.Error(), "1213") {
|
||||
break
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("%d月%d日写入失败: %w", month, day, lastErr)
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ===== 百度百科数据抓取与解析 =====
|
||||
|
||||
// baikeEvent 百度百科「历史上的今天」单条数据
|
||||
type baikeEvent struct {
|
||||
Year string `json:"year"` // 年份,负数表示公元前(如 "-30")
|
||||
Title string `json:"title"` // 标题(含 HTML 链接标签)
|
||||
Festival string `json:"festival"` // 节假日名(当天所有条目共享,可能为空)
|
||||
Link string `json:"link"` // 百科词条链接
|
||||
Type string `json:"type"` // event / birth / death
|
||||
Desc string `json:"desc"` // 描述(含 HTML 标签)
|
||||
}
|
||||
|
||||
// fetchMonth 抓取某月「历史上的今天」数据并解析为按天的条目(失败重试)
|
||||
func (s *WikiSyncService) fetchMonth(month int) ([]model.WikiOnThisDay, error) {
|
||||
apiURL := fmt.Sprintf("https://baike.baidu.com/cms/home/eventsOnHistory/%02d.json", month)
|
||||
|
||||
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)
|
||||
time.Sleep(time.Duration(attempt*3) * time.Second)
|
||||
}
|
||||
text, err := s.doFetch(apiURL)
|
||||
if err == nil {
|
||||
return text, nil
|
||||
body, err := s.doFetch(apiURL)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
lastErr = err
|
||||
items, err := parseBaikeMonth(body, month)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
return "", lastErr
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (s *WikiSyncService) doFetch(apiURL string) (string, error) {
|
||||
// doFetch 发起 HTTP 请求并返回响应体(带浏览器 UA 与 Referer,规避安全验证)
|
||||
func (s *WikiSyncService) doFetch(apiURL string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "LunarServer/1.0 (wiki on-this-day sync; contact: admin@neatcn.com)")
|
||||
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/calendar")
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
return nil, 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
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 上限 4MB
|
||||
}
|
||||
|
||||
// ===== wikitext 解析 =====
|
||||
// parseBaikeMonth 解析百度百科某月 JSON,输出该月全部条目
|
||||
func parseBaikeMonth(body []byte, month int) ([]model.WikiOnThisDay, error) {
|
||||
// 顶层结构:{"08": {"0801": [...], "0802": [...]}}
|
||||
var root map[string]map[string][]baikeEvent
|
||||
if err := json.Unmarshal(body, &root); err != nil {
|
||||
return nil, fmt.Errorf("JSON 解析失败: %w", err)
|
||||
}
|
||||
monthKey := fmt.Sprintf("%02d", month)
|
||||
dayMap, ok := root[monthKey]
|
||||
if !ok || len(dayMap) == 0 {
|
||||
return nil, fmt.Errorf("%s月无数据", monthKey)
|
||||
}
|
||||
|
||||
// classifySection 根据二级标题识别板块类型
|
||||
func classifySection(title string) string {
|
||||
switch {
|
||||
case strings.Contains(title, "大事") || strings.Contains(title, "事件"):
|
||||
items := make([]model.WikiOnThisDay, 0, 256)
|
||||
for dayKeyStr, events := range dayMap {
|
||||
day, err := strconv.Atoi(dayKeyStr[2:]) // "0812" -> 12
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seenFestival := make(map[string]bool)
|
||||
seenContent := make(map[string]bool)
|
||||
|
||||
for _, ev := range events {
|
||||
// 节假日:从 festival 字段提取(挂在每条上,去重后单独成 festival 条目)
|
||||
if fest := cleanHTMLText(ev.Festival); fest != "" && !seenFestival[fest] {
|
||||
seenFestival[fest] = true
|
||||
items = append(items, model.WikiOnThisDay{
|
||||
Month: month, Day: day, Kind: "festival", Year: 0, Content: fest, Status: 1,
|
||||
})
|
||||
}
|
||||
|
||||
kind := normalizeKind(ev.Type)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
text := cleanHTMLText(ev.Title)
|
||||
if len([]rune(text)) < 4 {
|
||||
continue
|
||||
}
|
||||
if len([]rune(text)) > 500 {
|
||||
text = string([]rune(text)[:500])
|
||||
}
|
||||
if seenContent[kind+"|"+text] {
|
||||
continue
|
||||
}
|
||||
seenContent[kind+"|"+text] = true
|
||||
|
||||
items = append(items, model.WikiOnThisDay{
|
||||
Month: month,
|
||||
Day: day,
|
||||
Kind: kind,
|
||||
Year: parseBaikeYear(ev.Year),
|
||||
Content: text,
|
||||
Status: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// normalizeKind 映射百度百科 type 到内部 kind
|
||||
func normalizeKind(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "event":
|
||||
return "event"
|
||||
case strings.Contains(title, "出生"):
|
||||
case "birth":
|
||||
return "birth"
|
||||
case strings.Contains(title, "逝世") || strings.Contains(title, "去世"):
|
||||
case "death":
|
||||
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,
|
||||
})
|
||||
// parseBaikeYear 解析年份字符串:"1912"→1912,"-30"→-30(公元前),非法→0
|
||||
func parseBaikeYear(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
return items
|
||||
y, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
// 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, "''", "")
|
||||
// cleanHTMLText 去除 HTML 标签与多余空白,输出纯文本
|
||||
func cleanHTMLText(s string) string {
|
||||
s = reHTMLTag.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
s = reLeadPunct.ReplaceAllString(s, "") // 模板被剥离后可能残留开头标点
|
||||
s = reSpace.ReplaceAllString(s, " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 取自 zh.wikipedia.org「8月10日」页面的真实片段
|
||||
const sampleWikitext = `'''8月10日'''是[[阳历]]年的第222天。
|
||||
// 取自百度百科 /cms/home/eventsOnHistory/08.json 的真实片段(8月12日/13日)
|
||||
const sampleBaikeJSON = `{"08":{
|
||||
"0812":[
|
||||
{"year":"-30","title":"埃及艳后<a target=\"_blank\" href=\"https://baike.baidu.com/item/x\">克利奥帕特拉七世</a>逝世","festival":"","link":"https://baike.baidu.com/item/x","type":"death","desc":"克利奥帕特拉七世"},
|
||||
{"year":"1848","title":"英国著名小说家<a target=\"_blank\" href=\"x\">乔治·艾略特</a>出生","festival":"","link":"x","type":"birth","desc":"x"},
|
||||
{"year":"1848","title":"英国著名小说家乔治·艾略特出生","festival":"","link":"x","type":"birth","desc":"x"},
|
||||
{"year":"1905","title":"挪威音乐家<a href=\"x\">某</a>指挥首演","festival":"","link":"x","type":"event","desc":"x"}
|
||||
],
|
||||
"0813":[
|
||||
{"year":"604","title":"隋文帝<a href=\"x\">杨坚</a>逝世","festival":"国际左撇子日","link":"x","type":"death","desc":"x"},
|
||||
{"year":"1926","title":"古巴革命领导人<a href=\"x\">卡斯特罗</a>出生","festival":"国际左撇子日","link":"x","type":"birth","desc":"x"},
|
||||
{"year":"1910","title":"某事件<a href=\"x\">发生</a>","festival":"国际左撇子日","link":"x","type":"event","desc":"x"},
|
||||
{"year":"abc","title":"年份非法的<a href=\"x\">事件</a>条目","festival":"国际左撇子日","link":"x","type":"event","desc":"x"}
|
||||
]
|
||||
}}`
|
||||
|
||||
== 大事记 ==
|
||||
=== 19世紀以前 ===
|
||||
* [[前612年]]:[[亚述]]最后一位君主[[辛·沙里施昆]]死于[[尼尼微]],王国灭亡。
|
||||
* [[955年]]:[[鄂圖一世 (神聖羅馬帝國)|鄂圖一世]]麾下的軍隊在[[第二次萊希菲爾德之戰 (955)|萊希菲爾德之戰]]中取得勝利。<ref>注释</ref>
|
||||
=== 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)
|
||||
func TestParseBaikeMonth(t *testing.T) {
|
||||
items, err := parseBaikeMonth([]byte(sampleBaikeJSON), 8)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBaikeMonth 失败: %v", err)
|
||||
}
|
||||
|
||||
count := func(kind string) int {
|
||||
n := 0
|
||||
@@ -49,65 +37,78 @@ func TestParseDayPage(t *testing.T) {
|
||||
return n
|
||||
}
|
||||
|
||||
if got := count("event"); got != 6 {
|
||||
t.Errorf("event 条目数 = %d, 期望 6", got)
|
||||
// 0812: 1 death + 2 birth(重复去重后1) + 1 event = 3;0813: 1 death + 1 birth + 2 event = 4
|
||||
if got := count("death"); got != 2 {
|
||||
t.Errorf("death 条目数 = %d, 期望 2", got)
|
||||
}
|
||||
if got := count("birth"); got != 2 { // 重复条目应去重
|
||||
if got := count("birth"); got != 2 { // 0812 重复出生条目应去重
|
||||
t.Errorf("birth 条目数 = %d, 期望 2", got)
|
||||
}
|
||||
if got := count("death"); got != 1 {
|
||||
t.Errorf("death 条目数 = %d, 期望 1", got)
|
||||
if got := count("event"); got != 3 {
|
||||
t.Errorf("event 条目数 = %d, 期望 3", got)
|
||||
}
|
||||
if got := count("festival"); got != 2 {
|
||||
t.Errorf("festival 条目数 = %d, 期望 2", got)
|
||||
// 国际左撇子日只在 0813,去重后 1 条 festival
|
||||
if got := count("festival"); got != 1 {
|
||||
t.Errorf("festival 条目数 = %d, 期望 1", got)
|
||||
}
|
||||
|
||||
// 校验具体条目的年份与文本清洗
|
||||
assertItem := func(kind string, wantYear int, wantContent string) {
|
||||
// 校验具体条目
|
||||
assertItem := func(kind string, day, wantYear int, wantContent string) {
|
||||
for _, it := range items {
|
||||
if it.Kind == kind && it.Content == wantContent {
|
||||
if it.Kind == kind && it.Day == day && it.Content == wantContent {
|
||||
if it.Year != wantYear {
|
||||
t.Errorf("%q 年份 = %d, 期望 %d", wantContent, it.Year, wantYear)
|
||||
}
|
||||
if it.Month != 8 {
|
||||
t.Errorf("%q 月份 = %d, 期望 8", wantContent, it.Month)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("未找到条目: kind=%s content=%q", kind, wantContent)
|
||||
t.Errorf("未找到条目: kind=%s day=%d content=%q", kind, day, wantContent)
|
||||
}
|
||||
assertItem("event", -612, "亚述最后一位君主辛·沙里施昆死于尼尼微,王国灭亡。")
|
||||
assertItem("event", 955, "鄂圖一世麾下的軍隊在萊希菲爾德之戰中取得勝利。")
|
||||
assertItem("event", 0, "某件无法确定年份的事情发生了。")
|
||||
assertItem("event", 2002, "凯尔·斯科特,英國職業足球運動員。")
|
||||
assertItem("festival", 0, "厄瓜多尔:独立日")
|
||||
assertItem("birth", -156, "汉武帝刘彻,西汉皇帝(前87年逝世)")
|
||||
assertItem("festival", 0, "世界狮子日")
|
||||
assertItem("death", 12, -30, "埃及艳后克利奥帕特拉七世逝世")
|
||||
assertItem("birth", 12, 1848, "英国著名小说家乔治·艾略特出生")
|
||||
assertItem("event", 13, 0, "年份非法的事件条目") // 非法年份应为 0
|
||||
assertItem("festival", 13, 0, "国际左撇子日")
|
||||
|
||||
// 不应包含 wiki 语法残留
|
||||
// 不应残留 HTML 标签
|
||||
for _, it := range items {
|
||||
if strings.Contains(it.Content, "[[") || strings.Contains(it.Content, "{{") ||
|
||||
strings.Contains(it.Content, "<ref") {
|
||||
t.Errorf("条目残留 wiki 语法: %q", it.Content)
|
||||
if strings.Contains(it.Content, "<a") || strings.Contains(it.Content, "href") ||
|
||||
strings.Contains(it.Content, "</a>") {
|
||||
t.Errorf("条目残留 HTML 标签: %q", it.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRealPage 若本地存在真实抓取的样本文件则验证(可选)
|
||||
func TestParseRealPage(t *testing.T) {
|
||||
data, err := os.ReadFile("/tmp/wiki_810.json")
|
||||
if err != nil {
|
||||
t.Skip("无真实样本文件,跳过")
|
||||
func TestParseBaikeYear(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"1912": 1912,
|
||||
"-30": -30,
|
||||
"0": 0,
|
||||
"abc": 0,
|
||||
"": 0,
|
||||
" 77 ": 77,
|
||||
}
|
||||
var d struct {
|
||||
Parse struct {
|
||||
Wikitext string `json:"wikitext"`
|
||||
} `json:"parse"`
|
||||
for in, want := range cases {
|
||||
if got := parseBaikeYear(in); got != want {
|
||||
t.Errorf("parseBaikeYear(%q) = %d, 期望 %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKind(t *testing.T) {
|
||||
if normalizeKind("event") != "event" || normalizeKind("birth") != "birth" ||
|
||||
normalizeKind("death") != "death" || normalizeKind("other") != "" ||
|
||||
normalizeKind(" EVENT ") != "event" {
|
||||
t.Error("normalizeKind 映射异常")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanHTMLText(t *testing.T) {
|
||||
in := `埃及艳后<a target="_blank" href="x">克利奥帕特拉七世</a> 逝世 test`
|
||||
want := "埃及艳后克利奥帕特拉七世 逝世 test"
|
||||
if got := cleanHTMLText(in); got != want {
|
||||
t.Errorf("cleanHTMLText = %q, 期望 %q", got, want)
|
||||
}
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
t.Fatalf("样本解析失败: %v", err)
|
||||
}
|
||||
items := parseDayPage(d.Parse.Wikitext)
|
||||
if len(items) < 20 {
|
||||
t.Errorf("真实页面解析条目过少: %d", len(items))
|
||||
}
|
||||
t.Logf("真实页面共解析 %d 条", len(items))
|
||||
}
|
||||
|
||||
+13
-5
@@ -5,20 +5,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .title }} - 祈福小助手管理后台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://unpkg.com/@inertiajs/vue3@1.0.0/dist/index.umd.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<!-- Vue + Inertia 通过 import map 从 esm.sh 加载(ESM,自动解析依赖树,国内可访问) -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"vue": "https://esm.sh/vue@3.4.21/dist/vue.esm-browser.prod.js",
|
||||
"@inertiajs/vue3": "https://esm.sh/@inertiajs/vue3@1.0.0?bundle&external=vue"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="app" data-page='{"component":"{{ .page }}","props":{},"url":"{{ .url }}","version":""}'></div>
|
||||
|
||||
<script>
|
||||
const { createApp, h } = Vue;
|
||||
const { createInertiaApp } = Inertia;
|
||||
<script type="module">
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: name => {
|
||||
const pages = {
|
||||
login: () => import('/static/js/pages/Login.js'),
|
||||
dashboard: () => import('/static/js/pages/Dashboard.js'),
|
||||
users: () => import('/static/js/pages/Users.js'),
|
||||
orders: () => import('/static/js/pages/Orders.js'),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Login 管理员登录页
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100 flex items-center justify-center px-4">
|
||||
<div class="max-w-sm w-full bg-white rounded-lg shadow-md p-8">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-red-600">祈福小助手</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">管理后台登录</p>
|
||||
<p class="text-gray-300 text-xs mt-2">v2026.08.12-login</p>
|
||||
</div>
|
||||
<form @submit.prevent="submit">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">管理密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入 ADMIN_PASSWORD"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="mb-4 text-sm text-red-600">{{ error }}</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !password"
|
||||
class="w-full bg-red-600 text-white py-2 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
password: '',
|
||||
loading: false,
|
||||
error: ''
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.password || this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: this.password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
// Cookie 已由服务端写入,跳回原本要访问的页面
|
||||
window.location.href = window.location.pathname === '/admin/login'
|
||||
? '/admin/'
|
||||
: window.location.pathname;
|
||||
} else {
|
||||
this.error = data.msg || '登录失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = '网络错误,请重试';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -47,6 +47,27 @@ export default {
|
||||
|
||||
<!-- 百科条目管理 -->
|
||||
<div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'">
|
||||
<!-- 节气详情同步(百度百科) -->
|
||||
<div class="bg-white shadow rounded-lg mb-4">
|
||||
<div class="px-4 py-4 sm:px-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">节气详情(百度百科)</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
已含详情 {{ termSync.detailedTerms || 0 }} / {{ termSync.totalTerms || 24 }}
|
||||
<span v-if="termSync.lastSync && termSync.lastSync.startedAt"> | 最近同步:{{ formatTime(termSync.lastSync.startedAt) }}</span>
|
||||
<span v-if="termSync.lastSync && termSync.lastSync.status" :class="termSync.lastSync.status === 'success' ? 'text-green-600' : 'text-yellow-600'"> | {{ termSync.lastSync.status === 'success' ? '成功' : termSync.lastSync.status === 'partial' ? '部分失败' : '失败' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="loadTermSyncStatus" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新</button>
|
||||
<button @click="triggerTermSync" :disabled="termSync.running || termSyncing" class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-sync mr-1" :class="termSync.running ? 'fa-spin' : ''"></i>{{ termSync.running ? '同步中...' : '同步节气详情' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
@@ -170,6 +191,21 @@ export default {
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">详细内容</label>
|
||||
<textarea v-model="editor.form.content" rows="6" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">配图 URL</label>
|
||||
<input v-model="editor.form.image" placeholder="https://..." class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||
<div v-if="editor.form.image" class="mt-2"><img :src="editor.form.image" class="h-20 rounded border" alt="配图预览"/></div>
|
||||
</div>
|
||||
<!-- 节气扩展字段(仅节气分类显示) -->
|
||||
<div v-if="editor.form.category === 'term'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">节气扩展信息</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-for="f in termExtraFields" :key="f.key">
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ f.name }}</label>
|
||||
<input v-model="editor.extra[f.key]" class="block w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">排序(越小越靠前)</label>
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user