首页老黄历全面升级 + 修复农历转换核心bug
Publish Mini Program Dev Version / publish (push) Successful in 2m8s

【重要修复】重写农历双向转换算法(原来全错):
- solarToLunar/lunarToSolar 改用标准算法(基准1900-01-31正月初一,UTC避免时区误差)
- 验证:2026春节2-17、中秋9-25、端午6-19、2023闰二月全部正确

【首页大日历】
- 日期数字加大到240rpx,超醒目
- 节假日红色喜庆卡片+红数字(春节/国庆/中秋等法定假日)
- 工作日绿色卡片+绿数字(一眼知道要上班)
- 周末红色数字+米黄卡片
- 节气显示具体时刻(如 立秋 20:29)
- 初一/十五金色标记(可在设置关闭)
- 佛历提醒:距下一佛诞还有几天(可在设置开启)

【全屏放大弹窗(老年友好)】
- 大日历卡片/宜忌/详情/节日速查 点击均可全屏放大
- 宜忌放大后字号44rpx,详情放大后40rpx

【月历视图】
- swiper 左右滑动换月,预计算5个月居中显示
- 滑到边缘自动补月,可无限滚动
- 点选日期切回日视图

【节日速查】
- 首页显示近期5个节日(中秋/重阳等)+公历日期+倒计时
- 点击直接跳到该日老黄历

