Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0b447ddb1 | ||
|
|
d175777d75 | ||
|
|
0256f4e7f3 | ||
|
|
9ccab93c5b | ||
|
|
2a68eae999 | ||
|
|
52c0d3c774 | ||
|
|
738b5c4452 | ||
|
|
a45004bfb2 | ||
|
|
f468cf4b94 | ||
|
|
d6ce5cd0f3 |
@@ -12,8 +12,10 @@ services:
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- /opt/lunar/production/current:/app
|
||||
- /opt/lunar/production/data:/app/data
|
||||
- /opt/lunar/production/.env:/app/.env
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080" # 只监听本机,对外由 nginx 反代 lunar.neatcn.com
|
||||
- "127.0.0.1:8081:8080" # 只监听本机,对外由 nginx 反代 lunar.neatcn.com
|
||||
env_file:
|
||||
- /opt/lunar/production/.env
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -149,5 +149,18 @@ Page({
|
||||
|
||||
goFortune() {
|
||||
wx.navigateTo({ url: '/pages/fortune/fortune' });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '八字排盘 - 生辰八字查询',
|
||||
path: '/pages/bazi/bazi'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '八字排盘 - 生辰八字查询'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,5 +104,18 @@ Page({
|
||||
// 未登录时引导回个人中心登录
|
||||
goLogin() {
|
||||
wx.navigateBack();
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '生辰档案 - 老黄历',
|
||||
path: '/pages/birth-profiles/birth-profiles'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '生辰档案 - 老黄历'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,5 +24,18 @@ Page({
|
||||
openDetail(e) {
|
||||
const date = e.currentTarget.dataset.date;
|
||||
wx.navigateTo({ url: `/pages/day-detail/day-detail?date=${date}` });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '我的收藏 - 老黄历',
|
||||
path: '/pages/bookmarks/bookmarks'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '我的收藏 - 老黄历'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,5 +82,16 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
noop() {}
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '万年历 - 农历公历对照查询',
|
||||
path: '/pages/calendar/calendar'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '万年历 - 农历公历对照查询'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,5 +39,25 @@ Page({
|
||||
|
||||
goBazi() {
|
||||
wx.navigateTo({ url: '/pages/bazi/bazi' });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const { dayInfo } = this.data;
|
||||
const title = dayInfo.lunarMonthName
|
||||
? `${dayInfo.lunarMonthName}${dayInfo.lunarDayName} 黄历详情`
|
||||
: '每日黄历详情';
|
||||
return {
|
||||
title,
|
||||
path: `/pages/day-detail/day-detail?date=${this.data.date}`
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
const { dayInfo } = this.data;
|
||||
return {
|
||||
title: dayInfo.lunarMonthName
|
||||
? `${dayInfo.lunarMonthName}${dayInfo.lunarDayName} 黄历详情`
|
||||
: '每日黄历详情'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,5 +60,18 @@ Page({
|
||||
const { boneYearIndex, boneMonthIndex, boneDayIndex, boneHourIndex } = this.data;
|
||||
const result = calculateBoneWeight(boneYearIndex, boneMonthIndex + 1, boneDayIndex + 1, boneHourIndex);
|
||||
this.setData({ boneResult: result });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '占卜工具 - 梅花易数称骨算命',
|
||||
path: '/pages/divination/divination'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '占卜工具 - 梅花易数称骨算命'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,5 +43,18 @@ Page({
|
||||
|
||||
goBazi() {
|
||||
wx.switchTab({ url: '/pages/bazi/bazi' });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '每日运势 - 今日运势查询',
|
||||
path: '/pages/fortune/fortune'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '每日运势 - 今日运势查询'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
+189
-29
@@ -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: '',
|
||||
@@ -81,16 +97,18 @@ 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);
|
||||
const upcoming = getUpcomingFestivals(10, y, m, d);
|
||||
return {
|
||||
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: '老黄历 - 传统农历黄历查询'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
+99
-32
@@ -18,6 +18,7 @@
|
||||
class="day-swiper"
|
||||
current="{{dayIndex}}"
|
||||
bindchange="onDaySwiperChange"
|
||||
bindanimationfinish="onDaySwiperFinish"
|
||||
duration="300"
|
||||
circular="{{false}}"
|
||||
>
|
||||
@@ -92,46 +93,75 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 黄历详情 -->
|
||||
<!-- 黄历详情(紧凑布局,每行2个) -->
|
||||
<view class="card" bindtap="openZoom" data-type="detail" data-date="{{dayItem.key}}">
|
||||
<view class="detail-grid">
|
||||
<view class="detail-item">
|
||||
<view class="detail-grid-compact">
|
||||
<view class="detail-item-compact">
|
||||
<text class="detail-label">值神</text>
|
||||
<text class="detail-value">{{dayItem.almanac.duty}}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<view class="detail-item-compact">
|
||||
<text class="detail-label">黄道</text>
|
||||
<text class="detail-value">{{dayItem.almanac.twelveStar.name}}({{dayItem.almanac.twelveStar.ecliptic}})</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<view class="detail-item-compact">
|
||||
<text class="detail-label">二十八宿</text>
|
||||
<text class="detail-value">{{dayItem.almanac.twentyEightStar.name}}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<view class="detail-item-compact">
|
||||
<text class="detail-label">六曜</text>
|
||||
<text class="detail-value">{{dayItem.almanac.sixStar}}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<view class="detail-item-compact">
|
||||
<text class="detail-label">冲煞</text>
|
||||
<text class="detail-value">冲{{dayItem.almanac.clash}} 煞{{dayItem.almanac.evilDirection}}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<view class="detail-item-compact">
|
||||
<text class="detail-label">彭祖百忌</text>
|
||||
<text class="detail-value">{{dayItem.almanac.pengZu}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 节日速查 -->
|
||||
<view class="card" bindtap="openZoom" data-type="fest" data-date="{{dayItem.key}}">
|
||||
<view class="section-title">节日速查</view>
|
||||
<view class="fest-quick">
|
||||
<!-- 节日速查(显示最近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 && 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>
|
||||
</view>
|
||||
</view>
|
||||
<view class="fest-empty" wx:else>暂无近期节日</view>
|
||||
</view>
|
||||
|
||||
<!-- 历史上的今天(数据来源:中文维基百科) -->
|
||||
<view class="card otd-card" wx:if="{{dayItem.otd}}">
|
||||
<view class="otd-head">
|
||||
<text class="section-title">历史上的今天</text>
|
||||
<view class="otd-tabs">
|
||||
<text class="otd-tab {{dayItem.otdTab === 'event' ? 'active' : ''}}" catchtap="switchOtdTab" data-date="{{dayItem.key}}" data-tab="event">大事记</text>
|
||||
<text class="otd-tab {{dayItem.otdTab === 'birth' ? 'active' : ''}}" catchtap="switchOtdTab" data-date="{{dayItem.key}}" data-tab="birth">出生</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="otd-list">
|
||||
<view class="otd-item" wx:for="{{dayItem.otdTab === 'event' ? dayItem.otd.eventsPreview : dayItem.otd.birthsPreview}}" wx:key="id" wx:for-item="ev">
|
||||
<text class="otd-year" wx:if="{{ev.yearText}}">{{ev.yearText}}</text>
|
||||
<text class="otd-text {{ev.yearText ? '' : 'no-year'}}">{{ev.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="otd-foot">
|
||||
<text class="otd-source">来源:中文维基百科</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}}">
|
||||
<view class="section-title">历史上的今天</view>
|
||||
<view class="placeholder-content">加载中…</view>
|
||||
</view>
|
||||
|
||||
<!-- 回今天 -->
|
||||
@@ -162,26 +192,22 @@
|
||||
<swiper-item wx:for="{{monthList}}" wx:key="key">
|
||||
<scroll-view scroll-y class="month-scroll">
|
||||
<view class="grid grid-cols-7 month-grid">
|
||||
<view
|
||||
class="day-cell {{d.isToday ? 'today' : ''}} {{d.solarMonth !== item.month ? 'other-month' : ''}}"
|
||||
wx:for="{{item.weeks}}"
|
||||
wx:for-item="week"
|
||||
wx:key="week"
|
||||
>
|
||||
<block wx:for="{{week}}" wx:for-item="d" wx:key="solarDate">
|
||||
<view
|
||||
class="day-cell-inner {{d.isToday ? 'today' : ''}} {{d.solarMonth !== item.month ? 'other-month' : ''}}"
|
||||
bindtap="selectDay"
|
||||
data-date="{{d.solarDate}}"
|
||||
>
|
||||
<text class="day-number">{{d.solarDay}}</text>
|
||||
<text class="day-lunar {{d.lunarFestival || d.solarFestival ? 'festival' : ''}}">
|
||||
{{d.lunarFestival || d.solarFestival || d.lunarDayName}}
|
||||
</text>
|
||||
<view class="day-dot" wx:if="{{d.isTermDay}}"></view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<block wx:for="{{item.weeks}}" wx:for-item="week" wx:key="index">
|
||||
<view
|
||||
class="day-cell-inner {{d.isToday ? 'today' : ''}} {{d.solarMonth !== item.month ? 'other-month' : ''}}"
|
||||
wx:for="{{week}}"
|
||||
wx:for-item="d"
|
||||
wx:key="solarDate"
|
||||
bindtap="selectDay"
|
||||
data-date="{{d.solarDate}}"
|
||||
>
|
||||
<text class="day-number">{{d.solarDay}}</text>
|
||||
<text class="day-lunar {{d.lunarFestival || d.solarFestival ? 'festival' : ''}}">
|
||||
{{d.lunarFestival || d.solarFestival || d.lunarDayName}}
|
||||
</text>
|
||||
<view class="day-dot" wx:if="{{d.isTermDay}}"></view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
@@ -264,5 +290,46 @@
|
||||
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 今年节日(字体稍小,避免溢出) -->
|
||||
<scroll-view scroll-y class="zoom-body" 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>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 历史上的今天放大 -->
|
||||
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'otd' && zoomData}}">
|
||||
<block wx:if="{{zoomData.events.length}}">
|
||||
<view class="zoom-otd-title">大事记</view>
|
||||
<view class="zoom-otd-item" wx:for="{{zoomData.events}}" wx:key="id">
|
||||
<text class="otd-year" wx:if="{{item.yearText}}">{{item.yearText}}</text>
|
||||
<text class="otd-text {{item.yearText ? '' : 'no-year'}}">{{item.content}}</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:if="{{zoomData.births.length}}">
|
||||
<view class="zoom-otd-title">出生</view>
|
||||
<view class="zoom-otd-item" wx:for="{{zoomData.births}}" wx:key="id">
|
||||
<text class="otd-year" wx:if="{{item.yearText}}">{{item.yearText}}</text>
|
||||
<text class="otd-text {{item.yearText ? '' : 'no-year'}}">{{item.content}}</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:if="{{zoomData.deaths.length}}">
|
||||
<view class="zoom-otd-title">逝世</view>
|
||||
<view class="zoom-otd-item" wx:for="{{zoomData.deaths}}" wx:key="id">
|
||||
<text class="otd-year" wx:if="{{item.yearText}}">{{item.yearText}}</text>
|
||||
<text class="otd-text {{item.yearText ? '' : 'no-year'}}">{{item.content}}</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:if="{{zoomData.festivals.length}}">
|
||||
<view class="zoom-otd-title">节假日与习俗</view>
|
||||
<view class="zoom-otd-item" wx:for="{{zoomData.festivals}}" wx:key="id">
|
||||
<text class="otd-text no-year">{{item.content}}</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="zoom-otd-source">数据来源:中文维基百科(CC BY-SA 4.0)</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+254
-23
@@ -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;
|
||||
@@ -392,6 +491,9 @@
|
||||
font-size: 24rpx;
|
||||
color: #C41E3A;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
min-width: 100rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fest-left.is-today {
|
||||
@@ -468,10 +570,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 +590,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 +767,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;
|
||||
}
|
||||
|
||||
@@ -50,5 +50,18 @@ Page({
|
||||
onHighlightLunarChange(e) {
|
||||
this.setData({ highlightLunar: e.detail.value });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '偏好设置 - 老黄历',
|
||||
path: '/pages/preferences/preferences'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '偏好设置 - 老黄历'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ Page({
|
||||
success: (loginRes) => {
|
||||
if (!loginRes.code) {
|
||||
this.setData({ logging: false });
|
||||
wx.showToast({ title: '微信登录失败', icon: 'none' });
|
||||
wx.showToast({ title: '授权登录失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
request({
|
||||
@@ -72,7 +72,7 @@ Page({
|
||||
},
|
||||
fail: () => {
|
||||
this.setData({ logging: false });
|
||||
wx.showToast({ title: '微信登录失败', icon: 'none' });
|
||||
wx.showToast({ title: '授权登录失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -100,5 +100,18 @@ Page({
|
||||
} else {
|
||||
wx.navigateTo({ url });
|
||||
}
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '老黄历 - 个人中心',
|
||||
path: '/pages/settings/settings'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '老黄历 - 个人中心'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,25 +8,24 @@
|
||||
<text>客</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="text-base font-medium">{{userInfo.nickname || '微信用户'}}</text>
|
||||
<text class="text-sm font-medium">{{userInfo.nickname || '授权用户'}}</text>
|
||||
<text class="text-xs text-muted block">已登录,生辰档案云端同步</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn-ghost text-muted" bindtap="logout">退出</view>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<view class="flex items-center gap-2 mb-2">
|
||||
<view class="flex items-center justify-between" wx:else>
|
||||
<view class="flex items-center gap-2">
|
||||
<view class="avatar avatar-placeholder">
|
||||
<text>客</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="text-base font-medium">未登录</text>
|
||||
<text class="text-xs text-muted block">绑定的生辰仅保存在本机,登录后才生效</text>
|
||||
<text class="text-sm font-medium">未登录</text>
|
||||
<text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn-primary text-center" bindtap="login">{{logging ? '登录中…' : '微信登录'}}</view>
|
||||
<view class="text-xs text-muted text-center mt-2">登录后可将生辰档案同步到云端,换设备不丢失</view>
|
||||
</block>
|
||||
<view class="btn-primary btn-sm" bindtap="login">{{logging ? '登录中…' : '授权登录'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的数据 -->
|
||||
@@ -76,10 +75,9 @@
|
||||
<!-- 关于 -->
|
||||
<view class="card">
|
||||
<view class="section-title">关于</view>
|
||||
<view class="text-sm text-muted">
|
||||
<text>老黄历小程序 v{{version}}</text>
|
||||
<view class="text-xs text-muted">
|
||||
<text>老黄历 v{{version}}</text>
|
||||
<text class="block mt-1" wx:if="{{buildNumber !== '0'}}">构建 #{{buildNumber}} ({{commitSha}})</text>
|
||||
<text class="block mt-1">提供农历、黄历、八字、许愿等功能</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
.container {
|
||||
padding: 20rpx;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
color: #C41E3A;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -21,14 +21,21 @@
|
||||
justify-content: center;
|
||||
background: #C41E3A;
|
||||
color: #FFFFFF;
|
||||
font-size: 40rpx;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 10rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
border-radius: 32rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
@@ -37,7 +44,7 @@
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-size: 28rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.menu-right {
|
||||
@@ -47,11 +54,11 @@
|
||||
}
|
||||
|
||||
.menu-count {
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9E8E7E;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #C9B8A6;
|
||||
font-size: 36rpx;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
@@ -32,5 +32,18 @@ Page({
|
||||
// 按日期排序
|
||||
terms.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
this.setData({ terms });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '二十四节气 - 节气查询',
|
||||
path: '/pages/solar-terms/solar-terms'
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '二十四节气 - 节气查询'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
+53
-1
@@ -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: '黄历百科 - 传统文化知识'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,4 +53,11 @@ Page({
|
||||
path: `/pages/wish-detail/wish-detail?id=${wish ? wish.id : ''}`,
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
const { wish } = this.data;
|
||||
return {
|
||||
title: wish ? `我的愿望:${wish.content.substring(0, 20)}...` : '快来许愿吧!'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -244,48 +244,65 @@ Page({
|
||||
this.startDanmakuScheduler(cache.danmaku);
|
||||
},
|
||||
|
||||
// 启动弹幕调度:每行同一时刻只播一条,前一条飘完再出下一条(循环)
|
||||
// 启动弹幕调度:每行一个发射定时器,前一条完全进入屏幕后就出下一条,
|
||||
// 同一行屏上保持 2~3 条,首尾相接不压着;各行速度不同、同行弹幕速度也错落
|
||||
startDanmakuScheduler(rows) {
|
||||
this.stopDanmakuScheduler();
|
||||
this.danmakuRows = rows || [];
|
||||
this.danmakuTimers = [];
|
||||
this.danmakuSeq = 0; // 全局自增序号,保证每条 key 唯一
|
||||
|
||||
if (!this.danmakuRows.length) {
|
||||
this.setData({ currentDanmaku: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const GAP = 1.5; // 同一行前一条飘完后,间隔再出下一条(秒)
|
||||
// 屏上目标条数:同一行同时约 2.5 条
|
||||
const ON_SCREEN = 2.5;
|
||||
// 弹幕完全进入屏幕约需飘过总路程的 30%(不压着前一条的最小间隔)
|
||||
const ENTER_RATIO = 0.3;
|
||||
const MIN_INTERVAL = 3; // 同一行相邻两条最小间隔(秒)
|
||||
|
||||
// 每行独立调度:row 行的第 idx 条播完后,隔 GAP 秒播第 idx+1 条
|
||||
const playRow = (row, idx) => {
|
||||
// 往某行发射一条弹幕,返回该条的 duration(供调度下一条用)
|
||||
const emit = (row, idx) => {
|
||||
const rowData = this.danmakuRows[row];
|
||||
if (!rowData) return;
|
||||
if (!rowData) return 0;
|
||||
const item = rowData.list[idx % rowData.list.length];
|
||||
// 更新该行当前弹幕(用行号作为 key 保证 setData 局部刷新)
|
||||
this.setData({
|
||||
[`currentDanmaku[${row}]`]: {
|
||||
key: `${row}-${idx % rowData.list.length}-${Date.now()}`,
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
author: item.author,
|
||||
type: item.type,
|
||||
top: rowData.top,
|
||||
duration: rowData.duration,
|
||||
},
|
||||
});
|
||||
// 前一条飘完 + 间隔后,播下一条
|
||||
const timer = setTimeout(() => {
|
||||
playRow(row, idx + 1);
|
||||
}, (rowData.duration + GAP) * 1000);
|
||||
this.danmakuTimers[row] = timer;
|
||||
const seq = ++this.danmakuSeq;
|
||||
const danmaku = {
|
||||
key: `d${seq}`,
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
author: item.author,
|
||||
type: item.type,
|
||||
top: rowData.top,
|
||||
duration: item.duration,
|
||||
};
|
||||
// 追加到屏上弹幕列表(构造新数组,避免索引冲突)
|
||||
this.setData({ currentDanmaku: this.data.currentDanmaku.concat(danmaku) });
|
||||
// 动画播完(飘到屏外)后,把这条从列表移除
|
||||
const removeTimer = setTimeout(() => {
|
||||
const list = this.data.currentDanmaku.filter((d) => d.key !== danmaku.key);
|
||||
this.setData({ currentDanmaku: list });
|
||||
}, item.duration * 1000);
|
||||
this.danmakuTimers.push(removeTimer);
|
||||
return item.duration;
|
||||
};
|
||||
|
||||
// 初始化 currentDanmaku 数组长度,并启动每行
|
||||
this.setData({ currentDanmaku: new Array(this.danmakuRows.length).fill(null) });
|
||||
this.danmakuRows.forEach((_, row) => {
|
||||
// 每行独立调度:按刚发射这条的速度决定下一条间隔,循环
|
||||
this.danmakuRows.forEach((rowData, row) => {
|
||||
let idx = 0;
|
||||
const loop = () => {
|
||||
const duration = emit(row, idx);
|
||||
idx += 1;
|
||||
// 下一条间隔:屏上保持约 2.5 条(duration/2.5),
|
||||
// 但不小于“完全进入”时间(duration*0.3,不压着)和最小间隔
|
||||
const interval = Math.max(duration / ON_SCREEN, duration * ENTER_RATIO, MIN_INTERVAL);
|
||||
const timer = setTimeout(loop, interval * 1000);
|
||||
this.danmakuTimers.push(timer);
|
||||
};
|
||||
// 各行错开启动,避免 4 行同时从右侧涌出
|
||||
const startTimer = setTimeout(() => playRow(row, 0), row * 1200);
|
||||
const startTimer = setTimeout(loop, row * 1500);
|
||||
this.danmakuTimers.push(startTimer);
|
||||
});
|
||||
},
|
||||
@@ -296,18 +313,21 @@ Page({
|
||||
this.danmakuTimers.forEach((t) => clearTimeout(t));
|
||||
this.danmakuTimers = [];
|
||||
}
|
||||
// 清空屏上弹幕,避免恢复时残留未播完的项
|
||||
if (this.data.currentDanmaku && this.data.currentDanmaku.length) {
|
||||
this.setData({ currentDanmaku: [] });
|
||||
}
|
||||
},
|
||||
|
||||
// 构建弹幕:共 4 行(行 0-2 树桩处三行,行 3 顶部置顶轮播下方)
|
||||
// 按行分组返回,由 JS 定时器控制:同一行一条完毕后再出下一条,不重叠
|
||||
// 每行速度不同、同行弹幕速度也带随机错落,避免太整齐;前一条完全进入后出下一条
|
||||
buildDanmaku(list) {
|
||||
const DURATION_BOTTOM = 30; // 树桩处一条飘完全屏的秒数(慢速)
|
||||
const DURATION_TOP = 24; // 顶部行稍快一点
|
||||
// 行位置(屏幕百分比):
|
||||
// - 行3 顶部行紧贴置顶轮播下方(稍有空隙)
|
||||
// - 行0-2 树桩处三行,第一条68%,三行紧凑(间隔5%)
|
||||
const ROW_TOPS = [68, 73, 78, 12];
|
||||
const ROW_DURATIONS = [DURATION_BOTTOM, DURATION_BOTTOM, DURATION_BOTTOM, DURATION_TOP];
|
||||
// - 行0-2 树桩处三行,第一条70%,三行紧凑(间隔3%)
|
||||
const ROW_TOPS = [70, 73, 76, 12];
|
||||
// 每行基准速度(秒/次):各行不同,避免整齐划一
|
||||
const ROW_BASE_DURATION = [26, 33, 40, 22];
|
||||
|
||||
// 轮流分配到 4 行
|
||||
const rows = [[], [], [], []];
|
||||
@@ -318,12 +338,13 @@ Page({
|
||||
// 每行存该行的所有弹幕(带位置/时长),供定时器轮流播放
|
||||
return rows.map((rowList, row) => ({
|
||||
top: ROW_TOPS[row],
|
||||
duration: ROW_DURATIONS[row],
|
||||
list: rowList.map((w) => ({
|
||||
list: rowList.map((w, i) => ({
|
||||
id: w.id,
|
||||
content: w.content,
|
||||
author: w.author || '匿名',
|
||||
type: w.type,
|
||||
// 同行弹幕速度也带 ±20% 错落(seeded 保证每次渲染一致)
|
||||
duration: Math.round(ROW_BASE_DURATION[row] * (0.8 + seeded(w.id != null ? w.id : i, row + 7) * 0.4)),
|
||||
})),
|
||||
})).filter((r) => r.list.length > 0);
|
||||
},
|
||||
|
||||
@@ -45,14 +45,14 @@
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 弹幕层(超出30条的心愿缓缓飘过,每行一条完毕再出下一条) -->
|
||||
<!-- 弹幕层(超出30条的心愿缓缓飘过,前一条完全进入后出下一条) -->
|
||||
<view class="danmaku-layer">
|
||||
<block wx:for="{{currentDanmaku}}" wx:key="key" wx:if="{{item}}">
|
||||
<view
|
||||
class="danmaku-item {{item.type === 'paid' ? 'paid' : ''}}"
|
||||
style="top: {{item.top}}%; animation-duration: {{item.duration}}s;"
|
||||
>{{item.content}} · {{item.author}}</view>
|
||||
</block>
|
||||
<view
|
||||
wx:for="{{currentDanmaku}}"
|
||||
wx:key="key"
|
||||
class="danmaku-item {{item.type === 'paid' ? 'paid' : ''}}"
|
||||
style="top: {{item.top}}%; animation-duration: {{item.duration}}s;"
|
||||
>{{item.content}} · {{item.author}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 边缘暗角 -->
|
||||
|
||||
@@ -47,5 +47,6 @@
|
||||
"editorSetting": {
|
||||
"tabIndent": "insertSpaces",
|
||||
"tabSize": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"isGameTourist": false
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+19
-1
@@ -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 页,只抓中文版)。
|
||||
|
||||
## 数据库
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -4,10 +4,10 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
@@ -39,9 +39,16 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
+23
-1
@@ -9,12 +9,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -33,6 +39,10 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -59,6 +69,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -87,8 +100,9 @@ golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
@@ -103,4 +117,12 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
)
|
||||
@@ -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 初始化默认数据
|
||||
|
||||
@@ -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", "值神", "当日当值的十二位神煞", "值神即当日值事之神,与建除十二神对应:青龙、明堂、天刑、朱雀、金匮、天德、白虎、玉堂、天牢、玄武、司命、勾陈依次轮值。青龙明堂金匮天德玉堂司命六神值日为黄道吉日。"},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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": "同步任务已启动"})
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)<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]+`)
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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)|萊希菲爾德之戰]]中取得勝利。<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)
|
||||
|
||||
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, "<ref") {
|
||||
t.Errorf("条目残留 wiki 语法: %q", it.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRealPage 若本地存在真实抓取的样本文件则验证(可选)
|
||||
func TestParseRealPage(t *testing.T) {
|
||||
data, err := os.ReadFile("/tmp/wiki_810.json")
|
||||
if err != nil {
|
||||
t.Skip("无真实样本文件,跳过")
|
||||
}
|
||||
var d struct {
|
||||
Parse struct {
|
||||
Wikitext string `json:"wikitext"`
|
||||
} `json:"parse"`
|
||||
}
|
||||
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))
|
||||
}
|
||||
+14
-5
@@ -5,24 +5,33 @@
|
||||
<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'),
|
||||
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();
|
||||
|
||||
@@ -22,6 +22,9 @@ export default {
|
||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
许愿管理
|
||||
</a>
|
||||
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
百科管理
|
||||
</a>
|
||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,9 @@ export default {
|
||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
许愿管理
|
||||
</a>
|
||||
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
百科管理
|
||||
</a>
|
||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
@@ -22,6 +22,9 @@ export default {
|
||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
许愿管理
|
||||
</a>
|
||||
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
百科管理
|
||||
</a>
|
||||
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
@@ -22,6 +22,9 @@ export default {
|
||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
许愿管理
|
||||
</a>
|
||||
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
百科管理
|
||||
</a>
|
||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<nav class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
仪表盘
|
||||
</a>
|
||||
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
用户管理
|
||||
</a>
|
||||
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
订单管理
|
||||
</a>
|
||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
许愿管理
|
||||
</a>
|
||||
<a href="/admin/wiki" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
百科管理
|
||||
</a>
|
||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
系统设置
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<!-- 标签页切换 -->
|
||||
<div class="px-4 sm:px-0 mb-4">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button @click="tab = 'entries'" :class="tab === 'entries' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700'" class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm">百科条目</button>
|
||||
<button @click="tab = 'sync'" :class="tab === 'sync' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700'" class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm">历史上的今天(维基同步)</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 百科条目管理 -->
|
||||
<div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'">
|
||||
<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">
|
||||
<h2 class="text-lg font-medium text-gray-900">百科条目(共 {{ entries.length }} 条)</h2>
|
||||
<button @click="openEditor()" 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">
|
||||
<i class="fas fa-plus mr-1"></i>新增条目
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<select v-model="filterCat" @change="loadEntries" class="block rounded-md border-gray-300 border px-3 py-2 text-sm">
|
||||
<option value="">全部分类</option>
|
||||
<option v-for="c in categories" :key="c.key" :value="c.key">{{ c.name }}</option>
|
||||
</select>
|
||||
<input v-model="filterKeyword" @keyup.enter="loadEntries" placeholder="搜索标题/内容..." class="block rounded-md border-gray-300 border px-3 py-2 text-sm w-64"/>
|
||||
<button @click="loadEntries" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">搜索</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">分类</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">标题</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">简介</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">排序</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="e in entries" :key="e.id">
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ e.id }}</td>
|
||||
<td class="px-4 py-3 text-sm whitespace-nowrap">{{ catName(e.category) }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 whitespace-nowrap">{{ e.title }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">{{ e.brief }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ e.sort }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span :class="e.status === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">{{ e.status === 1 ? '显示' : '隐藏' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<button @click="openEditor(e)" class="text-red-600 hover:text-red-800 mr-3">编辑</button>
|
||||
<button @click="removeEntry(e)" class="text-gray-400 hover:text-red-600">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="entries.length === 0">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-400">暂无条目</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史上的今天:维基百科同步 -->
|
||||
<div class="px-4 py-2 sm:px-0" v-if="tab === 'sync'">
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">维基百科数据同步</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">数据来源:中文维基百科「X月X日」页面(共 366 页,含大事记/出生/逝世/节假日习俗)。服务启动时若数据缺失会自动抓取;之后按配置间隔(默认 7 天)定时增量刷新。</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-500">日期覆盖</div>
|
||||
<div class="text-2xl font-bold">{{ syncStatus.totalDays || 0 }} / 366 天</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-500">条目总数</div>
|
||||
<div class="text-2xl font-bold">{{ syncStatus.totalItems || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-500">同步状态</div>
|
||||
<div class="text-2xl font-bold">
|
||||
<span v-if="syncStatus.running" class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>进行中</span>
|
||||
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'success'" class="text-green-600">已完成</span>
|
||||
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'partial'" class="text-yellow-600">部分失败</span>
|
||||
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'failed'" class="text-red-600">失败</span>
|
||||
<span v-else class="text-gray-400">未同步</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="syncStatus.lastSync" class="text-sm text-gray-600 mb-4 bg-gray-50 rounded-lg p-4">
|
||||
<div>最近同步:{{ formatTime(syncStatus.lastSync.startedAt) }} ~ {{ syncStatus.lastSync.finishedAt ? formatTime(syncStatus.lastSync.finishedAt) : '进行中' }}</div>
|
||||
<div class="mt-1">触发方式:{{ triggerName(syncStatus.lastSync.trigger) }} | 成功 {{ syncStatus.lastSync.success }} 页 / 失败 {{ syncStatus.lastSync.failed }} 页</div>
|
||||
</div>
|
||||
|
||||
<button @click="triggerSync" :disabled="syncStatus.running || syncing" class="inline-flex items-center px-4 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-2" :class="syncStatus.running ? 'fa-spin' : ''"></i>{{ syncStatus.running ? '同步进行中(约 3-5 分钟)' : '立即全量同步' }}
|
||||
</button>
|
||||
<button @click="loadSyncStatus" class="ml-3 px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新状态</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 条目编辑弹窗 -->
|
||||
<div v-if="editor.visible" class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75" @click="editor.visible = false"></div>
|
||||
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full sm:p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ editor.id ? '编辑条目' : '新增条目' }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">分类</label>
|
||||
<select v-model="editor.form.category" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
|
||||
<option v-for="c in categories" :key="c.key" :value="c.key">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||||
<input v-model="editor.form.title" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">简介(列表折叠时显示)</label>
|
||||
<input v-model="editor.form.brief" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||
</div>
|
||||
<div>
|
||||
<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 class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">排序(越小越靠前)</label>
|
||||
<input type="number" v-model.number="editor.form.sort" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||
<select v-model.number="editor.form.status" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
|
||||
<option :value="1">显示</option>
|
||||
<option :value="0">隐藏</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button @click="editor.visible = false" class="px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">取消</button>
|
||||
<button @click="saveEntry" :disabled="saving" class="px-4 py-2 border border-transparent rounded-md text-sm text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export default {
|
||||
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
许愿管理
|
||||
</a>
|
||||
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
百科管理
|
||||
</a>
|
||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user