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
+241
View File
@@ -0,0 +1,241 @@
---
name: ""
description: 生日提醒小程序自建后端的完整运维 playbook:维护场景操作步骤、故障排查表、关键资源清单和踩坑记录
metadata:
node_type: memory
type: reference
originSessionId: b9f9c436-1656-4989-aa1d-6b9f009fd1f2
---
## 项目速查表
| 资源 | 值 |
|---|---|
| 小程序前端 | `/Users/gaotu/WebstormProjects/myself/生日提醒小程序` |
| 后端代码 | 同上目录下 `server/` |
| Gitea 仓库 | https://git.ymxixi.space/adminym/wxserver.git |
| 对外 HTTPS | https://wxserver.ymxixi.space |
| 群晖内网 | http://192.168.1.66:15002 |
| 小程序 AppID | `wxe72882e2072d141a` |
| 数据库文件 | 群晖 `/volume1/docker/apps/birthday-server/data/birthday.db` |
| Docker 容器名 | `birthday-server` |
| FRP 公网入口 | 47.238.152.174(阿里云香港) |
| frpc 子域名 | `wxserver`type=http,软路由 `192.168.1.119` 上 |
| NPM 反代配置 | NPM 容器 `/data/nginx/proxy_host/33.conf` |
| 订阅消息模板 ID | `6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw` |
## 链路图(一图流)
```
微信小程序 (envVersion 切换 BASE_URL)
↓ HTTPS 443
wxserver.ymxixi.space (DNS A 记录 → 47.238.152.174)
Nginx Proxy Manager (Docker 容器 nginx-proxy-manager-zh-app-1,监听 80/443/81)
↓ proxy_pass http://127.0.0.1:8080Host 透传
frps (vhost_http_port: 8080;公开 vhost_https_port: 4430 但本项目不用)
↓ frp 隧道(subdomain=wxserver
frpc (软路由 192.168.1.119UCI `/etc/config/frpc`)
↓ TCP 转发到 192.168.1.66:15002
群晖 Docker 容器 birthday-server (Node 20 + Express + SQLite + node-cron)
/volume1/docker/apps/birthday-server/data/birthday.db
```
## 关键文件清单
**前端**
- `app.js` — 启动初始化 + 调 /api/login 拿 openid,加缓存复用
- `utils/api.js` — 请求封装;**BASE_URL 自动切换**develop → localhost:3000trial/release → 线上 HTTPS
- `pages/add-anniversary/add-anniversary.js` — 添加纪念日时调 syncToCloud
**后端**
- `server/src/index.js` — Express 入口 + 4 个接口(/api/login、/api/anniversary、/api/reminder/run、/api/health
- `server/src/db.js` — SQLite 表结构(anniversaries / remind_logs
- `server/src/wx.js` — 微信 APIaccess_token 缓存、code2session、sendSubscribeMessage
- `server/src/reminder.js` — node-cron 定时任务 + 推送逻辑
**部署**
- `.gitea/workflows/deploy.yml` — Gitea Actions 自动构建并部署
- `server/Dockerfile` — Node 20 + apt 装 python3/make/g++ + npm install
- `server/docker-compose.yml` — 本地开发用,生产走 Gitea Actions 直接 docker run
**配置**
- `server/.env.example` — 配置模板(已入库)
- `server/.env` — 本地真实配置(已 gitignore)
- Gitea Secrets`WX_APPID` / `WX_APPSECRET` / `WX_MINIPROGRAM_STATE`(可选 `WX_TEMPLATE_ID` / `REMINDER_CRON`
## 维护场景 Playbook
### 场景 1:改后端代码(最常见)
```
# 在本地改 server/ 下文件
git add server/...
git commit -m "..."
git push origin master
```
Gitea Actions 会自动构建并部署(约 3-8 分钟)。验证:
```
curl https://wxserver.ymxixi.space/api/health
```
后端无需审核,push 即生效。**关键纪律**:新接口字段要保持向后兼容(默认值 / 可选字段),否则线上用户的旧版小程序会崩。
### 场景 2:迭代小程序前端(页面 / 样式 / 交互改动)
```
1. 本地改 pages/ utils/ app.* 等
2. 开发者工具点「编译」预览,自测
3. 右上角「上传」→ 版本号递增(如 2.0.1 → 2.0.2
- 改 bug:选「修订补丁」
- 加小功能:选「特性更新」
- 重大变更:选「版本升级」
4. mp.weixin.qq.com → 管理 → 版本管理 → 找到刚上传的「开发版本」
5. 两条路:
a) 点「体验版」→ 自己/体验成员扫码先试,没问题再回来走 b
b) 点「提交审核」→ 审核通过(1-2 天)→ 点「发布」正式上线
6. 用户重新打开小程序自动拉新代码(强制更新可能要 24h)
```
**注意**:微信对小程序代码强制审核,**没有"热替换跳过审核"的办法**。紧急修线上 bug 可用「加急审核」(账号每年 2 次额度)。
### 场景 3:前端 + 后端一起改
为避免线上旧前端遇到新后端接口出错,**严格按这个顺序**:
```
① 改后端 → git push → CI 部署 → 验证 /api/health
② 验证后端对旧版本前端仍兼容(关键,否则线上炸锅)
③ 再改前端 → 上传 → 审核 → 发布
```
后端无审核延迟,可以随时回滚;前端审核+发布有延迟。所以"后端先行 + 向后兼容"是铁律。
### 场景 4:改环境变量 / 凭据
1. 浏览器登 `https://git.ymxixi.space/adminym/wxserver/settings/actions/secrets`
2. 编辑对应密钥(如 `WX_APPSECRET`
3. 进 Actions 页面找到最近一次 workflow → 右上角 **Re-run** 重跑
4. **不要靠 push 触发**——光改 Secrets 不会触发新流水线
### 场景 5:从体验版上线到正式版
1. 开发者工具点上传 → 选「版本升级」(或对应 semver)→ 上传
2. mp.weixin.qq.com → 管理 → 版本管理 → 提交审核
3. 审核通过后点「发布」
4. **关键**:去 Gitea Secrets 把 `WX_MINIPROGRAM_STATE` 改成 `formal`re-run workflow
(不改的话推送跳转的是开发版小程序,体验差)
### 场景 6:拉新人加体验
1. mp.weixin.qq.com → 管理 → 成员管理 → 体验成员 → 添加(最多 15 人,填对方微信号)
2. 把体验版二维码图发给对方,让他扫码
### 场景 7:备份数据库
```
ssh 群晖
cp /volume1/docker/apps/birthday-server/data/birthday.db \
/volume1/backup/birthday-$(date +%F).db
```
或从 Mac 拉到本地:
```
scp 群晖:/volume1/docker/apps/birthday-server/data/birthday.db ~/Downloads/
```
### 场景 8:看后端日志
```
ssh 群晖
docker logs -f birthday-server # 实时
docker logs --tail 200 birthday-server # 最近 200 行
```
### 场景 9:本地起后端调试
```
cd server
cp .env.example .env
# 编辑 .env 填 AppID/AppSecret
docker compose up -d
docker logs -f birthday-server
```
开发者工具勾选「不校验合法域名」,BASE_URL 会自动走 localhost:3000。
### 场景 10:扩展新接口
1. `server/src/index.js` 加新的 `app.post('/api/...', ...)` 路由
2. 数据库变动改 `server/src/db.js`(注意 SQLite `CREATE TABLE IF NOT EXISTS` 不能增列,需手动 ALTER 或重建)
3. 前端 `utils/api.js` 加调用方法
4. push → 自动部署 → 开发者工具上传新版 → 设体验版
### 场景 11DB Schema 升级
SQLite 重启不会自动改表结构。如果加字段:
- 临时操作:进容器 `docker exec -it birthday-server sqlite3 /app/data/birthday.db``ALTER TABLE anniversaries ADD COLUMN xxx`
- 永久方案:在 `db.js` 启动逻辑里加迁移 SQL,参考 PRAGMA user_version 做版本号管理
### 场景 12:回滚到旧版本
Gitea Actions 每次 push 都会构建新镜像但 tag 都是 `:latest`,旧镜像在群晖 Docker 里残留但没标签。最稳的回滚:
```
git revert <提交哈希>
git push
```
让 CI 重新构建旧代码版本,比 `docker run 旧镜像 ID` 干净。
## 故障排查表
| 症状 | 可能原因 | 排查命令 / 处理 |
|---|---|---|
| `https://wxserver.ymxixi.space/api/health` 502 | NPM 反代目标配错 | 进 NPM Web UI 检查:Forward Scheme=httpIP=127.0.0.1Port=8080 |
| 502 但 NPM 配置看着对 | nginx 没传 Host header / frps 找不到 vhost | NPM 默认会传,自建 nginx 要 `proxy_set_header Host $host` |
| TLS 报 `unrecognized name` | nginx 上没有该 server_name | NPM 里新加 Proxy Host 或修改 server_name |
| frpc 改了配置但不生效 | 改的是 `/var/etc/frpc.ini`(临时) | 通过 LuCI / `/etc/config/frpc` 改 UCI 配置,再 `/etc/init.d/frpc restart` |
| Actions 卡「等待中」不跑 | Runner 离线 | Container Manager 重启 `gitea-runner` 容器 |
| 构建镜像超时 / 拉不下来 | docker.io 国际网络慢 | 重试,或群晖 Container Manager 加镜像加速 |
| 改了 .env 但容器没生效 | `docker compose restart` 不重读 .env | 用 `docker compose up -d --force-recreate` 或重建容器 |
| 部署成功但 `docker run` 启动 1s 失败 | bind mount 目录不存在 | 通过 `docker run --rm -v /宿主路径:/x <image> mkdir -p /x/...` 在宿主上建 |
| 微信小程序请求失败「不在合法域名列表」 | 服务器域名没配 / 改后没生效 | mp.weixin.qq.com → 开发管理 → 服务器域名添加并扫码确认 |
| 体验版能收推送但点击跳错版本 | `WX_MINIPROGRAM_STATE` 与发布状态不匹配 | developer/trial/formal 对应小程序版本,改 Secrets re-run |
| 用户看到旧数据 | 小程序本地 storage 共享 | wx.clearStorageSync() 或重装小程序 |
| code2session 报 `invalid appid` (40013) | AppID/AppSecret 不匹配 或 .env 没生效 | 容器内 `printenv WX_APPID` 检查;改 Secrets 需重建容器 |
| 订阅消息发送返回 errcode≠0 | 用户未授权 / 模板 ID 错 / 配额耗尽 | 查 docker logs;用户每次添加才有一次订阅机会 |
## 产品决策记录(避免反复纠结)
### 闰月生日的提醒策略(采用 A 方案)
农历闰月(如闰二月、闰六月)只在某些年份存在。当用户的农历生日落在闰月(例如「闰二月初一」),遇到当年没有该闰月的年份,按**对应普通月份的同日**提醒(即「二月初一」)。这是民间最常见的过法。
**数据字段**`anniversaries.isLeapMonth`0/1),保存时由 `solarToLunar(...).isLeap` 决定。
**算法实现**`lunar.lunarToSolar(year, month, day, isLeap)` 在 isLeap=true 但当年无对应闰月时,会自动按普通月份返回——所以代码上无需显式 fallback。
**UI 提示**:用户在添加纪念日页面勾选「农历」+ 选择公历日期后,会实时显示「对应农历:闰X月X日」并标黄警示,说明无闰月年按普通月份提醒。
如未来需要改为 B 方案(按下一个月初过)或 C 方案(跳过不提醒),改 `reminder.js``getThisYearDate` 即可,无需动数据。
## 已知"踩过的坑"(避免重蹈)
1. **`docker compose restart` 不会重读 `.env`** — 必须 `up -d --force-recreate`。光 restart 会保留旧环境变量。
2. **Gitea Runner 容器内 `mkdir`** 创建的是 runner 容器内的路径,不是群晖宿主路径。在宿主上建持久化目录要走 `docker run -v 宿主路径:/x <镜像> mkdir -p /x/子目录`,让 docker daemon 用宿主路径处理 bind mount。
3. **frp `type=https` 是 TLS 透传**,要求本地服务自己监听 HTTPS 并带证书。HTTP 后端用 `type=http`,配合 frps 的 `vhost_http_port`,由 frps 按 `Host` header 路由。
4. **NPM 反代默认配置容易写成「自己的公网 IP + frps 公开端口」**(比如 47.238.152.174:4430)。正确写法是 `127.0.0.1:8080`,让流量在本机经 frps vhost_http_port 进入隧道。
5. **frpc 配置 `/var/etc/frpc.ini` 是临时文件**,是从 `/etc/config/frpc`(UCI)启动时生成的。直接改临时文件下次重启就丢,必须改 UCI(LuCI 界面或 `uci set`)。
6. **微信小程序的「服务器域名」修改有次数限制**(个人小程序约 5 次/月),改前最好确认无误。
7. **微信小程序的开发版/体验版/正式版共享同一份本地 `wx.setStorageSync`**(同一台手机、同一 AppID)。看到"莫名其妙的旧数据"先怀疑这个。
8. **`miniprogram_state` 三个值**`developer` 只能推到开发版,`trial` 推体验版,`formal` 推正式版。配错就收不到。
9. **Docker build 阶段不会自动用宿主代理**。代理走 docker-compose `build.args``HTTP_PROXY`/`HTTPS_PROXY`(保留 build-arg,不会写入运行时 ENV)。容器内访问宿主用 `host.docker.internal`
10. **Gitea Actions 的 Secrets 改完不会自动触发流水线**——必须手动 re-run,或者推一个新 commit。
11. **农历 `lunarInfo` 数据表来源要可信**。早期版本数据有错位(多个年份的闰月信息错),导致 solarToLunar 算的春节日期跟实际差 1-2 个月。已替换为权威源 `github.com/jjonline/calendar.js` 的版本。如未来要扩展年份范围(>2100),务必同样从该源取。
12. **农历存字段必须含 `isLeapMonth`**。只存 `lunarMonth`/`lunarDay` 会丢闰月信息,闰月生日的人每年被错误提醒到普通月份。修复需同时改:数据库列、后端 FIELDS、前端 formData、`lunar.lunarToSolar` 第 4 参数透传。
## 与其他记忆的关系
- 群晖 CI/CD 通用规范:见 [[reference_synology_cicd_playbook]]
- 软路由(frpc 所在)基本信息:见 [[project_home_router]]
- Docker 镜像拉取代理优先级:见 [[feedback_docker_mirror]]
- 项目当前架构和接口设计:见 [[project_birthday]]
+21 -13
View File
@@ -1,4 +1,6 @@
const api = require('./utils/api')
const storage = require('./utils/storage')
const sync = require('./utils/sync')
App({
onLaunch() {
@@ -20,20 +22,26 @@ App({
// 获取 openid:调自建后端 /api/login
async getUserOpenId() {
// 已缓存就直接复用,避免每次启动都登录
const cached = wx.getStorageSync('openid')
if (cached) {
this.globalData.openid = cached
return
}
try {
const res = await api.login()
this.globalData.openid = res.openid
wx.setStorageSync('openid', res.openid)
console.log('获取openid成功:', res.openid)
} catch (err) {
console.error('获取openid失败:', err)
let openid = wx.getStorageSync('openid')
if (!openid) {
try {
const res = await api.login()
openid = res.openid
wx.setStorageSync('openid', openid)
console.log('获取openid成功:', openid)
} catch (err) {
console.error('获取openid失败:', err)
return
}
}
this.globalData.openid = openid
// 拿到 openid 后,本地无数据时从云端拉一次(新设备/重装恢复场景)
const pulled = await storage.pullFromCloudIfEmpty()
if (pulled) console.log('已从云端恢复数据')
// flush 之前同步失败的待重试队列
sync.flush()
},
globalData: {
+95 -43
View File
@@ -1,21 +1,24 @@
// add-anniversary.js
const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date')
const api = require('../../utils/api')
const sync = require('../../utils/sync')
const lunar = require('../../utils/lunar')
Page({
data: {
anniversaryId: null,
personId: null,
personList: [],
personIndex: 0,
selectedPerson: '',
inputName: '',
typeList: ['公历生日', '农历生日', '结婚纪念日', '订婚纪念日', '其他纪念日'],
typeIndex: 0,
showCustomType: false,
dateValue: '',
remindDaysList: ['提前3天', '提前7天', '提前14天', '提前30天', '自定义'],
remindDaysIndex: 0,
// UI 反馈:选了农历日期时展示对应农历文本 + 闰月警示
lunarText: '',
isLeapMonth: false,
formData: {
isLunar: false,
type: 'birthday',
@@ -26,6 +29,7 @@ Page({
lunarYear: '',
lunarMonth: '',
lunarDay: '',
isLeapMonth: false,
importance: 'low',
remindEnabled: true,
remindDays: 7,
@@ -34,18 +38,17 @@ Page({
},
onLoad(options) {
// 获取人员列表
// 获取人员列表用于快捷选择
const persons = storage.getPersons()
this.setData({ personList: persons })
if (options.personId) {
// 从人员详情页进入
const index = persons.findIndex(p => p.id === options.personId)
if (index !== -1) {
// 从人员详情页进入,预选关联人员
const person = persons.find(p => p.id === options.personId)
if (person) {
this.setData({
personIndex: index,
personId: options.personId,
selectedPerson: persons[index].name
personId: person.id,
inputName: person.name
})
}
}
@@ -76,16 +79,22 @@ Page({
'YYYY-MM-DD'
)
// 设置类型
const typeIndex = this.getTypeIndex(anniversary.type)
// 编辑时不允许改关联人员,姓名展示用 personName 或从 personList 查
const person = this.data.personList.find(p => p.id === anniversary.personId)
this.setData({
formData: anniversary,
dateValue: date,
typeIndex,
showCustomType: anniversary.type === 'other'
showCustomType: anniversary.type === 'other',
personId: anniversary.personId,
inputName: person ? person.name : (anniversary.personName || '')
})
// 农历日期:编辑时也要回显
if (anniversary.isLunar) this._refreshLunar()
wx.setNavigationBarTitle({ title: '编辑纪念日' })
}
},
@@ -105,18 +114,26 @@ Page({
},
/**
* 选择人员
* 姓名输入:用户打字时实时更新;点 chip 时也会触发
* 输入框值变了就清掉已绑定的 personId,提交时再按姓名查找/创建
*/
onPersonChange(e) {
const index = parseInt(e.detail.value)
const person = this.data.personList[index]
onNameInput(e) {
const name = e.detail.value
const matched = this.data.personList.find(p => p.name === name.trim())
this.setData({
personIndex: index,
personId: person.id,
selectedPerson: person.name
inputName: name,
personId: matched ? matched.id : null
})
},
/**
* 点已有人员快捷 chip
*/
onPickPerson(e) {
const { id, name } = e.currentTarget.dataset
this.setData({ personId: id, inputName: name })
},
/**
* 选择类型
*/
@@ -146,9 +163,8 @@ Page({
*/
onDateTypeChange(e) {
const isLunar = e.detail.value === 'lunar'
this.setData({
'formData.isLunar': isLunar
})
this.setData({ 'formData.isLunar': isLunar })
this._refreshLunar()
},
/**
@@ -164,6 +180,29 @@ Page({
'formData.solarMonth': parseInt(parts[1]),
'formData.solarDay': parseInt(parts[2])
})
this._refreshLunar()
},
/**
* 选了"农历"时,把当前公历日期反算成农历,写入 formData + 提示文案
* Why:闰月生日必须在 UI 上让用户确认这是不是他想要的农历日期
*/
_refreshLunar() {
const { formData } = this.data
if (!formData.isLunar || !formData.solarYear || !formData.solarMonth || !formData.solarDay) {
this.setData({ lunarText: '', isLeapMonth: false, 'formData.isLeapMonth': false })
return
}
const solarDate = new Date(formData.solarYear, formData.solarMonth - 1, formData.solarDay)
const ld = lunar.solarToLunar(solarDate)
this.setData({
lunarText: ld.lunarText,
isLeapMonth: ld.isLeap,
'formData.lunarYear': ld.year,
'formData.lunarMonth': ld.month,
'formData.lunarDay': ld.day,
'formData.isLeapMonth': ld.isLeap
})
},
/**
@@ -237,11 +276,13 @@ Page({
* 提交
*/
async onSubmit() {
const { formData, personId, anniversaryId, personList } = this.data
const { formData, anniversaryId, inputName } = this.data
let { personId } = this.data
// 验证关联人员
if (!personId) {
wx.showToast({ title: '请选择关联人员', icon: 'none' })
// 验证姓名
const name = (inputName || '').trim()
if (!name) {
wx.showToast({ title: '请输入姓名', icon: 'none' })
return
}
@@ -263,13 +304,30 @@ Page({
await this.requestSubscribe()
} catch (err) {
console.log('用户拒绝订阅消息')
// 继续保存,即使用户拒绝订阅
}
}
// 获取人员名称
const person = personList.find(p => p.id === personId)
const personName = person ? person.name : ''
// 新增模式且没绑定 personId → 按姓名查找或自动创建
if (!anniversaryId && !personId) {
const person = storage.ensurePerson(name)
if (!person) {
wx.showToast({ title: '保存失败', icon: 'none' })
return
}
personId = person.id
}
// 农历生日:兜底反算(onDateChange/_refreshLunar 通常已填好,这里防边界)
if (formData.isLunar) {
const solarDate = new Date(formData.solarYear, formData.solarMonth - 1, formData.solarDay)
const lunarDate = lunar.solarToLunar(solarDate)
formData.lunarYear = lunarDate.year
formData.lunarMonth = lunarDate.month
formData.lunarDay = lunarDate.day
formData.isLeapMonth = lunarDate.isLeap
}
const personName = name
if (anniversaryId) {
// 编辑模式
@@ -328,21 +386,15 @@ Page({
},
/**
* 同步到自建后端
* 同步纪念日到后端(失败自动入队,启动时 flush)
*/
async syncToCloud(id, data, action) {
try {
const openid = wx.getStorageSync('openid')
if (!openid) {
console.log('未获取到openid,跳过云端同步')
return
}
const res = await api.anniversary(action, { id, ...data })
console.log('云端同步成功:', res)
} catch (err) {
console.error('云端同步失败:', err)
// 不影响本地保存
syncToCloud(id, data, action) {
const openid = wx.getStorageSync('openid')
if (!openid) {
console.log('未获取到openid,跳过云端同步')
return
}
sync.syncOrEnqueue({ kind: 'anniversary', action, data: { id, ...data } })
}
})
+21 -9
View File
@@ -1,16 +1,20 @@
<!--add-anniversary.wxml-->
<view class="container">
<view class="form">
<!-- 关联人员 -->
<!-- 关联人员(姓名输入 + 已有人员快捷选择) -->
<view class="form-item">
<text class="label required">关联人员</text>
<picker mode="selector" range="{{personList}}" range-key="name" value="{{personIndex}}" bindchange="onPersonChange">
<view class="picker">
<text wx:if="{{selectedPerson}}" class="picker-text">{{selectedPerson}}</text>
<text wx:else class="picker-placeholder">请选择关联人员</text>
<text class="picker-arrow"></text>
</view>
</picker>
<text class="label required">为谁记录</text>
<input class="input" placeholder="输入姓名,例如:妈妈" value="{{inputName}}" bindinput="onNameInput" disabled="{{!!anniversaryId}}" />
<view wx:if="{{personList.length > 0 && !anniversaryId}}" class="chips">
<view
wx:for="{{personList}}"
wx:key="id"
class="chip {{item.id === personId ? 'chip-active' : ''}}"
data-id="{{item.id}}"
data-name="{{item.name}}"
bindtap="onPickPerson"
>{{item.name}}</view>
</view>
</view>
<!-- 纪念日类型 -->
@@ -49,6 +53,14 @@
<text class="picker-arrow"></text>
</view>
</picker>
<!-- 选了农历后展示对应农历日期,闰月给醒目提示 -->
<view wx:if="{{formData.isLunar && lunarText}}" class="lunar-hint {{isLeapMonth ? 'lunar-hint-warn' : ''}}">
<text>对应农历:{{lunarText}}</text>
<text wx:if="{{isLeapMonth}}" class="leap-tag">⚠ 闰月</text>
</view>
<view wx:if="{{formData.isLunar && isLeapMonth}}" class="lunar-warn-detail">
该日期属于闰月。无闰月的年份将按对应普通月份提醒(例如闰二月初一 → 二月初一)。
</view>
</view>
<!-- 重要程度 -->
+60 -1
View File
@@ -59,13 +59,72 @@
}
.input {
margin-top: 16rpx;
margin-top: 0;
background-color: #f5f5f5;
border-radius: 8rpx;
padding: 24rpx;
font-size: 28rpx;
}
.input[disabled] {
color: #999;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.chip {
font-size: 24rpx;
color: #555;
padding: 10rpx 22rpx;
background-color: #f5f5f5;
border-radius: 100rpx;
border: 2rpx solid transparent;
}
.chip-active {
color: #6366f1;
background-color: #eef0ff;
border-color: #6366f1;
}
.lunar-hint {
margin-top: 16rpx;
font-size: 26rpx;
color: #666;
display: flex;
align-items: center;
gap: 16rpx;
}
.lunar-hint-warn {
color: #d97706;
font-weight: 500;
}
.leap-tag {
font-size: 22rpx;
background-color: #fef3c7;
color: #b45309;
padding: 4rpx 14rpx;
border-radius: 100rpx;
}
.lunar-warn-detail {
margin-top: 12rpx;
padding: 14rpx 20rpx;
background-color: #fffbeb;
border-left: 4rpx solid #d97706;
border-radius: 4rpx;
font-size: 24rpx;
color: #92400e;
line-height: 1.6;
}
.radio-group {
display: flex;
flex-direction: column;
+6 -36
View File
@@ -1,7 +1,7 @@
// index.js
const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date')
const { TYPE_NAMES } = require('../../utils/constants')
const fmt = require('../../utils/format')
Page({
data: {
@@ -45,10 +45,10 @@ Page({
const next = upcoming[0]
nextAnniversary = {
type: next.type,
typeName: this.getTypeName(next.type, next.customTypeName),
typeName: fmt.getTypeName(next.type, next.customTypeName),
dateText: dateUtils.formatDate(next.date, 'MM月DD日'),
daysUntil: next.daysUntil,
daysUntilText: this.formatDaysUntil(next.daysUntil)
daysUntilText: fmt.formatDaysUntil(next.daysUntil)
}
}
}
@@ -77,25 +77,6 @@ Page({
}
},
/**
* 格式化剩余天数
*/
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)}个月`
},
/**
* 获取类型名称
*/
getTypeName(type, customName) {
if (type === 'other' && customName) return customName
return TYPE_NAMES[type] || '纪念日'
},
/**
* 搜索输入
*/
@@ -178,22 +159,11 @@ Page({
},
/**
* 点击添加按钮
* 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人)
*/
onAddTap() {
wx.showActionSheet({
itemList: ['添加人员', '添加纪念日'],
success: (res) => {
if (res.tapIndex === 0) {
wx.navigateTo({
url: '/pages/add-person/add-person'
})
} else if (res.tapIndex === 1) {
wx.navigateTo({
url: '/pages/add-anniversary/add-anniversary'
})
}
}
wx.navigateTo({
url: '/pages/add-anniversary/add-anniversary'
})
}
})
+5 -26
View File
@@ -1,7 +1,7 @@
// person-detail.js
const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date')
const { TYPE_NAMES, TYPE_ICONS, IMPORTANCE_TEXTS } = require('../../utils/constants')
const fmt = require('../../utils/format')
Page({
data: {
@@ -58,10 +58,10 @@ Page({
date,
dateText: dateUtils.formatDate(date, 'YYYY年MM月DD日'),
daysUntil,
daysUntilAbs: Math.abs(daysUntil), // 添加绝对值
typeIcon: this.getTypeIcon(a.type),
typeName: a.customTypeName || this.getTypeName(a.type),
importanceText: this.getImportanceText(a.importance)
daysUntilAbs: Math.abs(daysUntil),
typeIcon: fmt.getTypeIcon(a.type),
typeName: fmt.getTypeName(a.type, a.customTypeName),
importanceText: fmt.getImportanceText(a.importance)
}
})
@@ -71,27 +71,6 @@ Page({
this.setData({ anniversaries: formatted })
},
/**
* 获取类型图标
*/
getTypeIcon(type) {
return TYPE_ICONS[type] || '📅'
},
/**
* 获取类型名称
*/
getTypeName(type) {
return TYPE_NAMES[type] || '其他'
},
/**
* 获取重要程度文本
*/
getImportanceText(importance) {
return IMPORTANCE_TEXTS[importance] || '一般'
},
/**
* 编辑人员
*/
+30
View File
@@ -24,6 +24,10 @@ db.exec(`
solarYear INTEGER,
solarMonth INTEGER,
solarDay INTEGER,
lunarYear INTEGER,
lunarMonth INTEGER,
lunarDay INTEGER,
isLeapMonth INTEGER DEFAULT 0,
importance TEXT,
remindEnabled INTEGER DEFAULT 0,
remindDays INTEGER DEFAULT 0,
@@ -34,6 +38,18 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_anniv_openid ON anniversaries(openid);
CREATE INDEX IF NOT EXISTS idx_anniv_remind ON anniversaries(remindEnabled);
CREATE TABLE IF NOT EXISTS persons (
id TEXT PRIMARY KEY,
openid TEXT NOT NULL,
name TEXT NOT NULL,
nickname TEXT,
avatar TEXT,
remark TEXT,
createTime INTEGER,
updateTime INTEGER
);
CREATE INDEX IF NOT EXISTS idx_person_openid ON persons(openid);
CREATE TABLE IF NOT EXISTS remind_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anniversaryId TEXT,
@@ -48,4 +64,18 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate);
`)
// 旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,需要手动 ALTER
// 失败说明列已存在,安全忽略
function tryAddColumn(table, column, type) {
try {
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`).run()
} catch (e) {
if (!/duplicate column/i.test(e.message)) throw e
}
}
tryAddColumn('anniversaries', 'lunarYear', 'INTEGER')
tryAddColumn('anniversaries', 'lunarMonth', 'INTEGER')
tryAddColumn('anniversaries', 'lunarDay', 'INTEGER')
tryAddColumn('anniversaries', 'isLeapMonth', 'INTEGER DEFAULT 0')
module.exports = db
+90 -3
View File
@@ -26,6 +26,27 @@ app.post('/api/login', async (req, res) => {
}
})
// 人员操作(与 anniversary 同款 action 协议)
app.post('/api/person', (req, res) => {
try {
const openid = req.headers['x-openid'] || req.body.openid
if (!openid) return res.json({ success: false, error: '缺少 openid' })
const { action, data } = req.body
switch (action) {
case 'add': return res.json(addPerson(openid, data))
case 'update': return res.json(updatePerson(openid, data))
case 'delete': return res.json(deletePerson(openid, data.id))
case 'sync': return res.json(syncPersons(openid, data))
case 'get': return res.json(getPersons(openid))
default: return res.json({ success: false, error: '未知操作' })
}
} catch (err) {
console.error('人员操作失败:', err.message)
res.json({ success: false, error: err.message })
}
})
// 纪念日操作(兼容原云函数 syncAnniversary 的 action 协议)
app.post('/api/anniversary', (req, res) => {
try {
@@ -51,8 +72,10 @@ app.post('/api/anniversary', (req, res) => {
const FIELDS = [
'id', 'openid', 'personId', 'personName', 'type', 'customTypeName',
'isLunar', 'solarYear', 'solarMonth', 'solarDay', 'importance',
'remindEnabled', 'remindDays', 'remark', 'createTime', 'updateTime'
'isLunar', 'solarYear', 'solarMonth', 'solarDay',
'lunarYear', 'lunarMonth', 'lunarDay', 'isLeapMonth',
'importance', 'remindEnabled', 'remindDays', 'remark',
'createTime', 'updateTime'
]
function normalize(row) {
@@ -60,7 +83,8 @@ function normalize(row) {
return {
...row,
isLunar: !!row.isLunar,
remindEnabled: !!row.remindEnabled
remindEnabled: !!row.remindEnabled,
isLeapMonth: !!row.isLeapMonth
}
}
@@ -77,6 +101,10 @@ function addAnniversary(openid, anniv) {
solarYear: anniv.solarYear ?? null,
solarMonth: anniv.solarMonth,
solarDay: anniv.solarDay,
lunarYear: anniv.lunarYear ?? null,
lunarMonth: anniv.lunarMonth ?? null,
lunarDay: anniv.lunarDay ?? null,
isLeapMonth: anniv.isLeapMonth ? 1 : 0,
importance: anniv.importance || 'normal',
remindEnabled: anniv.remindEnabled ? 1 : 0,
remindDays: anniv.remindDays ?? 0,
@@ -99,6 +127,7 @@ function updateAnniversary(openid, anniv) {
openid,
isLunar: ('isLunar' in anniv ? (anniv.isLunar ? 1 : 0) : existing.isLunar),
remindEnabled: ('remindEnabled' in anniv ? (anniv.remindEnabled ? 1 : 0) : existing.remindEnabled),
isLeapMonth: ('isLeapMonth' in anniv ? (anniv.isLeapMonth ? 1 : 0) : existing.isLeapMonth),
updateTime: Date.now()
}
const sets = FIELDS.filter(f => f !== 'id' && f !== 'openid' && f !== 'createTime')
@@ -126,6 +155,64 @@ function getAnniversaries(openid) {
return { success: true, data: rows.map(normalize) }
}
// ---- 人员 CRUD ----
const PERSON_FIELDS = ['id', 'openid', 'name', 'nickname', 'avatar', 'remark', 'createTime', 'updateTime']
function buildPersonRow(openid, person) {
const now = Date.now()
const id = person.id || ('id_' + now + '_' + Math.random().toString(36).slice(2, 11))
return {
id, openid,
name: person.name || '',
nickname: person.nickname || null,
avatar: person.avatar || null,
remark: person.remark || null,
createTime: person.createTime || now,
updateTime: now
}
}
function addPerson(openid, person) {
const row = buildPersonRow(openid, person)
const placeholders = PERSON_FIELDS.map(f => '@' + f).join(', ')
db.prepare(`INSERT OR REPLACE INTO persons (${PERSON_FIELDS.join(',')}) VALUES (${placeholders})`).run(row)
return { success: true, id: row.id }
}
function updatePerson(openid, person) {
const existing = db.prepare('SELECT * FROM persons WHERE id = ? AND openid = ?').get(person.id, openid)
if (!existing) return { success: false, error: '人员不存在' }
const merged = { ...existing, ...person, openid, updateTime: Date.now() }
const sets = PERSON_FIELDS.filter(f => f !== 'id' && f !== 'openid' && f !== 'createTime')
.map(f => `${f} = @${f}`).join(', ')
db.prepare(`UPDATE persons SET ${sets} WHERE id = @id AND openid = @openid`).run(merged)
return { success: true }
}
function deletePerson(openid, id) {
const tx = db.transaction(() => {
db.prepare('DELETE FROM persons WHERE id = ? AND openid = ?').run(id, openid)
db.prepare('DELETE FROM anniversaries WHERE personId = ? AND openid = ?').run(id, openid)
})
tx()
return { success: true }
}
function syncPersons(openid, list) {
const tx = db.transaction((items) => {
db.prepare('DELETE FROM persons WHERE openid = ?').run(openid)
for (const item of items) addPerson(openid, item)
})
tx(Array.isArray(list) ? list : [])
return { success: true, count: Array.isArray(list) ? list.length : 0 }
}
function getPersons(openid) {
const rows = db.prepare('SELECT * FROM persons WHERE openid = ? ORDER BY createTime DESC').all(openid)
return { success: true, data: rows }
}
// 手动触发提醒任务(便于调试,无需等到定点)
app.post('/api/reminder/run', async (req, res) => {
try {
+212
View File
@@ -0,0 +1,212 @@
/**
* 农历日期转换工具
* 基于寿星万年历算法(1900-2100年)
*/
// 农历数据:每条数据记录该年的月份大小和闰月信息
// 格式: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, 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日是农历正月初一(庚子年)
const BASE_DATE = new Date(1900, 0, 31)
const LUNAR_MONTHS = ['正','二','三','四','五','六','七','八','九','十','冬','腊']
const LUNAR_DAYS_TENS = ['初','十','廿','三']
const LUNAR_DAYS_UNITS = ['一','二','三','四','五','六','七','八','九','十']
/**
* 获取农历某年的总天数
*/
function _lunarYearDays(year) {
let total = 348
for (let i = 0x8000; i > 0x8; i >>= 1) {
total += (lunarInfo[year - 1900] & i) ? 1 : 0
}
return total + _leapDays(year)
}
/**
* 获取农历某年闰月的天数
*/
function _leapDays(year) {
if (_leapMonth(year)) {
return (lunarInfo[year - 1900] & 0x10000) ? 30 : 29
}
return 0
}
/**
* 获取农历某年的闰月月份,0表示无闰月
*/
function _leapMonth(year) {
return lunarInfo[year - 1900] & 0xf
}
/**
* 获取农历某年某月的天数
*/
function _monthDays(year, month) {
return (lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29
}
/**
* 公历转农历
* @param {Date} solarDate
* @returns {{ year, month, day, isLeap, lunarText }}
*/
function solarToLunar(solarDate) {
const date = new Date(solarDate.getFullYear(), solarDate.getMonth(), solarDate.getDate())
let offset = Math.round((date - BASE_DATE) / 86400000)
let lunarYear, lunarMonth, lunarDay
let isLeap = false
for (lunarYear = 1900; lunarYear < 2100 && offset > 0; lunarYear++) {
const days = _lunarYearDays(lunarYear)
offset -= days
}
if (offset < 0) {
offset += _lunarYearDays(--lunarYear)
}
const leapM = _leapMonth(lunarYear)
let isLeapYear = false
for (lunarMonth = 1; lunarMonth < 13 && offset > 0; lunarMonth++) {
if (leapM > 0 && lunarMonth === leapM + 1 && !isLeapYear) {
--lunarMonth
isLeapYear = true
const leapDays = _leapDays(lunarYear)
offset -= leapDays
} else {
offset -= _monthDays(lunarYear, lunarMonth)
}
if (isLeapYear && lunarMonth === leapM + 1) isLeapYear = false
}
if (offset === 0 && leapM > 0 && lunarMonth === leapM + 1) {
if (isLeapYear) {
isLeapYear = false
} else {
isLeapYear = true
--lunarMonth
}
}
if (offset < 0) {
offset += isLeapYear ? _leapDays(lunarYear) : _monthDays(lunarYear, lunarMonth)
if (isLeapYear) isLeapYear = false
else --lunarMonth
}
lunarDay = offset + 1
isLeap = isLeapYear
return {
year: lunarYear,
month: lunarMonth,
day: lunarDay,
isLeap,
lunarText: `${isLeap ? '闰' : ''}${getLunarMonthName(lunarMonth)}${getLunarDayName(lunarDay)}`
}
}
/**
* 农历转公历(指定年份)
* @param {Number} lunarYear
* @param {Number} lunarMonth
* @param {Number} lunarDay
* @param {Boolean} isLeap 是否闰月
* @returns {Date}
*/
function lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeap) {
let offset = 0
for (let y = 1900; y < lunarYear; y++) {
offset += _lunarYearDays(y)
}
const leapM = _leapMonth(lunarYear)
let hasLeap = false
for (let m = 1; m < lunarMonth; m++) {
if (leapM > 0 && m === leapM && !hasLeap) {
offset += _leapDays(lunarYear)
hasLeap = true
}
offset += _monthDays(lunarYear, m)
}
if (isLeap && lunarMonth === leapM) {
offset += _monthDays(lunarYear, lunarMonth)
}
offset += lunarDay - 1
const result = new Date(BASE_DATE.getTime() + offset * 86400000)
return result
}
/**
* 获取指定农历月日在今年或明年的公历日期
*/
function getNextLunarDate(lunarMonth, lunarDay) {
const today = new Date()
const currentYear = today.getFullYear()
const lunarToday = solarToLunar(today)
// 尝试今年
let candidate = lunarToSolar(lunarToday.year, lunarMonth, lunarDay, false)
if (candidate >= today) return candidate
// 明年
return lunarToSolar(lunarToday.year + 1, lunarMonth, lunarDay, false)
}
function getLunarMonthName(month) {
return LUNAR_MONTHS[month - 1] || ''
}
function getLunarDayName(day) {
if (day === 10) return '初十'
if (day === 20) return '二十'
if (day === 30) return '三十'
const tens = Math.floor(day / 10)
const units = day % 10
return LUNAR_DAYS_TENS[tens] + (units > 0 ? LUNAR_DAYS_UNITS[units - 1] : '')
}
function formatLunarText(lunarDate) {
return solarToLunar(new Date(lunarDate.year, lunarDate.month - 1, lunarDate.day)).lunarText
}
module.exports = {
solarToLunar,
lunarToSolar,
getNextLunarDate,
formatLunarText,
getLunarMonthName,
getLunarDayName
}
+16
View File
@@ -1,6 +1,7 @@
const cron = require('node-cron')
const db = require('./db')
const wx = require('./wx')
const lunar = require('./lunar')
const TEMPLATE_ID = process.env.WX_TEMPLATE_ID
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
@@ -26,9 +27,24 @@ function formatDate(date) {
}
// 算出"距离今年(或明年)这个纪念日还有多少天"
// 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay
function getThisYearDate(anniv) {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) {
// 闰月生日按 A 方案处理:若当年无对应闰月,lunarToSolar 内部已自动按普通月份算
const todayLunar = lunar.solarToLunar(today)
const wantLeap = !!anniv.isLeapMonth
let target = lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap)
target.setHours(0, 0, 0, 0)
if (target < today) {
target = lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap)
target.setHours(0, 0, 0, 0)
}
return target
}
let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)
target.setHours(0, 0, 0, 0)
if (target < today) {
+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 }