【设置页】
- 新增'显示佛历提醒'开关(默认关)
- 新增'初一十五高亮'开关(默认开)
This commit is contained in:
gouki
2026-08-07 12:49:57 +00:00
parent 5d40019ea5
commit 82c1a8d694
11 changed files with 1636 additions and 425 deletions
+6 -1
View File
@@ -59,5 +59,10 @@
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
"lazyCodeLoading": "requiredComponents",
"permission": {
"scope.record": {
"desc": "用于许愿树吹风互动效果"
}
}
}
+157 -36
View File
@@ -1,21 +1,40 @@
const { getDayInfo, getAlmanacInfo, getMonthCalendar } = require('../../utils/core/index.js');
const {
getDayInfo, getAlmanacInfo, getMonthCalendar,
getLegalHoliday, isWorkday, getUpcomingFestivals, getNextBuddhistFestival
} = require('../../utils/core/index.js');
const { SOLAR_TERM_TIMES } = require('../../utils/core/data.js');
function pad(n) { return String(n).padStart(2, '0'); }
function fmt(y, m, d) { return `${y}-${pad(m)}-${pad(d)}`; }
const MONTH_SPAN = 2; // 当前月前后各2个月,共5个月
Page({
data: {
statusBarHeight: 20,
navBarHeight: 44,
viewMode: 'day', // day | month
viewMode: 'day',
dayInfo: {},
almanac: {},
// 月历
year: 0,
month: 0,
legalHoliday: null,
workday: true,
termTime: '',
isFirstOrFifteen: false,
nextBuddhist: null,
showBuddhist: false,
highlightLunar: true,
upcoming: [],
// 月历 swiper
monthList: [], // [{key, year, month, weeks}]
monthIndex: 2, // 当前居中
currentYear: 0,
currentMonth: 0,
weekHeaders: ['日', '一', '二', '三', '四', '五', '六'],
calendarDays: [],
selectedDay: null
// 全屏弹窗
zoomVisible: false,
zoomTitle: '',
zoomType: '', // yiji | detail | fest
zoomData: null
},
onLoad() {
@@ -23,18 +42,33 @@ Page({
const menu = wx.getMenuButtonBoundingClientRect ? wx.getMenuButtonBoundingClientRect() : null;
const statusBarHeight = sys.statusBarHeight || 20;
let navBarHeight = 44;
if (menu && menu.top) {
navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height;
}
if (menu && menu.top) navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height;
this.setData({ statusBarHeight, navBarHeight });
this.loadSettings();
this.loadDay(new Date());
this.loadUpcoming();
},
onShow() {
this.loadSettings();
// 设置可能变化,刷新
if (this.data.dayInfo.solarYear) this.refreshDerived();
},
loadSettings() {
const s = wx.getStorageSync('lunar-settings') || {};
this.setData({
showBuddhist: s.showBuddhist === true,
highlightLunar: s.highlightLunar !== false
});
},
onPullDownRefresh() {
if (this.data.viewMode === 'day') {
this.loadDay(new Date());
this.loadUpcoming();
} else {
this.loadCalendar();
this.buildMonthList(this.data.currentYear, this.data.currentMonth);
}
wx.stopPullDownRefresh();
},
@@ -46,10 +80,26 @@ Page({
const d = date.getDate();
const dayInfo = getDayInfo(y, m, d);
const almanac = getAlmanacInfo(y, m, d);
this.setData({ dayInfo, almanac, year: y, month: m });
this.setData({ dayInfo, almanac, currentYear: y, currentMonth: m });
this.refreshDerived();
},
/** 计算派生数据(节假日/工作日/佛历/初一十五) */
refreshDerived() {
const { dayInfo, showBuddhist, highlightLunar } = this.data;
const { solarYear: y, solarMonth: m, solarDay: d } = dayInfo;
const legalHoliday = getLegalHoliday(y, m, d);
const workday = isWorkday(y, m, d);
const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : '';
const isFirstOrFifteen = highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15);
const nextBuddhist = showBuddhist ? getNextBuddhistFestival() : null;
this.setData({ legalHoliday, workday, termTime, isFirstOrFifteen, nextBuddhist });
},
loadUpcoming() {
this.setData({ upcoming: getUpcomingFestivals(5) });
},
/** 前一天 / 后一天 */
prevDay() { this.shiftDay(-1); },
nextDay() { this.shiftDay(1); },
shiftDay(delta) {
@@ -58,45 +108,72 @@ Page({
this.loadDay(date);
},
/** 回到今天 */
backToday() {
this.loadDay(new Date());
this.loadUpcoming();
},
/** 切换 日视图/月视图 */
/** 切换 日/月 视图 */
toggleView() {
const mode = this.data.viewMode === 'day' ? 'month' : 'day';
this.setData({ viewMode: mode });
if (mode === 'month') {
const { dayInfo } = this.data;
this.setData({ year: dayInfo.solarYear, month: dayInfo.solarMonth }, () => this.loadCalendar());
this.buildMonthList(dayInfo.solarYear, dayInfo.solarMonth);
}
},
/** 月历 */
loadCalendar() {
const { year, month } = this.data;
const weeks = getMonthCalendar(year, month);
const calendarDays = [];
weeks.forEach(w => w.forEach(d => calendarDays.push(d)));
this.setData({ calendarDays });
/** 构建5个月的数据,居中显示 */
buildMonthList(year, month) {
const monthList = [];
for (let i = -MONTH_SPAN; i <= MONTH_SPAN; i++) {
let y = year, m = month + i;
while (m < 1) { m += 12; y--; }
while (m > 12) { m -= 12; y++; }
const weeks = getMonthCalendar(y, m);
monthList.push({ key: `${y}-${m}`, year: y, month: m, weeks });
}
this.setData({ monthList, monthIndex: MONTH_SPAN, currentYear: year, currentMonth: month });
},
prevMonth() {
let { year, month } = this.data;
month--;
if (month < 1) { month = 12; year--; }
this.setData({ year, month }, () => this.loadCalendar());
/** swiper 切换 */
onMonthSwiperChange(e) {
const index = e.detail.current;
const { monthList } = this.data;
const cur = monthList[index];
this.setData({ monthIndex: index, currentYear: cur.year, currentMonth: cur.month });
// 边缘时向对应方向补一个月,保持可无限滚动
if (index === 0) {
this.prependMonth();
} else if (index === monthList.length - 1) {
this.appendMonth();
}
},
nextMonth() {
let { year, month } = this.data;
month++;
if (month > 12) { month = 1; year++; }
this.setData({ year, month }, () => this.loadCalendar());
prependMonth() {
const { monthList } = this.data;
const first = monthList[0];
let y = first.year, m = first.month - 1;
if (m < 1) { m = 12; y--; }
const weeks = getMonthCalendar(y, m);
monthList.unshift({ key: `${y}-${m}`, year: y, month: m, weeks });
if (monthList.length > 7) monthList.pop();
this.setData({ monthList, monthIndex: this.data.monthIndex + 1 });
},
/** 月历中选中某天 → 切回日视图 */
appendMonth() {
const { monthList } = this.data;
const last = monthList[monthList.length - 1];
let y = last.year, m = last.month + 1;
if (m > 12) { m = 1; y++; }
const weeks = getMonthCalendar(y, m);
monthList.push({ key: `${y}-${m}`, year: y, month: m, weeks });
if (monthList.length > 7) monthList.shift();
this.setData({ monthList, monthIndex: this.data.monthIndex - 1 });
},
/** 月历点选某天 → 切回日视图 */
selectDay(e) {
const date = e.currentTarget.dataset.date;
const [y, m, d] = date.split('-').map(Number);
@@ -104,13 +181,57 @@ Page({
this.setData({ viewMode: 'day' });
},
/** 跳详情页 */
goDetail() {
const { dayInfo } = this.data;
wx.navigateTo({ url: `/pages/day-detail/day-detail?date=${fmt(dayInfo.solarYear, dayInfo.solarMonth, dayInfo.solarDay)}` });
},
/** 跳转到某节日的老黄历 */
goFestival(e) {
const date = e.currentTarget.dataset.date;
const [y, m, d] = date.split('-').map(Number);
this.loadDay(new Date(y, m - 1, d));
this.setData({ viewMode: 'day' });
wx.pageScrollTo && wx.pageScrollTo({ scrollTop: 0, duration: 0 });
},
/** ===== 全屏放大弹窗 ===== */
openZoom(e) {
const type = e.currentTarget.dataset.type;
const { dayInfo, almanac, legalHoliday, termTime } = this.data;
let zoomTitle = '';
let zoomData = null;
if (type === 'yiji') {
zoomTitle = `${dayInfo.solarMonth}${dayInfo.solarDay}日 宜忌`;
zoomData = { recommends: almanac.recommends, avoids: almanac.avoids };
} else if (type === 'detail') {
zoomTitle = `${dayInfo.solarMonth}${dayInfo.solarDay}日 黄历详情`;
zoomData = {
lunar: `${dayInfo.lunarMonthName}${dayInfo.lunarDayName}`,
ganzhi: `${dayInfo.lunarYearGanzhi}${dayInfo.lunarMonthGanzhi}${dayInfo.lunarDayGanzhi}`,
duty: almanac.duty,
twelveStar: `${almanac.twelveStar.name}${almanac.twelveStar.ecliptic}`,
twentyEightStar: almanac.twentyEightStar.name,
sixStar: almanac.sixStar,
clash: `${almanac.clash}${almanac.evilDirection}`,
pengZu: almanac.pengZu,
legalHoliday,
termTime: dayInfo.solarTerm ? `${dayInfo.solarTerm} ${termTime}` : ''
};
} else if (type === 'fest') {
zoomTitle = '近期节日';
zoomData = { upcoming: this.data.upcoming };
}
this.setData({ zoomVisible: true, zoomTitle, zoomType: type, zoomData });
},
closeZoom() {
this.setData({ zoomVisible: false });
},
navTo(e) {
wx.navigateTo({ url: e.currentTarget.dataset.url });
}
},
noop() {}
});
+170 -51
View File
@@ -12,24 +12,26 @@
<!-- 日视图:老黄历 -->
<scroll-view class="page-body" scroll-y wx:if="{{viewMode === 'day'}}" style="padding-top: {{statusBarHeight + navBarHeight}}px;">
<view class="container">
<!-- 大日历卡片 -->
<view class="almanac-card">
<!-- 大日历卡片(点击放大) -->
<view class="almanac-card {{legalHoliday ? 'is-holiday' : (workday ? 'is-workday' : 'is-weekend')}}" bindtap="openZoom" data-type="detail">
<!-- 顶部:公历 -->
<view class="almanac-head">
<view class="almanac-head-left">
<text class="almanac-year">{{dayInfo.solarYear}}年{{dayInfo.solarMonth}}月</text>
<text class="almanac-week">{{dayInfo.weekDay}} · {{dayInfo.constellation}}</text>
</view>
<view class="almanac-head-right" wx:if="{{dayInfo.isToday}}">
<text class="today-tag">今日</text>
<view class="head-tags">
<text class="today-tag" wx:if="{{dayInfo.isToday}}">今日</text>
<text class="lunar-tag" wx:if="{{isFirstOrFifteen}}">{{dayInfo.lunarDay === 1 ? '初一' : '十五'}}</text>
</view>
</view>
<!-- 大日期 -->
<view class="almanac-day-row">
<view class="day-arrow" bindtap="prevDay"></view>
<view class="almanac-day-num font-chinese">{{dayInfo.solarDay}}</view>
<view class="day-arrow" bindtap="nextDay"></view>
<view class="almanac-day-row" catchtap="noop">
<view class="day-arrow" catchtap="prevDay"></view>
<view class="almanac-day-num font-chinese {{legalHoliday ? 'num-holiday' : (workday ? 'num-workday' : 'num-weekend')}}">{{dayInfo.solarDay}}</view>
<view class="day-arrow" catchtap="nextDay"></view>
</view>
<!-- 农历 -->
@@ -38,14 +40,27 @@
<text class="almanac-lunar-sub">{{dayInfo.lunarYearGanzhi}}年 {{dayInfo.lunarMonthGanzhi}}月 {{dayInfo.lunarDayGanzhi}}日 · 属{{dayInfo.zodiac}}</text>
</view>
<!-- 节日/节气 -->
<view class="almanac-fest" wx:if="{{dayInfo.lunarFestival || dayInfo.solarFestival || dayInfo.solarTerm}}">
<text class="badge badge-festival" wx:if="{{dayInfo.lunarFestival}}">{{dayInfo.lunarFestival}}</text>
<text class="badge badge-festival" wx:if="{{dayInfo.solarFestival}}">{{dayInfo.solarFestival}}</text>
<text class="badge badge-outline" wx:if="{{dayInfo.solarTerm}}">{{dayInfo.solarTerm}}</text>
<!-- 节日/节气/佛历 -->
<view class="almanac-fest" wx:if="{{legalHoliday || dayInfo.lunarFestival || dayInfo.solarFestival || dayInfo.solarTerm}}">
<text class="badge badge-holiday" wx:if="{{legalHoliday}}">{{legalHoliday}}</text>
<text class="badge badge-festival" wx:if="{{dayInfo.lunarFestival && !legalHoliday}}">{{dayInfo.lunarFestival}}</text>
<text class="badge badge-festival" wx:if="{{dayInfo.solarFestival && !legalHoliday}}">{{dayInfo.solarFestival}}</text>
<text class="badge badge-term" wx:if="{{dayInfo.solarTerm}}">{{dayInfo.solarTerm}}{{termTime ? ' ' + termTime : ''}}</text>
</view>
<!-- 宜忌 -->
<!-- 佛历提醒 -->
<view class="buddhist-tip" wx:if="{{showBuddhist && nextBuddhist}}" catchtap="noop">
<text class="buddhist-text">🪷 距 {{nextBuddhist.name}} 还有 {{nextBuddhist.daysLeft}} 天({{nextBuddhist.solarDate}}</text>
</view>
<!-- 工作日/节假日提示 -->
<view class="day-type-tip {{legalHoliday ? 'tip-holiday' : (workday ? 'tip-workday' : 'tip-weekend')}}">
{{legalHoliday ? legalHoliday + ' · 放假' : (workday ? '工作日 · 上班' : '周末 · 休息')}}
</view>
</view>
<!-- 宜忌(点击放大) -->
<view class="card yiji-card" bindtap="openZoom" data-type="yiji">
<view class="yiji-row">
<view class="yiji-col">
<view class="yiji-title yi">宜</view>
@@ -61,13 +76,11 @@
</view>
</view>
</view>
<!-- 回今天 -->
<view class="back-today" wx:if="{{!dayInfo.isToday}}" bindtap="backToday">回到今天</view>
<view class="zoom-hint">点击放大 🔍</view>
</view>
<!-- 黄历详情 -->
<view class="card">
<!-- 黄历详情(点击放大) -->
<view class="card" bindtap="openZoom" data-type="detail">
<view class="grid grid-cols-2 gap-2">
<view class="p-2 bg-page rounded">
<text class="text-xs text-muted">值神</text>
@@ -94,41 +107,147 @@
<text class="text-base font-medium block">{{almanac.pengZu}}</text>
</view>
</view>
<view class="btn-primary mt-2 detail-btn" bindtap="goDetail">查看详情</view>
</view>
</view>
</scroll-view>
<!-- 月历视图 -->
<scroll-view class="page-body" scroll-y wx:if="{{viewMode === 'month'}}" style="padding-top: {{statusBarHeight + navBarHeight}}px;">
<view class="container">
<!-- 年月切换 -->
<view class="card flex justify-between items-center">
<view class="btn-ghost" bindtap="prevMonth"></view>
<view class="text-xl font-bold">{{year}}年{{month}}月</view>
<view class="btn-ghost" bindtap="nextMonth"></view>
<view class="zoom-hint">点击放大 🔍</view>
</view>
<!-- 星期头 -->
<view class="grid grid-cols-7 gap-1 mb-1">
<view class="text-center text-sm text-muted" wx:for="{{weekHeaders}}" wx:key="*this">{{item}}</view>
</view>
<!-- 日历网格 -->
<view class="grid grid-cols-7 gap-1">
<view
class="day-cell {{item.isToday ? 'today' : ''}} {{item.solarMonth !== month ? 'other-month' : ''}}"
wx:for="{{calendarDays}}"
wx:key="solarDate"
bindtap="selectDay"
data-date="{{item.solarDate}}"
>
<text class="day-number">{{item.solarDay}}</text>
<text class="day-lunar {{item.lunarFestival || item.solarFestival ? 'festival' : ''}}">
{{item.lunarFestival || item.solarFestival || item.lunarDayName}}
</text>
<view class="day-dot" wx:if="{{item.isTermDay}}"></view>
<!-- 节日速查 -->
<view class="card" bindtap="openZoom" data-type="fest">
<view class="section-title">节日速查</view>
<view class="fest-quick">
<view class="fest-quick-item" wx:for="{{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>
<!-- 回今天 -->
<view class="back-today" wx:if="{{!dayInfo.isToday}}" bindtap="backToday">回到今天</view>
</view>
</scroll-view>
<!-- 月历视图:swiper 上下/左右滚动换月 -->
<view class="page-body month-body" wx:if="{{viewMode === 'month'}}" style="padding-top: {{statusBarHeight + navBarHeight}}px;">
<view class="month-header">
<text class="month-title font-chinese">{{currentYear}}年{{currentMonth}}月</text>
<text class="month-hint">上下或左右滑动换月</text>
</view>
<view class="grid grid-cols-7 week-head">
<view class="text-center week-cell" wx:for="{{weekHeaders}}" wx:key="*this">{{item}}</view>
</view>
<swiper
class="month-swiper"
vertical="{{false}}"
circular="{{false}}"
current="{{monthIndex}}"
bindchange="onMonthSwiperChange"
duration="300"
>
<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>
</view>
</scroll-view>
</swiper-item>
</swiper>
</view>
<!-- 全屏放大弹窗(老年友好) -->
<view class="zoom-mask {{zoomVisible ? 'show' : ''}}" bindtap="closeZoom">
<view class="zoom-panel" catchtap="noop">
<view class="zoom-header">
<text class="zoom-title font-chinese">{{zoomTitle}}</text>
<view class="zoom-close" bindtap="closeZoom">✕</view>
</view>
<!-- 宜忌放大 -->
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'yiji' && zoomData}}">
<view class="zoom-yiji-block">
<view class="zoom-yiji-title yi">宜</view>
<view class="zoom-yiji-items">
<text class="zoom-yiji-item yi" wx:for="{{zoomData.recommends}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="zoom-yiji-block">
<view class="zoom-yiji-title ji">忌</view>
<view class="zoom-yiji-items">
<text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text>
</view>
</view>
</scroll-view>
<!-- 详情放大 -->
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'detail' && zoomData}}">
<view class="zoom-detail-row" wx:if="{{zoomData.legalHoliday}}">
<text class="zoom-detail-label">节假日</text>
<text class="zoom-detail-value holiday">{{zoomData.legalHoliday}}</text>
</view>
<view class="zoom-detail-row" wx:if="{{zoomData.termTime}}">
<text class="zoom-detail-label">节气</text>
<text class="zoom-detail-value">{{zoomData.termTime}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">农历</text>
<text class="zoom-detail-value">{{zoomData.lunar}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">干支</text>
<text class="zoom-detail-value">{{zoomData.ganzhi}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">值神</text>
<text class="zoom-detail-value">{{zoomData.duty}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">黄道</text>
<text class="zoom-detail-value">{{zoomData.twelveStar}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">二十八宿</text>
<text class="zoom-detail-value">{{zoomData.twentyEightStar}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">六曜</text>
<text class="zoom-detail-value">{{zoomData.sixStar}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">冲煞</text>
<text class="zoom-detail-value">{{zoomData.clash}}</text>
</view>
<view class="zoom-detail-row">
<text class="zoom-detail-label">彭祖百忌</text>
<text class="zoom-detail-value">{{zoomData.pengZu}}</text>
</view>
</scroll-view>
<!-- 节日放大 -->
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'fest' && zoomData}}">
<view class="zoom-fest-row" wx:for="{{zoomData.upcoming}}" wx:key="name" bindtap="goFestival" data-date="{{item.solarDate}}">
<text class="zoom-fest-name">{{item.name}}</text>
<text class="zoom-fest-date">{{item.solarDate}}</text>
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
</view>
</scroll-view>
</view>
</view>
+373 -41
View File
@@ -42,7 +42,6 @@
min-width: 96rpx;
}
/* 页面主体 */
.page-body {
box-sizing: border-box;
min-height: 100vh;
@@ -55,12 +54,30 @@
/* ===== 老黄历大日历卡片 ===== */
.almanac-card {
background: linear-gradient(160deg, #FFFFFF 0%, #FFF8EF 100%);
border: 1rpx solid #E8D5C4;
border-radius: 24rpx;
padding: 32rpx 24rpx;
padding: 32rpx 24rpx 24rpx;
margin-bottom: 16rpx;
position: relative;
border: 2rpx solid #E8D5C4;
background: #FFFFFF;
}
/* 节假日:喜庆红 */
.almanac-card.is-holiday {
background: linear-gradient(160deg, #FFF5F4 0%, #FFE8E4 100%);
border-color: #C41E3A;
}
/* 工作日:清新绿 */
.almanac-card.is-workday {
background: linear-gradient(160deg, #F4FBF5 0%, #E6F5E9 100%);
border-color: #4CAF50;
}
/* 周末:米黄 */
.almanac-card.is-weekend {
background: linear-gradient(160deg, #FFFBF0 0%, #FFF3DC 100%);
border-color: #E0A93E;
}
.almanac-head {
@@ -81,6 +98,11 @@
margin-left: 16rpx;
}
.head-tags {
display: flex;
gap: 12rpx;
}
.today-tag {
background: #C41E3A;
color: #FFFFFF;
@@ -89,37 +111,48 @@
border-radius: 999rpx;
}
.lunar-tag {
background: #E0A93E;
color: #FFFFFF;
font-size: 20rpx;
padding: 4rpx 20rpx;
border-radius: 999rpx;
}
/* 大日期 */
.almanac-day-row {
display: flex;
align-items: center;
justify-content: center;
margin: 16rpx 0 8rpx;
margin: 8rpx 0;
}
.almanac-day-num {
font-size: 160rpx;
font-size: 240rpx;
font-weight: 700;
color: #C41E3A;
line-height: 1.1;
min-width: 320rpx;
line-height: 1.05;
min-width: 400rpx;
text-align: center;
}
.num-holiday { color: #C41E3A; }
.num-workday { color: #2E7D32; }
.num-weekend { color: #C41E3A; }
.day-arrow {
font-size: 64rpx;
font-size: 72rpx;
color: #C9B8A6;
padding: 0 32rpx;
padding: 0 28rpx;
}
/* 农历 */
.almanac-lunar {
text-align: center;
margin-bottom: 16rpx;
margin-bottom: 12rpx;
}
.almanac-lunar-main {
font-size: 44rpx;
font-size: 48rpx;
font-weight: 600;
color: #1A1A1A;
display: block;
@@ -136,15 +169,72 @@
.almanac-fest {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 16rpx;
margin-bottom: 12rpx;
}
.badge-holiday {
background: #C41E3A;
color: #FFFFFF;
font-size: 24rpx;
padding: 6rpx 24rpx;
}
.badge-festival {
background: #C41E3A;
color: #FFFFFF;
}
.badge-term {
background: #2E7D32;
color: #FFFFFF;
}
/* 佛历提醒 */
.buddhist-tip {
text-align: center;
padding: 12rpx;
margin-bottom: 8rpx;
}
.buddhist-text {
font-size: 26rpx;
color: #B8860B;
}
/* 工作日/节假日提示 */
.day-type-tip {
text-align: center;
font-size: 28rpx;
font-weight: 600;
padding: 12rpx;
border-radius: 12rpx;
margin-top: 8rpx;
}
.tip-holiday {
color: #C41E3A;
background: rgba(196, 30, 58, 0.08);
}
.tip-workday {
color: #2E7D32;
background: rgba(46, 125, 50, 0.08);
}
.tip-weekend {
color: #C41E3A;
background: rgba(196, 30, 58, 0.08);
}
/* 宜忌 */
.yiji-card {
position: relative;
}
.yiji-row {
display: flex;
border-top: 1rpx solid #F0E6DA;
padding-top: 24rpx;
}
.yiji-col {
@@ -171,15 +261,8 @@
flex-shrink: 0;
}
.yiji-title.yi {
background: #C41E3A;
color: #FFFFFF;
}
.yiji-title.ji {
background: #546E7A;
color: #FFFFFF;
}
.yiji-title.yi { background: #C41E3A; color: #FFFFFF; }
.yiji-title.ji { background: #546E7A; color: #FFFFFF; }
.yiji-items {
display: flex;
@@ -189,22 +272,69 @@
}
.yiji-item {
font-size: 24rpx;
font-size: 26rpx;
padding: 4rpx 14rpx;
border-radius: 8rpx;
}
.yiji-item.yi {
background: #FEF2F2;
.yiji-item.yi { background: #FEF2F2; color: #C41E3A; }
.yiji-item.ji { background: #F1F5F9; color: #546E7A; }
.zoom-hint {
text-align: center;
font-size: 20rpx;
color: #C9B8A6;
margin-top: 12rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 16rpx;
color: #C41E3A;
}
.yiji-item.ji {
background: #F1F5F9;
color: #546E7A;
/* 节日速查 */
.fest-quick {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.fest-quick-item {
display: flex;
align-items: center;
padding: 16rpx;
background: #FFFBF5;
border-radius: 12rpx;
gap: 16rpx;
}
.fest-name {
font-size: 28rpx;
font-weight: 600;
min-width: 120rpx;
}
.fest-date {
font-size: 24rpx;
color: #9E8E7E;
flex: 1;
}
.fest-left {
font-size: 24rpx;
color: #C41E3A;
font-weight: 500;
}
.fest-left.is-today {
background: #C41E3A;
color: #FFFFFF;
padding: 2rpx 16rpx;
border-radius: 999rpx;
}
/* 回到今天 */
.back-today {
text-align: center;
margin-top: 20rpx;
@@ -213,37 +343,86 @@
padding: 12rpx;
}
.detail-btn {
/* ===== 月历视图 ===== */
.month-body {
display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box;
}
.month-header {
text-align: center;
padding: 16rpx 0 8rpx;
}
.month-title {
font-size: 40rpx;
font-weight: 700;
color: #C41E3A;
display: block;
}
.month-hint {
font-size: 20rpx;
color: #C9B8A6;
}
.week-head {
padding: 0 20rpx;
margin-bottom: 4rpx;
}
.week-cell {
font-size: 24rpx;
color: #9E8E7E;
padding: 8rpx 0;
}
.month-swiper {
flex: 1;
height: 100%;
}
.month-scroll {
height: 100%;
}
.month-grid {
padding: 0 20rpx;
gap: 8rpx;
}
/* ===== 月历 ===== */
.day-cell {
background: transparent;
}
.day-cell-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12rpx 4rpx;
padding: 16rpx 4rpx;
background: #FFFFFF;
border-radius: 12rpx;
min-height: 100rpx;
min-height: 110rpx;
}
.day-cell.today {
.day-cell-inner.today {
background: #C41E3A;
color: #FFFFFF;
}
.day-cell.today .day-lunar {
.day-cell-inner.today .day-lunar {
color: rgba(255, 255, 255, 0.8);
}
.day-cell.other-month {
opacity: 0.4;
.day-cell-inner.other-month {
opacity: 0.35;
}
.day-number {
font-size: 28rpx;
font-size: 30rpx;
font-weight: 500;
}
@@ -265,3 +444,156 @@
background: #C41E3A;
margin-top: 4rpx;
}
/* ===== 全屏放大弹窗 ===== */
.zoom-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
display: none;
}
.zoom-mask.show {
display: flex;
align-items: center;
justify-content: center;
}
.zoom-panel {
width: 92%;
max-height: 86%;
background: #FFFFFF;
border-radius: 24rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.zoom-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 28rpx;
background: #C41E3A;
}
.zoom-title {
color: #FFFFFF;
font-size: 36rpx;
font-weight: 600;
}
.zoom-close {
color: #FFFFFF;
font-size: 44rpx;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-body {
padding: 32rpx 28rpx;
max-height: 70vh;
}
/* 宜忌放大 */
.zoom-yiji-block {
margin-bottom: 40rpx;
}
.zoom-yiji-title {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 52rpx;
font-weight: 700;
margin-bottom: 24rpx;
}
.zoom-yiji-title.yi { background: #C41E3A; color: #FFFFFF; }
.zoom-yiji-title.ji { background: #546E7A; color: #FFFFFF; }
.zoom-yiji-items {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.zoom-yiji-item {
font-size: 44rpx;
padding: 16rpx 32rpx;
border-radius: 16rpx;
font-weight: 500;
}
.zoom-yiji-item.yi { background: #FEF2F2; color: #C41E3A; }
.zoom-yiji-item.ji { background: #F1F5F9; color: #546E7A; }
/* 详情放大 */
.zoom-detail-row {
display: flex;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0E6DA;
align-items: center;
}
.zoom-detail-label {
width: 200rpx;
font-size: 36rpx;
color: #9E8E7E;
flex-shrink: 0;
}
.zoom-detail-value {
font-size: 40rpx;
font-weight: 600;
color: #1A1A1A;
flex: 1;
}
.zoom-detail-value.holiday {
color: #C41E3A;
}
/* 节日放大 */
.zoom-fest-row {
display: flex;
align-items: center;
padding: 28rpx 0;
border-bottom: 1rpx solid #F0E6DA;
gap: 24rpx;
}
.zoom-fest-name {
font-size: 44rpx;
font-weight: 600;
min-width: 200rpx;
}
.zoom-fest-date {
font-size: 36rpx;
color: #9E8E7E;
flex: 1;
}
.zoom-fest-left {
font-size: 36rpx;
color: #C41E3A;
font-weight: 500;
}
.zoom-fest-left.is-today {
background: #C41E3A;
color: #FFFFFF;
padding: 4rpx 24rpx;
border-radius: 999rpx;
}
+17 -3
View File
@@ -4,6 +4,8 @@ Page({
weekStartDay: 0,
ziHourSect: 'lateZiNextDay',
solarTime: true,
showBuddhist: false,
highlightLunar: true,
bookmarks: []
},
@@ -21,13 +23,15 @@ Page({
this.setData({
weekStartDay: settings.weekStartDay || 0,
ziHourSect: settings.ziHourSect || 'lateZiNextDay',
solarTime: settings.solarTime !== false
solarTime: settings.solarTime !== false,
showBuddhist: settings.showBuddhist === true,
highlightLunar: settings.highlightLunar !== false
});
},
saveSettings() {
const { weekStartDay, ziHourSect, solarTime } = this.data;
wx.setStorageSync('lunar-settings', { weekStartDay, ziHourSect, solarTime });
const { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar } = this.data;
wx.setStorageSync('lunar-settings', { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar });
},
loadBookmarks() {
@@ -50,6 +54,16 @@ Page({
this.saveSettings();
},
onBuddhistChange(e) {
this.setData({ showBuddhist: e.detail.value });
this.saveSettings();
},
onHighlightLunarChange(e) {
this.setData({ highlightLunar: e.detail.value });
this.saveSettings();
},
removeBookmark(e) {
const date = e.currentTarget.dataset.date;
let bookmarks = this.data.bookmarks.filter(b => b.date !== date);
+10
View File
@@ -36,6 +36,16 @@
<text class="setting-label">真太阳时校正</text>
<switch checked="{{solarTime}}" bindchange="onSolarTimeChange" color="#C41E3A"/>
</view>
<view class="setting-item">
<text class="setting-label">显示佛历提醒</text>
<switch checked="{{showBuddhist}}" bindchange="onBuddhistChange" color="#C41E3A"/>
</view>
<view class="setting-item">
<text class="setting-label">初一十五高亮</text>
<switch checked="{{highlightLunar}}" bindchange="onHighlightLunarChange" color="#C41E3A"/>
</view>
</view>
<!-- 收藏管理 -->
+543 -86
View File
@@ -1,61 +1,556 @@
const { request } = require('../../utils/request.js');
// 丝带颜色配置
const RIBBON_COLORS = {
free: ['#FF6B6B', '#FF8E8E', '#FFB6C1', '#FFC0CB'],
paid: ['#FFD700', '#FFA500', '#FF8C00', '#DAA520'],
};
Page({
data: {
tree: null,
trees: [],
currentTreeIndex: 0,
wishes: [],
loading: true,
canvasWidth: 375,
canvasHeight: 600,
canvasHeight: 667,
showCreateModal: false,
wishContent: '',
wishType: 'free', // free: 免费, paid: 付费
wishType: 'free',
maxFreeLength: 20,
maxPaidLength: 100,
products: [],
selectedProduct: null,
// 弹幕相关
danmakuList: [],
// 传感器状态
gyroEnabled: false,
micEnabled: false,
windLevel: 0, // 0-1 风力等级
},
onLoad() {
this.loadWishTree();
this.initCanvas();
this.loadTrees();
this.loadProducts();
this.initSensors();
this.startAnimation();
},
onUnload() {
this.stopAnimation();
this.stopSensors();
},
onPullDownRefresh() {
this.loadWishTree().then(() => {
this.loadTrees().then(() => {
wx.stopPullDownRefresh();
});
},
// 加载许愿树数据
async loadWishTree() {
// 初始化画布
initCanvas() {
const systemInfo = wx.getSystemInfoSync();
this.setData({
canvasWidth: systemInfo.windowWidth,
canvasHeight: systemInfo.windowHeight,
});
},
// 初始化传感器
initSensors() {
// 陀螺仪
wx.startGyroscope({
interval: 'game',
success: () => {
this.setData({ gyroEnabled: true });
wx.onGyroscopeChange((res) => {
// 根据陀螺仪数据计算风力
const wind = Math.min(1, Math.abs(res.x) * 0.5 + Math.abs(res.y) * 0.3);
this.setData({ windLevel: wind });
});
},
fail: () => {
console.log('陀螺仪不可用');
},
});
// 麦克风(需要用户授权)
this.recorderManager = wx.getRecorderManager();
this.recorderManager.onFrameRecorded((res) => {
// 分析音量,模拟风力
const frameBuffer = res.frameBuffer;
if (frameBuffer) {
const volume = this.analyzeVolume(frameBuffer);
this.setData({ windLevel: Math.min(1, volume * 2) });
}
});
},
// 分析音量
analyzeVolume(buffer) {
// 简单的音量分析
const data = new Int16Array(buffer);
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += Math.abs(data[i]);
}
return sum / data.length / 32768;
},
// 开启麦克风
enableMicrophone() {
wx.authorize({
scope: 'scope.record',
success: () => {
this.recorderManager.start({
duration: 600000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'PCM',
frameSize: 50,
});
this.setData({ micEnabled: true });
wx.showToast({ title: '吹气让丝带飘动', icon: 'none' });
},
fail: () => {
wx.showToast({ title: '需要麦克风权限', icon: 'none' });
},
});
},
// 停止传感器
stopSensors() {
wx.stopGyroscope();
if (this.recorderManager) {
this.recorderManager.stop();
}
},
// 加载许愿树列表
async loadTrees() {
try {
const res = await request({
url: '/api/wish/tree',
url: '/api/wish/trees',
method: 'GET',
});
if (res.code === 0) {
this.setData({
tree: res.data.tree,
wishes: res.data.wishes || [],
trees: res.data.list || [],
loading: false,
});
this.drawTree();
this.loadWishes();
}
} catch (err) {
console.error('加载许愿树失败:', err);
this.setData({ loading: false });
// 使用本地模拟数据
// 使用模拟数据
this.setData({
tree: { id: 1, name: '祈福许愿树', maxWishes: 100 },
wishes: this.getMockWishes(),
trees: [
{ id: 1, name: '祈福树', type: 'pine', maxWishes: 100 },
{ id: 2, name: '姻缘树', type: 'sakura', maxWishes: 50 },
{ id: 3, name: '事业树', type: 'bamboo', maxWishes: 80 },
],
loading: false,
});
this.drawTree();
this.loadWishes();
}
},
// 加载当前树的许愿
async loadWishes() {
const { trees, currentTreeIndex } = this.data;
if (!trees.length) return;
const tree = trees[currentTreeIndex];
try {
const res = await request({
url: `/api/wish/tree/${tree.id}/wishes`,
method: 'GET',
});
if (res.code === 0) {
this.setData({ wishes: res.data.list || [] });
this.initRibbons();
this.initDanmaku();
}
} catch (err) {
console.error('加载许愿失败:', err);
// 使用模拟数据
this.setData({
wishes: this.getMockWishes(),
});
this.initRibbons();
this.initDanmaku();
}
},
// 模拟许愿数据
getMockWishes() {
return [
{ id: 1, content: '愿家人平安健康', type: 'paid', createdAt: Date.now() - 86400000 },
{ id: 2, content: '事业顺利,步步高升', type: 'free', createdAt: Date.now() - 43200000 },
{ id: 3, content: '心想事成,万事如意', type: 'paid', createdAt: Date.now() - 21600000 },
{ id: 4, content: '考试通过,金榜题名', type: 'free', createdAt: Date.now() - 10800000 },
{ id: 5, content: '财源广进,富贵吉祥', type: 'paid', createdAt: Date.now() - 3600000 },
{ id: 6, content: '身体健康,长命百岁', type: 'free', createdAt: Date.now() - 1800000 },
{ id: 7, content: '爱情甜蜜,白头偕老', type: 'paid', createdAt: Date.now() - 900000 },
{ id: 8, content: '出入平安,一帆风顺', type: 'free', createdAt: Date.now() - 300000 },
];
},
// 初始化丝带
initRibbons() {
const { wishes, canvasWidth, canvasHeight } = this.data;
const tree = this.getCurrentTree();
// 根据树的类型计算丝带挂载点
const branches = this.getTreeBranches(tree.type, canvasWidth, canvasHeight);
this.ribbons = wishes.map((wish, index) => {
const branch = branches[index % branches.length];
const colors = RIBBON_COLORS[wish.type] || RIBBON_COLORS.free;
return {
id: wish.id,
wish: wish,
x: branch.x,
y: branch.y,
length: 60 + Math.random() * 40,
color: colors[Math.floor(Math.random() * colors.length)],
angle: Math.random() * Math.PI * 2,
swingPhase: Math.random() * Math.PI * 2,
swingSpeed: 0.02 + Math.random() * 0.02,
windEffect: 0.5 + Math.random() * 0.5,
};
});
},
// 获取树枝位置
getTreeBranches(treeType, width, height) {
const centerX = width / 2;
const baseY = height * 0.75;
// 根据树类型返回不同的树枝配置
const configs = {
pine: [
{ x: centerX - 80, y: baseY - 200 },
{ x: centerX + 60, y: baseY - 220 },
{ x: centerX - 40, y: baseY - 280 },
{ x: centerX + 90, y: baseY - 260 },
{ x: centerX, y: baseY - 320 },
{ x: centerX - 100, y: baseY - 150 },
{ x: centerX + 100, y: baseY - 180 },
],
sakura: [
{ x: centerX - 70, y: baseY - 180 },
{ x: centerX + 80, y: baseY - 200 },
{ x: centerX - 30, y: baseY - 250 },
{ x: centerX + 50, y: baseY - 280 },
{ x: centerX, y: baseY - 220 },
],
bamboo: [
{ x: centerX - 60, y: baseY - 150 },
{ x: centerX + 60, y: baseY - 180 },
{ x: centerX - 30, y: baseY - 220 },
{ x: centerX + 30, y: baseY - 250 },
],
};
return configs[treeType] || configs.pine;
},
// 初始化弹幕
initDanmaku() {
const { wishes, canvasWidth } = this.data;
this.danmaku = wishes.map((wish, index) => ({
id: wish.id,
content: wish.content,
type: wish.type,
x: canvasWidth + Math.random() * 200,
y: 100 + (index % 5) * 60,
speed: 0.5 + Math.random() * 0.5,
opacity: 0.7 + Math.random() * 0.3,
}));
},
// 开始动画循环
startAnimation() {
const that = this;
this.animationTimer = setInterval(() => {
that.draw();
that.updateDanmaku();
}, 1000 / 60); // 60fps
},
// 停止动画
stopAnimation() {
if (this.animationTimer) {
clearInterval(this.animationTimer);
}
},
// 绘制场景
draw() {
const ctx = wx.createCanvasContext('wishTree', this);
const { canvasWidth, canvasHeight, windLevel } = this.data;
const tree = this.getCurrentTree();
// 清空画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 绘制天空
this.drawSky(ctx, canvasWidth, canvasHeight);
// 绘制云朵
this.drawClouds(ctx, canvasWidth, canvasHeight);
// 绘制地面
this.drawGround(ctx, canvasWidth, canvasHeight);
// 绘制大树
this.drawTree(ctx, tree, canvasWidth, canvasHeight);
// 绘制丝带
this.drawRibbons(ctx, windLevel);
// 绘制弹幕
this.drawDanmaku(ctx);
ctx.draw();
},
// 绘制天空
drawSky(ctx, width, height) {
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(0.5, '#16213e');
gradient.addColorStop(1, '#0f3460');
ctx.setFillStyle(gradient);
ctx.fillRect(0, 0, width, height);
// 绘制星星
ctx.setFillStyle('#ffffff');
for (let i = 0; i < 50; i++) {
const x = Math.random() * width;
const y = Math.random() * height * 0.6;
const size = Math.random() * 2;
const opacity = 0.3 + Math.random() * 0.7;
ctx.setGlobalAlpha(opacity);
ctx.fillRect(x, y, size, size);
}
ctx.setGlobalAlpha(1);
},
// 绘制云朵
drawClouds(ctx, width, height) {
const time = Date.now() / 1000;
ctx.setFillStyle('rgba(255, 255, 255, 0.1)');
for (let i = 0; i < 3; i++) {
const x = ((time * 10 + i * 200) % (width + 200)) - 100;
const y = 50 + i * 80;
ctx.beginPath();
ctx.arc(x, y, 30, 0, Math.PI * 2);
ctx.arc(x + 25, y - 10, 25, 0, Math.PI * 2);
ctx.arc(x + 50, y, 30, 0, Math.PI * 2);
ctx.fill();
}
},
// 绘制地面
drawGround(ctx, width, height) {
const groundY = height * 0.75;
// 草地
const gradient = ctx.createLinearGradient(0, groundY, 0, height);
gradient.addColorStop(0, '#2d5016');
gradient.addColorStop(1, '#1a3009');
ctx.setFillStyle(gradient);
ctx.fillRect(0, groundY, width, height - groundY);
// 草丛
ctx.setStrokeStyle('#3a6b1f');
ctx.setLineWidth(2);
for (let i = 0; i < 20; i++) {
const x = (i * 37) % width;
const h = 10 + (i % 3) * 5;
ctx.beginPath();
ctx.moveTo(x, groundY);
ctx.quadraticCurveTo(x + 5, groundY - h, x + 10, groundY);
ctx.stroke();
}
},
// 绘制大树
drawTree(ctx, tree, width, height) {
const centerX = width / 2;
const baseY = height * 0.75;
// 树干
ctx.setFillStyle('#4a3728');
ctx.beginPath();
ctx.moveTo(centerX - 25, baseY);
ctx.lineTo(centerX - 15, baseY - 150);
ctx.lineTo(centerX + 15, baseY - 150);
ctx.lineTo(centerX + 25, baseY);
ctx.closePath();
ctx.fill();
// 树冠 - 多层
const layers = [
{ radius: 120, y: baseY - 180, color: '#1e4d2b' },
{ radius: 100, y: baseY - 220, color: '#2d6a3e' },
{ radius: 80, y: baseY - 260, color: '#3d8b4f' },
{ radius: 60, y: baseY - 300, color: '#4dac61' },
];
layers.forEach(layer => {
ctx.setFillStyle(layer.color);
ctx.beginPath();
ctx.arc(centerX, layer.y, layer.radius, 0, Math.PI * 2);
ctx.fill();
});
// 装饰果实
ctx.setFillStyle('#ffd700');
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const r = 60 + Math.random() * 40;
const x = centerX + Math.cos(angle) * r;
const y = baseY - 220 + Math.sin(angle) * r * 0.6;
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fill();
}
},
// 绘制丝带
drawRibbons(ctx, windLevel) {
if (!this.ribbons) return;
const time = Date.now() / 1000;
this.ribbons.forEach(ribbon => {
// 计算摆动
const baseSwing = Math.sin(time * ribbon.swingSpeed + ribbon.swingPhase) * 0.3;
const windSwing = Math.sin(time * 3 + ribbon.swingPhase) * windLevel * ribbon.windEffect;
const totalSwing = baseSwing + windSwing;
// 丝带分段绘制,实现飘动效果
const segments = 8;
const segmentLength = ribbon.length / segments;
ctx.setStrokeStyle(ribbon.color);
ctx.setLineWidth(4);
ctx.setLineCap('round');
let prevX = ribbon.x;
let prevY = ribbon.y;
for (let i = 1; i <= segments; i++) {
const t = i / segments;
const wave = Math.sin(time * 2 + ribbon.swingPhase + t * 3) * (5 + windLevel * 15) * t;
const x = ribbon.x + totalSwing * i * segmentLength * 0.3 + wave;
const y = ribbon.y + i * segmentLength;
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.stroke();
prevX = x;
prevY = y;
}
// 丝带末端装饰
ctx.setFillStyle(ribbon.color);
ctx.beginPath();
ctx.arc(prevX, prevY, 4, 0, Math.PI * 2);
ctx.fill();
});
},
// 绘制弹幕
drawDanmaku(ctx) {
if (!this.danmaku) return;
this.danmaku.forEach(item => {
// 弹幕背景 - 手动绘制圆角矩形
const padding = 10;
const textWidth = item.content.length * 14;
const rectX = item.x - padding;
const rectY = item.y - 15;
const rectW = textWidth + padding * 2;
const rectH = 30;
const radius = 15;
ctx.setFillStyle(item.type === 'paid' ? 'rgba(255, 215, 0, 0.8)' : 'rgba(255, 107, 107, 0.8)');
ctx.beginPath();
ctx.moveTo(rectX + radius, rectY);
ctx.lineTo(rectX + rectW - radius, rectY);
ctx.arc(rectX + rectW - radius, rectY + radius, radius, -Math.PI / 2, 0);
ctx.lineTo(rectX + rectW, rectY + rectH - radius);
ctx.arc(rectX + rectW - radius, rectY + rectH - radius, radius, 0, Math.PI / 2);
ctx.lineTo(rectX + radius, rectY + rectH);
ctx.arc(rectX + radius, rectY + rectH - radius, radius, Math.PI / 2, Math.PI);
ctx.lineTo(rectX, rectY + radius);
ctx.arc(rectX + radius, rectY + radius, radius, Math.PI, Math.PI * 1.5);
ctx.closePath();
ctx.fill();
// 弹幕文字
ctx.setFillStyle('#ffffff');
ctx.setFontSize(14);
ctx.setTextAlign('left');
ctx.setTextBaseline('middle');
ctx.fillText(item.content, item.x, item.y);
});
},
// 更新弹幕位置
updateDanmaku() {
const { canvasWidth } = this.data;
if (!this.danmaku) return;
this.danmaku.forEach(item => {
item.x -= item.speed;
if (item.x < -200) {
item.x = canvasWidth + Math.random() * 100;
}
});
},
// 获取当前树
getCurrentTree() {
const { trees, currentTreeIndex } = this.data;
return trees[currentTreeIndex] || { type: 'pine' };
},
// 切换树
switchTree(e) {
const direction = e.currentTarget.dataset.direction;
const { trees, currentTreeIndex } = this.data;
let newIndex = currentTreeIndex;
if (direction === 'prev') {
newIndex = (currentTreeIndex - 1 + trees.length) % trees.length;
} else {
newIndex = (currentTreeIndex + 1) % trees.length;
}
this.setData({ currentTreeIndex: newIndex });
this.loadWishes();
},
// 加载许愿商品
async loadProducts() {
try {
@@ -69,73 +564,16 @@ Page({
}
} catch (err) {
console.error('加载商品失败:', err);
// 使用默认商品
this.setData({
products: [
{ id: 1, name: '普通许愿条', price: 100, duration: 7, position: 0 },
{ id: 2, name: '精品许愿条', price: 500, duration: 30, position: 10 },
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90, position: 100 },
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
],
});
}
},
// 模拟许愿数据
getMockWishes() {
return [
{ id: 1, content: '愿家人平安健康', type: 'paid', position: 10, isRobot: false },
{ id: 2, content: '事业顺利', type: 'free', position: 5, isRobot: false },
{ id: 3, content: '心想事成', type: 'paid', position: 20, isRobot: true },
{ id: 4, content: '考试通过', type: 'free', position: 3, isRobot: false },
{ id: 5, content: '财源广进', type: 'paid', position: 15, isRobot: false },
];
},
// 绘制许愿树
drawTree() {
const ctx = wx.createCanvasContext('wishTree', this);
const { canvasWidth, canvasHeight, wishes } = this.data;
// 清空画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 绘制树干
ctx.setFillStyle('#8B4513');
ctx.fillRect(canvasWidth / 2 - 20, canvasHeight - 150, 40, 150);
// 绘制树冠(圆形)
ctx.setFillStyle('#228B22');
ctx.beginPath();
ctx.arc(canvasWidth / 2, canvasHeight - 200, 120, 0, 2 * Math.PI);
ctx.fill();
// 绘制许愿条
wishes.forEach((wish, index) => {
const angle = (index / wishes.length) * 2 * Math.PI;
const radius = 80 + Math.random() * 40;
const x = canvasWidth / 2 + radius * Math.cos(angle);
const y = canvasHeight - 200 + radius * Math.sin(angle);
// 根据类型设置颜色
if (wish.type === 'paid') {
ctx.setFillStyle('#FFD700'); // 金色 - 付费
} else {
ctx.setFillStyle('#FF6B6B'); // 红色 - 免费
}
// 绘制许愿条(小矩形)
ctx.fillRect(x - 15, y - 5, 30, 10);
// 绘制文字
ctx.setFillStyle('#FFFFFF');
ctx.setFontSize(8);
ctx.setTextAlign('center');
ctx.fillText(wish.content.substring(0, 4), x, y + 3);
});
ctx.draw();
},
// 打开许愿弹窗
openCreateModal() {
this.setData({ showCreateModal: true });
@@ -171,28 +609,25 @@ Page({
// 提交许愿
async submitWish() {
const { wishContent, wishType, selectedProduct } = this.data;
const { wishContent, wishType, selectedProduct, trees, currentTreeIndex } = this.data;
if (!wishContent.trim()) {
wx.showToast({ title: '请输入许愿内容', icon: 'none' });
return;
}
// 检查字数限制
const maxLength = wishType === 'free' ? this.data.maxFreeLength : this.data.maxPaidLength;
if (wishContent.length > maxLength) {
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
return;
}
// 付费许愿需要选择商品
if (wishType === 'paid' && !selectedProduct) {
wx.showToast({ title: '请选择许愿商品', icon: 'none' });
return;
}
try {
// 如果是付费许愿,先创建订单
if (wishType === 'paid') {
const orderRes = await request({
url: '/api/pay/create',
@@ -201,29 +636,28 @@ Page({
type: 'wish',
productId: selectedProduct.id,
content: wishContent,
treeId: trees[currentTreeIndex].id,
},
});
if (orderRes.code === 0) {
// 调起微信支付
await this.wxPay(orderRes.data);
}
} else {
// 免费许愿直接创建
const res = await request({
url: '/api/wish/create',
method: 'POST',
data: {
content: wishContent,
type: 'free',
treeId: this.data.tree.id,
treeId: trees[currentTreeIndex].id,
},
});
if (res.code === 0) {
wx.showToast({ title: '许愿成功', icon: 'success' });
this.closeCreateModal();
this.loadWishTree();
this.loadWishes();
}
}
} catch (err) {
@@ -244,7 +678,7 @@ Page({
success: () => {
wx.showToast({ title: '支付成功', icon: 'success' });
this.closeCreateModal();
this.loadWishTree();
this.loadWishes();
resolve();
},
fail: (err) => {
@@ -256,6 +690,28 @@ Page({
});
},
// 画布触摸事件
onCanvasTouch(e) {
const { x, y } = e.touches[0];
// 检测是否点击到丝带
if (this.ribbons) {
for (let ribbon of this.ribbons) {
const dx = x - ribbon.x;
const dy = y - ribbon.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 50) {
// 点击到丝带,显示详情
wx.navigateTo({
url: `/pages/wish-detail/wish-detail?id=${ribbon.id}`,
});
return;
}
}
}
},
// 查看许愿详情
viewWishDetail(e) {
const id = e.currentTarget.dataset.id;
@@ -266,8 +722,9 @@ Page({
// 分享
onShareAppMessage() {
const tree = this.getCurrentTree();
return {
title: '快来许愿树许下你的愿望吧!',
title: `快来${tree.name || '许愿树'}许下你的愿望吧!`,
path: '/pages/wish-tree/wish-tree',
};
},
+24 -32
View File
@@ -1,48 +1,40 @@
<view class="container">
<!-- 许愿树画布 -->
<view class="canvas-container">
<canvas
canvas-id="wishTree"
class="wish-tree-canvas"
style="width: {{canvasWidth}}px; height: {{canvasHeight}}px;"
></canvas>
<!-- 全屏画布 -->
<canvas
canvas-id="wishTree"
class="wish-tree-canvas"
style="width: {{canvasWidth}}px; height: {{canvasHeight}}px;"
bindtouchstart="onCanvasTouch"
></canvas>
<!-- 许愿条覆盖层 -->
<view class="wishes-overlay">
<view
wx:for="{{wishes}}"
wx:key="id"
class="wish-tag {{item.type}} {{item.isRobot ? 'robot' : ''}}"
style="left: {{item.x}}px; top: {{item.y}}px;"
bindtap="viewWishDetail"
data-id="{{item.id}}"
>
<text class="wish-text">{{item.content}}</text>
<view class="wish-badge" wx:if="{{item.type === 'paid'}}">VIP</view>
<!-- 顶部导航 -->
<view class="top-bar">
<view class="tree-switcher">
<view class="switch-btn" bindtap="switchTree" data-direction="prev">
<text class="switch-icon"></text>
</view>
<view class="tree-name">{{trees[currentTreeIndex].name || '许愿树'}}</view>
<view class="switch-btn" bindtap="switchTree" data-direction="next">
<text class="switch-icon"></text>
</view>
</view>
<!-- 传感器控制 -->
<view class="sensor-controls">
<view class="sensor-btn {{micEnabled ? 'active' : ''}}" bindtap="enableMicrophone">
<text class="sensor-icon">🎤</text>
</view>
</view>
</view>
<!-- 许愿按钮 -->
<view class="action-bar">
<button class="btn-primary btn-wish" bindtap="openCreateModal">
<button class="btn-wish" bindtap="openCreateModal">
<text class="icon">🙏</text>
<text>我要许愿</text>
</button>
</view>
<!-- 许愿说明 -->
<view class="tips-section">
<view class="tip-item">
<text class="tip-icon">🎋</text>
<text class="tip-text">免费许愿:20字以内,可能被覆盖</text>
</view>
<view class="tip-item">
<text class="tip-icon">✨</text>
<text class="tip-text">付费许愿:100字以内,优先展示,长期保留</text>
</view>
</view>
<!-- 许愿弹窗 -->
<view class="modal" wx:if="{{showCreateModal}}">
<view class="modal-mask" bindtap="closeCreateModal"></view>
+89 -92
View File
@@ -1,127 +1,121 @@
.container {
min-height: 100vh;
background: linear-gradient(180deg, #87CEEB 0%, #E0F6FF 100%);
padding-bottom: 120rpx;
}
/* 画布容器 */
.canvas-container {
position: relative;
width: 100%;
height: 800rpx;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #0f3460;
}
/* 全屏画布 */
.wish-tree-canvas {
width: 100%;
height: 100%;
}
/* 许愿条覆盖层 */
.wishes-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.wish-tag {
/* 顶部导航 */
.top-bar {
position: absolute;
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
color: #fff;
pointer-events: auto;
transform: translate(-50%, -50%);
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.2);
max-width: 200rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wish-tag.free {
background: linear-gradient(135deg, #FF6B6B, #FF8E8E);
}
.wish-tag.paid {
background: linear-gradient(135deg, #FFD700, #FFA500);
}
.wish-tag.robot {
border: 2rpx dashed #fff;
}
.wish-text {
font-size: 20rpx;
}
.wish-badge {
position: absolute;
top: -8rpx;
right: -8rpx;
background: #FF4500;
color: #fff;
font-size: 16rpx;
padding: 2rpx 8rpx;
border-radius: 10rpx;
}
/* 操作栏 */
.action-bar {
position: fixed;
bottom: 0;
top: 0;
left: 0;
right: 0;
padding: 20rpx 30rpx;
background: rgba(255,255,255,0.95);
box-shadow: 0 -2rpx 20rpx rgba(0,0,0,0.1);
padding: 80rpx 30rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
}
.tree-switcher {
display: flex;
align-items: center;
gap: 20rpx;
background: rgba(0, 0, 0, 0.3);
padding: 10rpx 20rpx;
border-radius: 40rpx;
backdrop-filter: blur(10px);
}
.switch-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
}
.switch-icon {
font-size: 40rpx;
color: #fff;
font-weight: bold;
}
.tree-name {
font-size: 32rpx;
color: #fff;
font-weight: bold;
min-width: 150rpx;
text-align: center;
}
/* 传感器控制 */
.sensor-controls {
display: flex;
gap: 16rpx;
}
.sensor-btn {
width: 70rpx;
height: 70rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
backdrop-filter: blur(10px);
transition: all 0.3s;
}
.sensor-btn.active {
background: rgba(255, 215, 0, 0.5);
box-shadow: 0 0 20rpx rgba(255, 215, 0, 0.5);
}
.sensor-icon {
font-size: 36rpx;
}
/* 许愿按钮 */
.action-bar {
position: absolute;
bottom: 60rpx;
left: 50%;
transform: translateX(-50%);
z-index: 100;
}
.btn-wish {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
gap: 12rpx;
background: linear-gradient(135deg, #C41E3A, #E74C3C);
color: #fff;
font-size: 32rpx;
padding: 24rpx;
padding: 24rpx 60rpx;
border-radius: 50rpx;
border: none;
box-shadow: 0 8rpx 30rpx rgba(196, 30, 58, 0.4);
}
.btn-wish .icon {
font-size: 36rpx;
}
/* 提示区域 */
.tips-section {
padding: 30rpx;
margin-top: 20rpx;
}
.tip-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx;
background: rgba(255,255,255,0.8);
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.tip-icon {
font-size: 36rpx;
}
.tip-text {
font-size: 26rpx;
color: #666;
}
/* 弹窗 */
.modal {
position: fixed;
@@ -138,7 +132,8 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(5px);
}
.modal-content {
@@ -191,6 +186,7 @@
border: 2rpx solid #eee;
border-radius: 16rpx;
text-align: center;
transition: all 0.3s;
}
.type-item.active {
@@ -254,6 +250,7 @@
padding: 24rpx;
border: 2rpx solid #eee;
border-radius: 16rpx;
transition: all 0.3s;
}
.product-item.active {
+52
View File
@@ -101,6 +101,54 @@ const BUDDHIST_FESTIVALS = {
'11-17': '阿弥陀佛圣诞', '12-8': '释迦牟尼佛成道日(腊八)', '12-29': '华严菩萨圣诞'
};
/** 法定节假日(公历) */
const LEGAL_HOLIDAYS_SOLAR = {
'1-1': '元旦', '5-1': '劳动节', '10-1': '国庆节'
};
/** 法定节假日(农历) */
const LEGAL_HOLIDAYS_LUNAR = {
'1-1': '春节', '5-5': '端午节', '8-15': '中秋节', '12-30': '除夕'
};
/** 二十四节气大致时刻(用于显示,小时:分钟) */
const SOLAR_TERM_TIMES = {
'立春': '04:01', '雨水': '23:51', '惊蛰': '22:07', '春分': '23:06',
'清明': '03:20', '谷雨': '10:24', '立夏': '20:26', '小满': '09:23',
'芒种': '00:26', '夏至': '17:14', '小暑': '10:38', '大暑': '04:07',
'立秋': '20:29', '处暑': '11:16', '白露': '23:32', '秋分': '09:04',
'寒露': '15:22', '霜降': '18:36', '立冬': '18:45', '小雪': '16:20',
'大雪': '11:46', '冬至': '05:48', '小寒': '23:05', '大寒': '16:30'
};
/** 常用节日速查(名称 → 类型+月日) */
const FESTIVAL_QUICK_SEARCH = [
{ name: '春节', type: 'lunar', date: '1-1' },
{ name: '元宵节', type: 'lunar', date: '1-15' },
{ name: '龙抬头', type: 'lunar', date: '2-2' },
{ name: '端午节', type: 'lunar', date: '5-5' },
{ name: '七夕', type: 'lunar', date: '7-7' },
{ name: '中元节', type: 'lunar', date: '7-15' },
{ name: '中秋节', type: 'lunar', date: '8-15' },
{ name: '重阳节', type: 'lunar', date: '9-9' },
{ name: '腊八节', type: 'lunar', date: '12-8' },
{ name: '小年', type: 'lunar', date: '12-23' },
{ name: '除夕', type: 'lunar', date: '12-30' },
{ name: '元旦', type: 'solar', date: '1-1' },
{ name: '情人节', type: 'solar', date: '2-14' },
{ name: '妇女节', type: 'solar', date: '3-8' },
{ name: '植树节', type: 'solar', date: '3-12' },
{ name: '劳动节', type: 'solar', date: '5-1' },
{ name: '青年节', type: 'solar', date: '5-4' },
{ name: '儿童节', type: 'solar', date: '6-1' },
{ name: '建党节', type: 'solar', date: '7-1' },
{ name: '建军节', type: 'solar', date: '8-1' },
{ name: '教师节', type: 'solar', date: '9-10' },
{ name: '国庆节', type: 'solar', date: '10-1' },
{ name: '平安夜', type: 'solar', date: '12-24' },
{ name: '圣诞节', type: 'solar', date: '12-25' }
];
/** 纳音表 */
const NAYIN_TABLE = [
'海中金', '炉中火', '大林木', '路旁土', '剑锋金', '山头火',
@@ -370,6 +418,10 @@ module.exports = {
SOLAR_FESTIVALS,
LUNAR_FESTIVALS,
BUDDHIST_FESTIVALS,
LEGAL_HOLIDAYS_SOLAR,
LEGAL_HOLIDAYS_LUNAR,
SOLAR_TERM_TIMES,
FESTIVAL_QUICK_SEARCH,
NAYIN_TABLE,
HOUR_NAMES,
HOUR_RANGES,
+195 -83
View File
@@ -13,6 +13,10 @@ const {
SOLAR_FESTIVALS,
LUNAR_FESTIVALS,
BUDDHIST_FESTIVALS,
LEGAL_HOLIDAYS_SOLAR,
LEGAL_HOLIDAYS_LUNAR,
SOLAR_TERM_TIMES,
FESTIVAL_QUICK_SEARCH,
NAYIN_TABLE,
HOUR_NAMES,
HOUR_RANGES,
@@ -84,124 +88,107 @@ function getLunarMonthDays(year, month) {
return (LUNAR_INFO[year - 1900] & (0x10000 >> month)) ? 30 : 29;
}
/** 公历转农历 */
/** 公历转农历(标准算法,基准 1900-01-31 = 农历1900年正月初一) */
function solarToLunar(year, month, day) {
if (year < 1900 || year > 2100) return null;
let offset = 0;
for (let y = 1900; y < year; y++) {
offset += getLunarYearDays(y);
// 与基准日 1900-01-31(农历1900年正月初一)的天数差(用 UTC 避免时区误差)
const baseDate = Date.UTC(1900, 0, 31);
const objDate = Date.UTC(year, month - 1, day);
let offset = Math.floor((objDate - baseDate) / 86400000);
let lunarYear = 1900;
let daysOfYear = 0;
// 确定农历年
for (; lunarYear < 2101 && offset > 0; lunarYear++) {
daysOfYear = getLunarYearDays(lunarYear);
if (offset < daysOfYear) break;
offset -= daysOfYear;
}
let leapMonth = getLeapMonth(year);
const leapMonth = getLeapMonth(lunarYear);
let isLeap = false;
let lunarMonth = 1;
let lunarDay = 1;
let daysInMonth = 0;
let daysOfMonth = 0;
// 计算从正月初一到目标日期的天数
let targetOffset = offset;
for (let m = 1; m < month; m++) {
targetOffset += getSolarMonthDays(year, m);
}
targetOffset += day - 1;
// 农历年剩余天数
let daysInLunarYear = getLunarYearDays(year);
let remaining = targetOffset - offset;
if (remaining < 0 || remaining >= daysInLunarYear) {
// 需要调整年份
if (remaining < 0) {
year--;
remaining += getLunarYearDays(year);
} else {
remaining -= daysInLunarYear;
year++;
}
leapMonth = getLeapMonth(year);
}
// 逐月计算
for (let m = 1; m <= 12; m++) {
daysInMonth = getLunarMonthDays(year, m);
if (remaining < daysInMonth) {
lunarMonth = m;
lunarDay = remaining + 1;
break;
}
remaining -= daysInMonth;
// 处理闰月
if (m === leapMonth && !isLeap) {
m--;
// 确定农历月日
for (; lunarMonth < 13 && offset > 0; lunarMonth++) {
// 闰月
if (leapMonth > 0 && lunarMonth === leapMonth + 1 && !isLeap) {
lunarMonth--;
isLeap = true;
daysInMonth = getLeapMonthDays(year);
if (remaining < daysInMonth) {
lunarMonth = m + 1;
lunarDay = remaining + 1;
isLeap = true;
break;
}
remaining -= daysInMonth;
daysOfMonth = getLeapMonthDays(lunarYear);
} else {
daysOfMonth = getLunarMonthDays(lunarYear, lunarMonth);
}
if (offset < daysOfMonth) break;
offset -= daysOfMonth;
// 解除闰月标记
if (isLeap && lunarMonth === leapMonth + 1) isLeap = false;
}
// offset 为 0 且恰好是闰月边界
if (offset === 0 && leapMonth > 0 && lunarMonth === leapMonth + 1) {
if (isLeap) {
isLeap = false;
} else {
isLeap = true;
lunarMonth--;
}
}
if (offset < 0) {
offset += daysOfMonth;
lunarMonth--;
}
return {
year,
year: lunarYear,
month: lunarMonth,
day: lunarDay,
isLeap: isLeap && lunarMonth === leapMonth
day: offset + 1,
isLeap
};
}
/** 农历转公历 */
/** 农历转公历(标准算法,基准 1900-01-31 = 农历1900年正月初一) */
function lunarToSolar(year, month, day, isLeap = false) {
if (year < 1900 || year > 2100) return null;
// 累计从 1900 年正月初一到目标农历日期的天数
let offset = 0;
for (let y = 1900; y < year; y++) {
offset += getLunarYearDays(y);
}
const leapMonth = getLeapMonth(year);
let days = 0;
// 累计当年 1 月到目标月前一月的 days
for (let m = 1; m < month; m++) {
days += getLunarMonthDays(year, m);
if (m === leapMonth && !isLeap) {
days += getLeapMonthDays(year);
offset += getLunarMonthDays(year, m);
// 经过闰月(在 m 月之后)
if (m === leapMonth) {
offset += getLeapMonthDays(year);
}
}
// 目标月本身是闰月:需先加上该月正常月天数
if (isLeap && month === leapMonth) {
days += getLunarMonthDays(year, month);
offset += getLunarMonthDays(year, month);
}
days += day - 1;
offset += days;
offset += day - 1;
// 从1900年1月31日(农历正月初一)开始计算
let solarYear = 1900;
let solarMonth = 1;
let solarDay = 31;
let remaining = offset;
while (remaining > 0) {
const daysInMonth = getSolarMonthDays(solarYear, solarMonth);
if (remaining < daysInMonth) {
solarDay = remaining + 1;
break;
}
remaining -= daysInMonth;
solarMonth++;
if (solarMonth > 12) {
solarMonth = 1;
solarYear++;
}
}
return { year: solarYear, month: solarMonth, day: solarDay };
// 基准日 1900-01-31 + offset
const baseMs = Date.UTC(1900, 0, 31);
const target = new Date(baseMs + offset * 86400000);
return {
year: target.getUTCFullYear(),
month: target.getUTCMonth() + 1,
day: target.getUTCDate()
};
}
/** 获取日期的干支 */
@@ -1109,6 +1096,124 @@ function getBuddhistFestival(lunarMonth, lunarDay) {
return BUDDHIST_FESTIVALS[`${lunarMonth}-${lunarDay}`] || null;
}
/** 判断是否为法定节假日 */
function getLegalHoliday(year, month, day) {
const solarKey = `${month}-${day}`;
if (LEGAL_HOLIDAYS_SOLAR[solarKey]) return LEGAL_HOLIDAYS_SOLAR[solarKey];
const lunar = solarToLunar(year, month, day);
if (lunar) {
const lunarKey = `${lunar.month}-${lunar.day}`;
if (LEGAL_HOLIDAYS_LUNAR[lunarKey]) return LEGAL_HOLIDAYS_LUNAR[lunarKey];
}
return null;
}
/** 计算某天是星期几(0=周日) */
function getWeekDay(year, month, day) {
return new Date(year, month - 1, day).getDay();
}
/** 判断是否为工作日(周一到周五且非法定节假日) */
function isWorkday(year, month, day) {
if (getLegalHoliday(year, month, day)) return false;
const wd = getWeekDay(year, month, day);
return wd >= 1 && wd <= 5;
}
/** 农历转公历(某年) */
function lunarFestivalToSolar(year, lunarMonth, lunarDay) {
// 除夕特殊:腊月最后一天
if (lunarMonth === 12 && lunarDay === 30) {
const daysInMonth = getLunarMonthDays(year, 12);
const leap = getLeapMonth(year);
if (leap === 12) {
return lunarToSolar(year, 12, daysInMonth, true);
}
return lunarToSolar(year, 12, daysInMonth, false);
}
return lunarToSolar(year, lunarMonth, lunarDay, false);
}
/** 计算某农历节日在公历 year 年的日期 */
function getFestivalSolarDate(year, fest) {
if (fest.type === 'solar') {
const [m, d] = fest.date.split('-').map(Number);
return { year, month: m, day: d };
}
const [lm, ld] = fest.date.split('-').map(Number);
// 农历节日可能落在上一公历年(如春节在1-2月)
// 先按当前公历年算
let solar = lunarFestivalToSolar(year, lm, ld);
if (!solar) return null;
return solar;
}
/** 计算两个日期相差天数(target - from */
function daysBetween(fromY, fromM, fromD, toY, toM, toD) {
const a = new Date(fromY, fromM - 1, fromD);
const b = new Date(toY, toM - 1, toD);
return Math.round((b - a) / (24 * 60 * 60 * 1000));
}
/** 获取即将到来的 N 个节日(含今天之后的) */
function getUpcomingFestivals(count = 6) {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const d = now.getDate();
const list = [];
for (const fest of FESTIVAL_QUICK_SEARCH) {
// 尝试今年和明年
for (const tryYear of [y, y + 1]) {
const solar = getFestivalSolarDate(tryYear, fest);
if (!solar) continue;
const diff = daysBetween(y, m, d, solar.year, solar.month, solar.day);
if (diff >= 0) {
list.push({
name: fest.name,
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
solarYear: solar.year,
solarMonth: solar.month,
solarDay: solar.day,
daysLeft: diff
});
break;
}
}
}
list.sort((a, b) => a.daysLeft - b.daysLeft);
return list.slice(0, count);
}
/** 获取下一个佛教节日及倒计时 */
function getNextBuddhistFestival() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const d = now.getDate();
const entries = Object.entries(BUDDHIST_FESTIVALS);
const list = [];
for (const [key, name] of entries) {
const [lm, ld] = key.split('-').map(Number);
for (const tryYear of [y, y + 1]) {
const solar = lunarFestivalToSolar(tryYear, lm, ld);
if (!solar) continue;
const diff = daysBetween(y, m, d, solar.year, solar.month, solar.day);
if (diff >= 0) {
list.push({ name, solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`, daysLeft: diff });
break;
}
}
}
list.sort((a, b) => a.daysLeft - b.daysLeft);
return list[0] || null;
}
/** 神煞分析 */
function analyzeShensha(bazi) {
const result = [];
@@ -1245,6 +1350,13 @@ module.exports = {
calculatePlumBlossom,
calculateBoneWeight,
getBuddhistFestival,
getLegalHoliday,
isWorkday,
getWeekDay,
getFestivalSolarDate,
getUpcomingFestivals,
getNextBuddhistFestival,
daysBetween,
analyzeShensha,
analyzeFortuneGanzhi,
getYearMonths,