v2.1.0 流程改造 + 农历准确性修复 + 双向同步 + 闰月支持
部署到群晖 / deploy (push) Successful in 44s

- Phase 1: 添加纪念日合并人物创建流程(方案 B)
- Phase 2: 农历提醒按 lunarMonth/Day 计算每年公历
- Phase 3: 人员数据同步到后端(新增 /api/person)
- Phase 4: 新设备启动从云端恢复数据
- Phase 5: 工具函数收敛 utils/format.js
- Phase 6: 同步失败入队 + 启动重试
- Phase 7: 闰月生日完整支持(含 isLeapMonth + UI 警示)
- 修复 lunarInfo 数据表错位(替换为权威源 jjonline/calendar.js)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuming
2026-06-02 05:51:17 +08:00
parent 06d22884b9
commit ddcfe3334e
16 changed files with 1001 additions and 167 deletions
+6 -1
View File
@@ -67,4 +67,9 @@ function anniversary(action, data) {
return request({ url: '/api/anniversary', data: { action, data } })
}
module.exports = { request, login, anniversary, BASE_URL }
// 人员操作
function person(action, data) {
return request({ url: '/api/person', data: { action, data } })
}
module.exports = { request, login, anniversary, person, BASE_URL }
+35
View File
@@ -0,0 +1,35 @@
/**
* 纪念日相关的展示格式化工具
* Why:原本散落在 index / person-detail / add-anniversary 里的转换逻辑,统一到这里避免多份维护
*/
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_TEXTS } = require('./constants')
function getTypeName(type, customTypeName) {
if (type === 'other' && customTypeName) return customTypeName
return TYPE_NAMES[type] || '纪念日'
}
function getTypeIcon(type) {
return TYPE_ICONS[type] || '📅'
}
function getImportanceText(importance) {
return IMPORTANCE_TEXTS[importance] || '一般'
}
// 距离 X 天的口语化展示
function formatDaysUntil(days) {
if (days === 0) return '今天'
if (days === 1) return '明天'
if (days < 7) return `${days}天后`
if (days < 30) return `还有${Math.floor(days / 7)}`
return `还有${Math.floor(days / 30)}个月`
}
module.exports = {
getTypeName,
getTypeIcon,
getImportanceText,
formatDaysUntil
}
+23 -21
View File
@@ -4,28 +4,30 @@
*/
// 农历数据:每条数据记录该年的月份大小和闰月信息
// 格式:高4位=闰月大小(0无闰/1闰大月), 中4位=闰月位置, 低12位=12个月大小(1大/0小)
// 格式:bit 16=闰月大小(1 大月 / 0 小月)bit 15-4=12 个月大小(1大/0小)bit 3-0=闰月位置(0 无闰)
// 数据源:github.com/jjonline/calendar.js(权威,经过修订)
const lunarInfo = [
0x04bd8,0x04ae0,0x0a570,0x054d5,0x0d260,0x0d950,0x16554,0x056a0,0x09ad0,0x055d2, // 1900-1909
0x04ae0,0x0a5b6,0x0a4d0,0x0d250,0x1d255,0x0b540,0x0d6a0,0x0ada2,0x095b0,0x14977, // 1910-1919
0x04970,0x0a4b0,0x0b4b5,0x06a50,0x06d40,0x1ab54,0x02b60,0x09570,0x052f2,0x04970, // 1920-1929
0x06566,0x0d4a0,0x0ea50,0x06e95,0x05ad0,0x02b60,0x186e3,0x092e0,0x1c8d7,0x0c950, // 1930-1939
0x0d4a0,0x1d8a6,0x0b550,0x056a0,0x1a5b4,0x025d0,0x092d0,0x0d2b2,0x0a950,0x0b557, // 1940-1949
0x06ca0,0x0b550,0x15355,0x04da0,0x0a5b0,0x14573,0x052b0,0x0a9a8,0x0e950,0x06aa0, // 1950-1959
0x0aea6,0x0ab50,0x04b60,0x0aae4,0x0a570,0x05260,0x0f263,0x0d950,0x05b57,0x056a0, // 1960-1969
0x096d0,0x04dd5,0x04ad0,0x0a4d0,0x0d4d4,0x0d250,0x0d558,0x0b540,0x0b6a0,0x195a6, // 1970-1979
0x095b0,0x049b0,0x0a974,0x0a4b0,0x0b27a,0x06a50,0x06d40,0x0af46,0x0ab60,0x09570, // 1980-1989
0x04af5,0x04970,0x064b0,0x074a3,0x0ea50,0x06aa0,0x0a6b6,0x056a0,0x02b60,0x09570, // 1990-1999
0x049b0,0x0a4b0,0x0aa50,0x1b255,0x06d40,0x0ad50,0x14b55,0x056a0,0x0a6d0,0x055d4, // 2000-2009
0x052d0,0x0a9b8,0x0a950,0x0b4a0,0x0b6a6,0x0ad50,0x055a0,0x0aba4,0x0a5b0,0x052b0, // 2010-2019
0x0b273,0x06930,0x07337,0x06aa0,0x0ad50,0x14b55,0x04b60,0x0a570,0x054e4,0x0d160, // 2020-2029
0x0e968,0x0d520,0x0daa0,0x16aa6,0x056d0,0x04ae0,0x0a9d4,0x0a2d0,0x0d150,0x0f252, // 2030-2039
0x0d520,0x0daa0,0x16aa6,0x056d0,0x04ae0,0x0a9d4,0x0a2d0,0x0d150,0x0f252,0x0d520, // 2040-2049
0x0b54f,0x0d6a0,0x0ada0,0x14955,0x056a0,0x0a6d0,0x0155b,0x025d0,0x092d0,0x0d954, // 2050-2059
0x0d4a0,0x0b550,0x0b4a9,0x04da0,0x0a5b0,0x15176,0x052b0,0x0a930,0x07954,0x06aa0, // 2060-2069
0x0ad50,0x05b52,0x04b60,0x0a6e6,0x0a4e0,0x0d260,0x0ea65,0x0d530,0x05aa0,0x076a3, // 2070-2079
0x096d0,0x04afb,0x04ad0,0x0a4d0,0x1d0b6,0x0d250,0x0d520,0x0dd45,0x0b5a0,0x056d0, // 2080-2089
0x055b2,0x049b0,0x0a577,0x0a4b0,0x0aa50,0x1b255,0x06d20,0x0ada0,0x14b63,0x09370 // 2090-2099
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520 // 2100
]
// 1900年1月31日是农历正月初一(庚子年)
+68 -9
View File
@@ -1,7 +1,17 @@
/**
* 本地存储管理工具(含缓存层)
* 本地存储管理工具(含缓存层 + 云端 fire-and-forget 同步
*/
const api = require('./api')
const sync = require('./sync')
// 异步同步人员到后端;失败自动入队,启动时 flush
function _syncPerson(action, data) {
const openid = wx.getStorageSync('openid')
if (!openid) return
sync.syncOrEnqueue({ kind: 'person', action, data })
}
// 内存缓存
const _cache = {
persons: null,
@@ -61,7 +71,9 @@ function addPerson(person) {
}
persons.push(newPerson)
const result = savePersons(persons)
return result.success ? newPerson : null
if (!result.success) return null
_syncPerson('add', newPerson)
return newPerson
}
/**
@@ -70,11 +82,23 @@ function addPerson(person) {
function updatePerson(id, updates) {
const persons = getPersons()
const index = persons.findIndex(p => p.id === id)
if (index !== -1) {
persons[index] = { ...persons[index], ...updates, updateTime: new Date().getTime() }
return savePersons(persons).success
}
return false
if (index === -1) return false
persons[index] = { ...persons[index], ...updates, updateTime: new Date().getTime() }
const ok = savePersons(persons).success
if (ok) _syncPerson('update', persons[index])
return ok
}
/**
* 按姓名查找已有人员;找不到则创建一个新的 person(仅 name),返回 person 对象
* Why:方案 B 流程合并——用户在「添加纪念日」页直接输入姓名,不再强制先建人
*/
function ensurePerson(name) {
const trimmed = (name || '').trim()
if (!trimmed) return null
const existing = getPersons().find(p => p.name === trimmed)
if (existing) return existing
return addPerson({ name: trimmed })
}
/**
@@ -82,7 +106,9 @@ function updatePerson(id, updates) {
*/
function deletePerson(id) {
const persons = getPersons()
return savePersons(persons.filter(p => p.id !== id)).success
const ok = savePersons(persons.filter(p => p.id !== id)).success
if (ok) _syncPerson('delete', { id })
return ok
}
/**
@@ -168,6 +194,8 @@ function deletePersonWithAnniversaries(personId) {
wx.setStorageSync('anniversaries', anniversaries)
_cache.persons = persons
_cache.anniversaries = anniversaries
// 后端 delete person 会级联删除该 personId 的所有 anniversaries(在事务里),无需单独再调
_syncPerson('delete', { id: personId })
return true
} catch (e) {
console.error('删除人员及纪念日失败', e)
@@ -238,6 +266,35 @@ function importData(data) {
}
}
/**
* 仅当本地为空时,从后端拉取所有人员和纪念日落地到本地
* Why:换设备/重装时,云端是唯一真相源,需要把数据拉回来恢复
*/
async function pullFromCloudIfEmpty() {
const hasLocal = getPersons().length > 0 || getAnniversaries().length > 0
if (hasLocal) return false
const openid = wx.getStorageSync('openid')
if (!openid) return false
try {
const [personsRes, annivRes] = await Promise.all([
api.person('get'),
api.anniversary('get')
])
if (personsRes && personsRes.success && Array.isArray(personsRes.data) && personsRes.data.length > 0) {
wx.setStorageSync('persons', personsRes.data)
_cache.persons = personsRes.data
}
if (annivRes && annivRes.success && Array.isArray(annivRes.data) && annivRes.data.length > 0) {
wx.setStorageSync('anniversaries', annivRes.data)
_cache.anniversaries = annivRes.data
}
return true
} catch (e) {
console.error('[pullFromCloud] 失败', e)
return false
}
}
/**
* 清空所有数据
*/
@@ -258,6 +315,7 @@ module.exports = {
getPersons,
getPersonById,
addPerson,
ensurePerson,
updatePerson,
deletePerson,
deletePersonWithAnniversaries,
@@ -273,5 +331,6 @@ module.exports = {
// 工具函数
exportData,
importData,
clearAllData
clearAllData,
pullFromCloudIfEmpty
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 同步管理:失败入队,启动时重试
* Why:自建后端、HTTPS 链路、定时任务任一环节抖动都会导致单次写入丢失。本地队列兜底,保证最终一致。
*/
const api = require('./api')
const QUEUE_KEY = 'pending_sync_queue'
function loadQueue() {
try { return wx.getStorageSync(QUEUE_KEY) || [] } catch (e) { return [] }
}
function saveQueue(q) {
try { wx.setStorageSync(QUEUE_KEY, q) } catch (e) {}
}
function enqueue(item) {
const q = loadQueue()
q.push({ ...item, ts: Date.now() })
saveQueue(q)
}
async function dispatch(item) {
if (item.kind === 'person') return api.person(item.action, item.data)
if (item.kind === 'anniversary') return api.anniversary(item.action, item.data)
throw new Error('unknown sync kind: ' + item.kind)
}
// 立即尝试同步;失败入队
async function syncOrEnqueue(item) {
try {
const res = await dispatch(item)
if (res && res.success === false) throw new Error(res.error || 'server returned failure')
return true
} catch (err) {
console.warn('[sync] 失败已入队:', item.kind, item.action, err.message || err)
enqueue(item)
return false
}
}
// 尝试 flush 整个队列,失败的留下下次再试
async function flush() {
const q = loadQueue()
if (q.length === 0) return { flushed: 0, remaining: 0 }
const remaining = []
let flushed = 0
for (const item of q) {
try {
const res = await dispatch(item)
if (res && res.success === false) throw new Error(res.error || 'server returned failure')
flushed++
} catch (err) {
remaining.push(item)
}
}
saveQueue(remaining)
if (flushed > 0) console.log(`[sync] 队列 flush 完成:成功 ${flushed},剩余 ${remaining.length}`)
return { flushed, remaining: remaining.length }
}
function getPendingCount() {
return loadQueue().length
}
module.exports = { syncOrEnqueue, flush, getPendingCount }